feat: editor language menu, title field, header actions, publish dialogs, read-only rendering and draft recovery
This commit is contained in:
1 parent
a671b81f80
commit
339e646eb8
85 files changed
+6369
-624
No files matched your search
@@ -15,8 +15,10 @@ Feature: Editor screen — load, edit and save an article's body
|
|||||||
/article (mobile top bar title "Edit article" instead), an ArticleStatusBadge size "md"
|
/article (mobile top bar title "Edit article" instead), an ArticleStatusBadge size "md"
|
||||||
reflecting article.is_online ("Draft" or "Online"), a SaveStatus span, a Save button,
|
reflecting article.is_online ("Draft" or "Online"), a SaveStatus span, a Save button,
|
||||||
and a PublishControl button, per docs/design/components.md
|
and a PublishControl button, per docs/design/components.md
|
||||||
And the article's title is never editable after creation, per
|
And the title is an editable field labelled "Title" ("Title (French)" when the site has more
|
||||||
features/article_title_rules.feature; only the body is edited here
|
than one enabled language), per features/localization_editor_navigation.feature, which
|
||||||
|
supersedes features/article_title_rules.feature's "never editable"; the body is edited in
|
||||||
|
ArticleEditor
|
||||||
And the body is edited in ArticleEditor (modelValue bound to the fetched HTML body); any
|
And the body is edited in ArticleEditor (modelValue bound to the fetched HTML body); any
|
||||||
change moves useArticleDraft's state from "clean" to "dirty", and PublishControl's label
|
change moves useArticleDraft's state from "clean" to "dirty", and PublishControl's label
|
||||||
switches from "Publish" (or "Unpublish", if online) to "Save and publish" while dirty and
|
switches from "Publish" (or "Unpublish", if online) to "Save and publish" while dirty and
|
||||||
@@ -32,17 +34,21 @@ Feature: Editor screen — load, edit and save an article's body
|
|||||||
And a successful save (200) sets useArticleDraft's state to "saved" and records the save
|
And a successful save (200) sets useArticleDraft's state to "saved" and records the save
|
||||||
time; SaveStatus shows "Saved at {HH:MM}" for the first hour and "Saved {n} min ago" after
|
time; SaveStatus shows "Saved at {HH:MM}" for the first hour and "Saved {n} min ago" after
|
||||||
an hour has passed, per docs/design/components.md
|
an hour has passed, per docs/design/components.md
|
||||||
|
And the Save button reads "Save" for every caller allowed to edit; the "Save draft" label of
|
||||||
|
callers who cannot publish no longer exists
|
||||||
And a 500 response or a request that never reaches the server sets useArticleDraft's state
|
And a 500 response or a request that never reaches the server sets useArticleDraft's state
|
||||||
to "error"; SaveStatus shows "Save failed" with an inline Retry button (UButton size="xs"
|
to "error"; SaveStatus shows "Save failed" and the header's primary button reads "Try saving
|
||||||
variant="outline"); the edited body is kept exactly as typed, both in the editor and for
|
again", per features/localization_editor_navigation.feature (the inline Retry button of
|
||||||
the next Retry
|
SaveStatus is no longer rendered in the editor); the edited body is kept exactly as typed,
|
||||||
|
both in the editor and for the next retry
|
||||||
And a 404 response to a save (the article was deleted elsewhere) does not use the "error"
|
And a 404 response to a save (the article was deleted elsewhere) does not use the "error"
|
||||||
SaveStatus; it shows a toast "That article no longer exists.", per docs/design/states.md's
|
SaveStatus; it shows a toast "That article no longer exists.", per docs/design/states.md's
|
||||||
HTTP-status-to-copy table
|
HTTP-status-to-copy table
|
||||||
And a 401 response to a save is handled by useApi's global handling
|
And a 401 response to a save is handled by useApi's global handling
|
||||||
(features/api_client_global_error_handling.feature): before the redirect, useArticleDraft
|
(features/api_client_global_error_handling.feature): before the redirect, useArticleDraft
|
||||||
mirrors the current dirty body to sessionStorage keyed by the article's id, so it survives
|
mirrors the current dirty title, body and search fields to sessionStorage keyed by the user
|
||||||
the redirect to /login?redirect=/article/{id}/edit&reason=expired
|
id and the article id, with the time of the mirror, so it survives the redirect to
|
||||||
|
/login?redirect=/article/{id}/edit&reason=expired
|
||||||
And EditorStatusBar shows a neutral "{words} words · {characters} characters" count with
|
And EditorStatusBar shows a neutral "{words} words · {characters} characters" count with
|
||||||
en-US thousands separators (e.g. "1,204 words · 7,318 characters", "0 words · 0 characters"
|
en-US thousands separators (e.g. "1,204 words · 7,318 characters", "0 words · 0 characters"
|
||||||
for an empty body); characters is the body's HTML string length, words are the
|
for an empty body); characters is the body's HTML string length, words are the
|
||||||
@@ -65,10 +71,11 @@ Feature: Editor screen — load, edit and save an article's body
|
|||||||
proceeds with the navigation, discarding the edit; "Keep editing" closes the dialog and
|
proceeds with the navigation, discarding the edit; "Keep editing" closes the dialog and
|
||||||
keeps the user on the editor with the edit intact; navigating away while "clean" or
|
keeps the user on the editor with the edit intact; navigating away while "clean" or
|
||||||
"saved" never opens this dialog
|
"saved" never opens this dialog
|
||||||
And an id absent from the repository (GET resolves 404) replaces the entire editor with an
|
And an id absent from the repository (GET resolves 404) replaces the entire editor with the
|
||||||
error state: title "This article doesn't exist.", and a single action "All articles"
|
Version unavailable state: title "This version isn't available", text "It may have been
|
||||||
navigating to /article; no breadcrumb-title, SaveStatus, Save, PublishControl or
|
deleted, or you may not have access.", and a single action "Back to Articles" navigating
|
||||||
ArticleEditor renders in this state
|
to /article; no header, SaveStatus, Save, PublishControl or ArticleEditor renders in this
|
||||||
|
state
|
||||||
And skeleton placeholders (for the title, content and details) replace the editor's content
|
And skeleton placeholders (for the title, content and details) replace the editor's content
|
||||||
only after 150 ms of GET /v1/article/{id} being unresolved, so a fast response never
|
only after 150 ms of GET /v1/article/{id} being unresolved, so a fast response never
|
||||||
flashes a skeleton, per the Skeleton component spec
|
flashes a skeleton, per the Skeleton component spec
|
||||||
@@ -132,12 +139,12 @@ Feature: Editor screen — load, edit and save an article's body
|
|||||||
When more than an hour has passed with no further edit or save
|
When more than an hour has passed with no further edit or save
|
||||||
Then SaveStatus reads "Saved {n} min ago" instead of "Saved at 11:02"
|
Then SaveStatus reads "Saved {n} min ago" instead of "Saved at 11:02"
|
||||||
|
|
||||||
Scenario Outline: Save failed — a 500 or a network failure keeps the changes and offers Retry
|
Scenario Outline: Save failed — a 500 or a network failure keeps the changes and offers Try saving again
|
||||||
Given the user is on /article/12/edit with unsaved changes to the body "Revised opening
|
Given the user is on /article/12/edit with unsaved changes to the body "Revised opening
|
||||||
paragraph."
|
paragraph."
|
||||||
When the user activates Save
|
When the user activates Save
|
||||||
And PATCH /v1/article/12 <failure>
|
And PATCH /v1/article/12 <failure>
|
||||||
Then SaveStatus reads "Save failed" with a Retry button
|
Then SaveStatus reads "Save failed" and the primary button reads "Try saving again"
|
||||||
And the editor still shows the body "Revised opening paragraph."
|
And the editor still shows the body "Revised opening paragraph."
|
||||||
And no navigation occurs
|
And no navigation occurs
|
||||||
|
|
||||||
@@ -149,7 +156,7 @@ Feature: Editor screen — load, edit and save an article's body
|
|||||||
Scenario: Retry resends the same body and can recover
|
Scenario: Retry resends the same body and can recover
|
||||||
Given the user is on /article/12/edit in the Save failed state, with unsaved body "Revised
|
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
|
opening paragraph." and PATCH /v1/article/12 having failed with a 500
|
||||||
When the user activates Retry
|
When the user activates "Try saving again"
|
||||||
Then PATCH /v1/article/12 is sent again with JSON {"body": "Revised opening paragraph."}
|
Then PATCH /v1/article/12 is sent again with JSON {"body": "Revised opening paragraph."}
|
||||||
When it resolves 200 with the updated article
|
When it resolves 200 with the updated article
|
||||||
Then SaveStatus reads "Saved at {current time}"
|
Then SaveStatus reads "Saved at {current time}"
|
||||||
@@ -214,7 +221,7 @@ Feature: Editor screen — load, edit and save an article's body
|
|||||||
When the user activates Save
|
When the user activates Save
|
||||||
And PATCH /v1/article/12 resolves 401
|
And PATCH /v1/article/12 resolves 401
|
||||||
Then the body "Paragraph added just before the session expired." is mirrored to
|
Then the body "Paragraph added just before the session expired." is mirrored to
|
||||||
sessionStorage keyed by article id 12
|
sessionStorage keyed by user id and article id 12
|
||||||
And the app navigates to /login?redirect=/article/12/edit&reason=expired
|
And the app navigates to /login?redirect=/article/12/edit&reason=expired
|
||||||
When the user logs in again and is returned to /article/12/edit
|
When the user logs in again and is returned to /article/12/edit
|
||||||
And GET /v1/article/12 resolves 200 with the last-saved body (without that paragraph)
|
And GET /v1/article/12 resolves 200 with the last-saved body (without that paragraph)
|
||||||
@@ -225,8 +232,8 @@ Feature: Editor screen — load, edit and save an article's body
|
|||||||
Scenario: An unknown or deleted article id replaces the editor with an error state
|
Scenario: An unknown or deleted article id replaces the editor with an error state
|
||||||
Given GET /v1/article/9999 resolves 404
|
Given GET /v1/article/9999 resolves 404
|
||||||
When the user navigates to /article/9999/edit
|
When the user navigates to /article/9999/edit
|
||||||
Then the editor shows a single error state: title "This article doesn't exist." and one
|
Then the editor shows a single Version unavailable state: title "This version isn't
|
||||||
action "All articles"
|
available" and one action "Back to Articles"
|
||||||
And no breadcrumb-title, SaveStatus, Save, PublishControl or ArticleEditor renders
|
And no header, SaveStatus, Save, PublishControl or ArticleEditor renders
|
||||||
When the user activates "All articles"
|
When the user activates "Back to Articles"
|
||||||
Then the app navigates to /article
|
Then the app navigates to /article
|
||||||
+13
-12
@@ -4,16 +4,15 @@
|
|||||||
|
|
||||||
`docs/design/components.md`'s `ErrorView` only documents two fixed copies (`kind: 'not-found'` →
|
`docs/design/components.md`'s `ErrorView` only documents two fixed copies (`kind: 'not-found'` →
|
||||||
"Page not found" / Home, and `kind: 'error'` → the generic retry copy). The editor's own 404-on-
|
"Page not found" / Home, and `kind: 'error'` → the generic retry copy). The editor's own 404-on-
|
||||||
load state needs different copy ("This article doesn't exist." / "All articles" → /article),
|
load state shows the Version unavailable copy ("This version isn't available" / "Back to
|
||||||
per `docs/design/states.md`'s HTTP-status-to-copy table. Either widen `ErrorView` with an optional
|
Articles" → /article), per `features/localization_editor_navigation.feature`, which supersedes the
|
||||||
title/description/action override, or add a third `kind`; this is a same-layer prop widening, not
|
original "This article doesn't exist." copy; it is rendered by `EditorVersionGone`.
|
||||||
a new component, so it doesn't need its own slice.
|
|
||||||
|
|
||||||
Known limitation, not a bug to silently fix: per `features/public_article_read.feature`,
|
Known limitation, not a bug to silently fix: per `features/public_article_read.feature`,
|
||||||
`GET /v1/article/{id}` falls back to public online-only visibility on a malformed/expired jwt
|
`GET /v1/article/{id}` falls back to public online-only visibility on a malformed/expired jwt
|
||||||
(no 401). So if a session is already stale when the owner opens an *offline* article, the GET
|
(no 401). So if a session is already stale when the owner opens an *offline* article, the GET
|
||||||
answers 404 exactly like a truly deleted article — the editor cannot tell the two apart from this
|
answers 404 exactly like a truly deleted article — the editor cannot tell the two apart from this
|
||||||
response alone, and correctly shows the "This article doesn't exist." state.
|
response alone, and correctly shows the "This version isn't available" state.
|
||||||
|
|
||||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||||
|
|
||||||
@@ -46,9 +45,9 @@ Fixture article (reused across cases unless a scenario overrides a field): id 12
|
|||||||
6. **Relative save time after an hour** — after a 200 save at 11:02, advance the fake clock past
|
6. **Relative save time after an hour** — after a 200 save at 11:02, advance the fake clock past
|
||||||
one hour with no further edit; assert SaveStatus switches to "Saved {n} min ago".
|
one hour with no further edit; assert SaveStatus switches to "Saved {n} min ago".
|
||||||
7. **Save failed, Examples-driven** — (a) `PATCH` resolves 500, (b) `PATCH` rejects (network);
|
7. **Save failed, Examples-driven** — (a) `PATCH` resolves 500, (b) `PATCH` rejects (network);
|
||||||
in each, assert SaveStatus "Save failed" with a visible Retry button, and the editor still
|
in each, assert SaveStatus "Save failed" with the primary button "Try saving again", and the editor still
|
||||||
shows the typed body.
|
shows the typed body.
|
||||||
8. **Retry recovers** — from Save failed (500), click Retry; assert `PATCH` is called again with
|
8. **Retry recovers** — from Save failed (500), click "Try saving again"; assert `PATCH` is called again with
|
||||||
the same body; resolve 200; assert SaveStatus "Saved at {time}".
|
the same body; resolve 200; assert SaveStatus "Saved at {time}".
|
||||||
9. **404 on save shows a toast, not the error SaveStatus** — from Unsaved changes, save; resolve
|
9. **404 on save shows a toast, not the error SaveStatus** — from Unsaved changes, save; resolve
|
||||||
`PATCH` 404; assert a toast "That article no longer exists." appears and SaveStatus never
|
`PATCH` 404; assert a toast "That article no longer exists." appears and SaveStatus never
|
||||||
@@ -74,15 +73,17 @@ Fixture article (reused across cases unless a scenario overrides a field): id 12
|
|||||||
assert the router navigates to `/article` with no ConfirmDialog ever rendered.
|
assert the router navigates to `/article` with no ConfirmDialog ever rendered.
|
||||||
17. **Session expiry mid-save mirrors and restores the draft** — from Unsaved changes with body
|
17. **Session expiry mid-save mirrors and restores the draft** — from Unsaved changes with body
|
||||||
"Paragraph added just before the session expired.", save; resolve `PATCH` 401; assert
|
"Paragraph added just before the session expired.", save; resolve `PATCH` 401; assert
|
||||||
`sessionStorage` holds that body keyed by article id 12, and the router navigates to
|
`sessionStorage` holds that body keyed by user id and article id 12 (key
|
||||||
|
`renewcms:article-draft:{userId}:12`, a JSON object with title, body, search fields and the
|
||||||
|
mirror time), and the router navigates to
|
||||||
`/login?redirect=/article/12/edit&reason=expired`. Then, simulating the return trip, mount
|
`/login?redirect=/article/12/edit&reason=expired`. Then, simulating the return trip, mount
|
||||||
EditorPage again at `/article/12/edit` with `GET /v1/article/12` resolving 200 with a body
|
EditorPage again at `/article/12/edit` with `GET /v1/article/12` resolving 200 with a body
|
||||||
that lacks that paragraph; assert the rendered body is the restored sessionStorage draft
|
that lacks that paragraph; assert the rendered body is the restored sessionStorage draft
|
||||||
(with the paragraph) and SaveStatus reads "Unsaved changes".
|
(with the paragraph) and SaveStatus reads "Unsaved changes".
|
||||||
18. **Not found on load** — mock `GET /v1/article/9999` resolving 404; mount at
|
18. **Not found on load** — mock `GET /v1/article/9999` resolving 404; mount at
|
||||||
`/article/9999/edit`; assert only the error state renders (title "This article doesn't
|
`/article/9999/edit`; assert only the Version unavailable state renders (title "This version
|
||||||
exist.", action "All articles"), with no breadcrumb-title/SaveStatus/Save/PublishControl/
|
isn't available", action "Back to Articles"), with no header/SaveStatus/Save/PublishControl/
|
||||||
ArticleEditor; click "All articles"; assert the router navigates to `/article`.
|
ArticleEditor; click "Back to Articles"; assert the router navigates to `/article`.
|
||||||
|
|
||||||
## Visual (manual verification — desktop and mobile, light and dark)
|
## Visual (manual verification — desktop and mobile, light and dark)
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ reusing the sample data below and a fixed `now` of "2026-09-26T11:02:00Z" (updat
|
|||||||
"N words · 4,318 characters", details panel (Created/Updated/ID/Images/Public API URL).
|
"N words · 4,318 characters", details panel (Created/Updated/ID/Images/Public API URL).
|
||||||
- [ ] Unsaved changes: "Unsaved changes" SaveStatus, enabled Save, "Save and publish".
|
- [ ] Unsaved changes: "Unsaved changes" SaveStatus, enabled Save, "Save and publish".
|
||||||
- [ ] Saving: "Saving…" SaveStatus, disabled Save.
|
- [ ] Saving: "Saving…" SaveStatus, disabled Save.
|
||||||
- [ ] Save failed: "Save failed" with Retry, per the SaveStatus error spec.
|
- [ ] Save failed: "Save failed" with the "Try saving again" primary, per the SaveStatus error spec.
|
||||||
- [ ] Very long article: count "19,845 words · 120,000 characters", neutral, Save/Publish enabled.
|
- [ ] Very long article: count "19,845 words · 120,000 characters", neutral, Save/Publish enabled.
|
||||||
- [ ] Preview: read-only rendered title and body in the content scale, toolbar hidden.
|
- [ ] Preview: read-only rendered title and body in the content scale, toolbar hidden.
|
||||||
- [ ] Offline: SaveStatus shows the wifi-off icon and reads "Waiting for connection", disabled
|
- [ ] Offline: SaveStatus shows the wifi-off icon and reads "Waiting for connection", disabled
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
Feature: Editor language navigation, title, header actions and permission states
|
||||||
|
The article editor at /article/:id/edit opens one language version of an article. A language menu switches between the readable versions, the title is an editable field, the header shows one primary action chosen from the version's capabilities, and every control renders from capabilities[] on GET /v1/article/{id}. Unsaved work survives a session expiry and is restored for the same user and article only. SPA only: the backend contract of features/article_story_lifecycle.feature, features/article_edit_revision.feature and features/site_languages.feature is unchanged.
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given /article/:id/edit is a verified route rendered by EditorView inside DashboardShell, per docs/design/routes.md
|
||||||
|
And the visual spec is docs/design/screens/localization-editor.md (groups L5, L5b, L8) and docs/design/screens/localization-conflicts-moves.md (loc.l11.session, loc.l11.restored), with the copy drawn in docs/design/source/LocaleFrame.dc.html
|
||||||
|
And error responses are plain text from http.Error, never JSON, and the SPA never shows an HTTP status name, a revision number or an id
|
||||||
|
And GET /v1/article/{id} carries lang, story_id, capabilities[] and translations[] as in features/article_story_lifecycle.feature
|
||||||
|
And capabilities[] holds any of "read", "edit", "publish", "delete", "create_translation" and is the only input for showing or enabling an action
|
||||||
|
And translations[] lists readable versions only, default language first then by tag, each with id, lang, title, is_online, language_enabled and capabilities, and includes the version being read
|
||||||
|
And GET /v1/site/{key}/languages (readable by any signed-in user) gives the site's enabled languages and the default, and supplies the languages that have no version yet
|
||||||
|
And the UI says "article", never "story", and names the language in action copy only when the site has more than one enabled language
|
||||||
|
And site "journal" has enabled languages en (default), fr, de, ar and disabled language es
|
||||||
|
And story 5 holds article 10 (en, online, "Renovation costs"), article 12 (fr, offline, "Couts de renovation"), article 11 (de, offline, not readable to the caller) and article 13 (ar, online, "Takalif")
|
||||||
|
And "owner" holds capabilities ["read","edit","publish","delete","create_translation"] on every version
|
||||||
|
And PATCH /v1/article/{id} is sent without "if_revision" in this slice (last write wins, as today); the revision conflict dialog belongs to localization_translation_flows
|
||||||
|
|
||||||
|
Rule: The language menu
|
||||||
|
|
||||||
|
Scenario: The menu lists readable versions in API order with their state
|
||||||
|
Given the owner is on /article/12/edit
|
||||||
|
When the owner activates the language control named "Language: French. Switch version"
|
||||||
|
Then a menu opens, not a listbox, with the rows English, Arabic and French and no German version row
|
||||||
|
And German appears only as an "Add translation" row, because the owner holds create_translation
|
||||||
|
And each row shows its language name, its code and a publication badge ("Published" or "Draft")
|
||||||
|
And the row for French is marked current
|
||||||
|
And the order is English (default), Arabic, French, following translations[]
|
||||||
|
|
||||||
|
Scenario Outline: Row tags and absent rows follow each sibling's capabilities
|
||||||
|
Given the caller reads article 12 and the sibling <sibling> has capabilities <caps>
|
||||||
|
When the language menu opens
|
||||||
|
Then <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| sibling | caps | result |
|
||||||
|
| Arabic (13) | ["read"] | the Arabic row is tagged "Read only" and opens that version |
|
||||||
|
| German (11) | not in translations[] | no German version row, no count, no placeholder; for a caller without create_translation no German row at all, for a caller with it only an "Add translation" row |
|
||||||
|
| English (10) | ["read","edit"] | the English row has no "Read only" tag |
|
||||||
|
| French disabled | language_enabled false | the row stays listed and opens, marked as a disabled language |
|
||||||
|
|
||||||
|
Scenario Outline: Missing languages offer Add translation only to a caller who can create
|
||||||
|
Given the site enables en, fr, de and ar and article 20 exists only in en
|
||||||
|
And the caller's capabilities on article 20 are <caps>
|
||||||
|
When the language menu opens
|
||||||
|
Then <rows>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| caps | rows |
|
||||||
|
| ["read","edit","create_translation"] | missing languages, including languages whose version exists but is unreadable to the caller, appear as rows with an "Add translation" action |
|
||||||
|
| ["read","edit"] | the missing languages appear nowhere and nothing is offered |
|
||||||
|
|
||||||
|
Scenario Outline: Menu size
|
||||||
|
Given the site enables <count> languages and the caller reads <readable> versions
|
||||||
|
When the language menu opens
|
||||||
|
Then <layout>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| count | readable | layout |
|
||||||
|
| 4 | 4 | all four rows are visible without a filter |
|
||||||
|
| 2 | 2 | the two rows are visible without a filter |
|
||||||
|
| many | many | the list scrolls inside the viewport and a filter field narrows it by language name |
|
||||||
|
|
||||||
|
Scenario: The menu is keyboard operable
|
||||||
|
Given the language menu is closed with focus on its control
|
||||||
|
When the user presses Enter or ArrowDown
|
||||||
|
Then the menu opens with focus on the current row
|
||||||
|
When the user presses ArrowDown and Enter
|
||||||
|
Then the next row is chosen
|
||||||
|
When the menu is open and the user presses Escape
|
||||||
|
Then it closes and focus returns to the control
|
||||||
|
|
||||||
|
Scenario: A single-language site shows no language control
|
||||||
|
Given site "archive" enables one language and article 30 belongs to it
|
||||||
|
When the owner opens /article/30/edit
|
||||||
|
Then no language control, language badge or language name appears in the header or the action labels
|
||||||
|
And the primary action reads "Publish", not "Publish French"
|
||||||
|
|
||||||
|
Rule: Switching versions opens another article id
|
||||||
|
|
||||||
|
Scenario: Switching from a clean editor
|
||||||
|
Given the owner is on /article/12/edit with no unsaved changes
|
||||||
|
When the owner chooses English in the language menu
|
||||||
|
Then GET /v1/article/10 is requested before the route changes
|
||||||
|
And on 200 the route becomes /article/10/edit, the title shows "Renovation costs" and the control reads English
|
||||||
|
And a polite announcement reads "Editing English version"
|
||||||
|
|
||||||
|
Scenario: A slow switch shows a loading state
|
||||||
|
Given GET /v1/article/10 has not resolved 150 ms after the choice
|
||||||
|
Then the editor shows the Loading version state (loc.l5.loading) and the current version stays on screen until the response arrives
|
||||||
|
When GET /v1/article/10 resolves in under 150 ms
|
||||||
|
Then no loading state is ever shown
|
||||||
|
|
||||||
|
Scenario: A failed switch keeps the current version
|
||||||
|
Given the owner is on /article/12/edit
|
||||||
|
When the owner chooses English and GET /v1/article/10 answers 500 or never reaches the server
|
||||||
|
Then a toast reads "Couldn't open the English version" with "You're still editing French. Try again."
|
||||||
|
And the route is still /article/12/edit and the title and unsaved state are untouched
|
||||||
|
|
||||||
|
Scenario: The editor opened directly on an unavailable version
|
||||||
|
Given GET /v1/article/9999 answers 404 "Article not found"
|
||||||
|
When the owner navigates to /article/9999/edit
|
||||||
|
Then the editor shows only the Version unavailable state with the title "This version isn't available" and the text "It may have been deleted, or you may not have access."
|
||||||
|
And one action "Back to Articles" navigates to /article
|
||||||
|
And no header, SaveStatus, Save, primary action, language control or editor surface renders
|
||||||
|
|
||||||
|
Scenario Outline: Choosing another version with unsaved changes asks first
|
||||||
|
Given the owner is on /article/12/edit with unsaved changes to the French title
|
||||||
|
When the owner chooses English in the language menu
|
||||||
|
Then a dialog titled "Leave without saving?" reads "Your changes to the French version aren't saved."
|
||||||
|
And it offers "Keep editing", "Discard and open English" and "Save and open English" in that order
|
||||||
|
When the owner activates <choice>
|
||||||
|
Then <outcome>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| choice | outcome |
|
||||||
|
| Keep editing | the dialog closes, the route stays /article/12/edit, the unsaved title is intact, no request is sent |
|
||||||
|
| Discard and open English | the local French draft is removed, then the route becomes /article/10/edit and nothing is saved |
|
||||||
|
| Save and open English | PATCH /v1/article/12 is sent first and on 200 the route becomes /article/10/edit |
|
||||||
|
|
||||||
|
Scenario: Save and open English stops when the save fails
|
||||||
|
Given the "Leave without saving?" dialog is open for the French version
|
||||||
|
When the owner activates "Save and open English" and PATCH /v1/article/12 answers 500
|
||||||
|
Then the route stays /article/12/edit, the edits stay unsaved and the Save failed state of the header shows
|
||||||
|
And no request for article 10 is sent
|
||||||
|
|
||||||
|
Rule: The title is an editable field
|
||||||
|
|
||||||
|
Scenario: The title replaces the heading and the old tooltip
|
||||||
|
Given the owner opens /article/12/edit
|
||||||
|
Then the title is an input in heading size labelled "Title (French)" holding "Couts de renovation"
|
||||||
|
And the tooltip "The title can't be changed after creation." no longer exists anywhere
|
||||||
|
And on a single-language site the label is "Title"
|
||||||
|
|
||||||
|
Scenario Outline: Title field states
|
||||||
|
Given the owner is on /article/12/edit
|
||||||
|
When <action>
|
||||||
|
Then the field shows <look>
|
||||||
|
And the helper text is <helper>
|
||||||
|
And Save is <save>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| action | look | helper | save |
|
||||||
|
| the page loads, title untouched | neutral border | none | disabled |
|
||||||
|
| the field gets focus, nothing changed | primary outline | "Changing the title doesn't change the address." | disabled |
|
||||||
|
| the user types "Couts reels" | primary border, counter "11 / 200" | "Changing the title doesn't change the address." | enabled |
|
||||||
|
| the user changes it and focus leaves | neutral border and Unsaved changes | "Changing the title doesn't change the address." | enabled |
|
||||||
|
| the user clears the field | error border and icon, aria-invalid | "Enter a title" as an alert | blocked |
|
||||||
|
| the user types "ab" | error border, counter "2 / 200" | "Use at least 3 characters" | blocked |
|
||||||
|
| the user types 203 characters | error border, counter "203 / 200" | "Use 200 characters or fewer" | blocked |
|
||||||
|
|
||||||
|
Scenario: Saving the title sends it with the other edits
|
||||||
|
Given the owner changed the title to "Couts reels" and the body
|
||||||
|
When the owner activates Save
|
||||||
|
Then PATCH /v1/article/12 is sent with the title, the body and any changed search fields in one request
|
||||||
|
And the field shows a spinner and "Saving title…" while in flight
|
||||||
|
And on 200 the header title, the breadcrumb and the language menu row all read "Couts reels"
|
||||||
|
And the slug is not touched and no slug field is sent
|
||||||
|
|
||||||
|
Scenario: A failed title save keeps the text
|
||||||
|
Given the owner changed the title to "Couts reels"
|
||||||
|
When PATCH /v1/article/12 answers 500
|
||||||
|
Then the field keeps "Couts reels" with an error border and icon
|
||||||
|
And the helper reads "Couldn't save the title. Your text is kept." with a "Try again" action
|
||||||
|
And the state is still unsaved
|
||||||
|
When the owner activates "Try again"
|
||||||
|
Then the same PATCH is sent again and focus returns to the field
|
||||||
|
|
||||||
|
Scenario: The server rejects a title the client allowed
|
||||||
|
Given the owner types a title the server answers 400 with apperr.ArticleTitleErrorMessage
|
||||||
|
Then the field shows the invalid state with that plain-text message and the edit is kept
|
||||||
|
|
||||||
|
Rule: One primary action in the header
|
||||||
|
|
||||||
|
Scenario Outline: Header priority and action matrix
|
||||||
|
Given the version is <state> and the caller's capabilities are <caps>
|
||||||
|
Then the filled primary button is <primary>
|
||||||
|
And the secondary buttons are <secondary>
|
||||||
|
And <note>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| state | caps | primary | secondary | note |
|
||||||
|
| clean draft | edit, publish | "Publish French" | Save (disabled) | loc.l5.default |
|
||||||
|
| clean draft | edit | none | Save (disabled) | the hint "Publishing needs the publish permission" shows |
|
||||||
|
| dirty draft | edit | "Save" | none | loc.l5.dirty; the hint "Saving keeps it as a draft." shows |
|
||||||
|
| dirty draft | edit, publish | "Save and publish French" | Save | loc.l5.dirtyPublish |
|
||||||
|
| clean online | edit, publish | none | "Unpublish French", Save (disabled) | the alert "This version is online. Saving updates the live page right away." shows |
|
||||||
|
| dirty online | edit, publish | "Save" | "Unpublish French" | the same online alert shows |
|
||||||
|
| saving | any | "Saving…" busy | none | aria-busy is set on the button |
|
||||||
|
| saved | edit, publish | "Publish French" | Save (disabled) | loc.l5.saved |
|
||||||
|
| save failed | edit | "Try saving again" | none | nothing was published |
|
||||||
|
| publish failed after save | edit, publish | "Retry publish" | Save (disabled) | edits show as saved |
|
||||||
|
| publish without edit | publish | "Publish French" | none | fields are read-only |
|
||||||
|
| read-only | read | none | none | the reference action would stay; it is release 2 and absent here |
|
||||||
|
|
||||||
|
Scenario: At most one filled button
|
||||||
|
Given any combination of state and capabilities in the matrix
|
||||||
|
Then at most one button in the header is filled
|
||||||
|
|
||||||
|
Scenario: Saving an online version is allowed without publish permission
|
||||||
|
Given the version is online and the caller holds ["read","edit"]
|
||||||
|
Then Save is available, no Publish or Unpublish button renders and the online alert shows
|
||||||
|
|
||||||
|
Scenario Outline: Collapse order by width
|
||||||
|
Given the owner is on the clean online French version with edit and publish capabilities
|
||||||
|
When the viewport is <width> px wide
|
||||||
|
Then <layout>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| width | layout |
|
||||||
|
| 1440 | title, language, publication badge, status badge, Unpublish French, Save and More actions are all visible with their labels |
|
||||||
|
| 1024 | Unpublish moves into the More actions menu; the status badge and the other badges keep their words |
|
||||||
|
| 390 | title, language, publication and dirty badges stay at the top; Save, the primary and an overflow button sit in a fixed bottom bar; Unpublish, Edit slug, Move and Delete go to the overflow sheet; every touch target is at least 44 px high |
|
||||||
|
|
||||||
|
Scenario: The overflow menu follows capabilities
|
||||||
|
Given the owner opens More actions on the French version
|
||||||
|
Then the menu holds Edit slug, Move article and Delete items, and Unpublish when the width collapsed it
|
||||||
|
And on mobile it opens as a sheet
|
||||||
|
When the caller lacks the capability for an item
|
||||||
|
Then the item is shown disabled with the reason as visible text and aria-disabled
|
||||||
|
And Edit slug, Move article and Delete article are shown disabled with the reason "Not available yet." until the slice that wires them
|
||||||
|
|
||||||
|
Scenario: Cannot delete the whole article (P6)
|
||||||
|
Given the caller can create and modify only their own versions and holds ["read","edit","publish","delete","create_translation"] on French but not on a sibling
|
||||||
|
When the caller opens More actions
|
||||||
|
Then "Delete article" and "Move article" are disabled with the reason "You can't delete this article."
|
||||||
|
And "Delete French version" is enabled
|
||||||
|
|
||||||
|
Rule: Save and publish
|
||||||
|
|
||||||
|
Scenario: Save and publish saves first, then publishes only that version
|
||||||
|
Given the owner is on the dirty French draft with edit and publish capabilities
|
||||||
|
When the owner activates "Save and publish French"
|
||||||
|
Then a dialog titled "Save and publish French?" reads "Your unsaved changes to the French version are saved first, then only the French version goes online. English and German aren't affected."
|
||||||
|
When the owner confirms
|
||||||
|
Then PATCH /v1/article/12 is sent first
|
||||||
|
And only after it answers 200 is PATCH /v1/article/12/publish sent
|
||||||
|
And no request touches articles 10, 11 or 13
|
||||||
|
And a toast reads "French published" with "Other versions weren't changed."
|
||||||
|
|
||||||
|
Scenario: A failed save publishes nothing
|
||||||
|
Given the owner confirmed "Save and publish French"
|
||||||
|
When PATCH /v1/article/12 answers 500
|
||||||
|
Then PATCH /v1/article/12/publish is never sent
|
||||||
|
And a toast reads "Couldn't save French" with "Nothing was published. Your changes are kept. Try again."
|
||||||
|
And the banner reads "Couldn't save French" with "Your changes are kept here. Nothing was published. Try saving again." and the primary reads "Try saving again"
|
||||||
|
And the edits stay unsaved
|
||||||
|
|
||||||
|
Scenario: A failed publish after a good save is not a failed save
|
||||||
|
Given PATCH /v1/article/12 answers 200
|
||||||
|
When PATCH /v1/article/12/publish answers 500
|
||||||
|
Then the banner reads "Saved as a draft. Publishing failed. Try publishing again." with "Your edits are saved. Only the publishing step failed."
|
||||||
|
And the state reads Saved, not Save failed, and the primary reads "Retry publish"
|
||||||
|
When the owner activates "Retry publish"
|
||||||
|
Then only PATCH /v1/article/12/publish is sent
|
||||||
|
|
||||||
|
Scenario: Publishing is blocked in a disabled language
|
||||||
|
Given French is disabled on journal
|
||||||
|
When PATCH /v1/article/12/publish answers 422 "language_disabled"
|
||||||
|
Then a warning reads "French is disabled on this site. Re-enable it to publish." with "You can still edit and save this version. Ask an owner to re-enable French."
|
||||||
|
And Save keeps working
|
||||||
|
|
||||||
|
Scenario Outline: Publishing a clean version
|
||||||
|
Given the owner is on the clean <version> version
|
||||||
|
When the owner activates "<button>"
|
||||||
|
Then a dialog titled "<title>" reads "<text>"
|
||||||
|
When the owner confirms
|
||||||
|
Then <request> is sent and only that version changes
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| version | button | title | text | request |
|
||||||
|
| French draft | Publish French | Publish French? | Only the French version goes online. English and German aren't affected. | PATCH /v1/article/12/publish |
|
||||||
|
| French online | Unpublish French | Unpublish French? | The French version goes back to draft and stops being public. English stays online. | PATCH /v1/article/12/unpublish |
|
||||||
|
|
||||||
|
Scenario: Unpublish never saves local edits
|
||||||
|
Given the owner is on the dirty online French version
|
||||||
|
When the owner activates "Unpublish French"
|
||||||
|
Then the dialog titled "Unpublish French?" reads "The saved French version goes offline and becomes a draft. English stays online." and an alert "Your edits stay in the editor" explains "Your unsaved changes are kept here. Nothing is saved when you unpublish. Save them yourself when you are ready."
|
||||||
|
When the owner confirms
|
||||||
|
Then only PATCH /v1/article/12/unpublish is sent, with no PATCH /v1/article/12
|
||||||
|
And the editor still holds the unsaved edits and the state is still unsaved
|
||||||
|
|
||||||
|
Scenario: Publish fails without a save
|
||||||
|
When a clean publish answers 500
|
||||||
|
Then a toast reads "Couldn't publish French" with "Nothing changed. Try again."
|
||||||
|
|
||||||
|
Rule: Permission-driven rendering
|
||||||
|
|
||||||
|
Scenario Outline: A version without edit renders read-only
|
||||||
|
Given the caller's capabilities on article 12 are <caps>
|
||||||
|
When the caller opens /article/12/edit
|
||||||
|
Then <result>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| caps | result |
|
||||||
|
| ["read"] | a banner reads "You can read this version. You can't edit it." with "The source reference and the versions you can read stay available."; the title and body are read-only (aria-readonly); the formatting toolbar is hidden; no Save, Publish, Unpublish or Delete renders; keyboard Ctrl/⌘ S does nothing |
|
||||||
|
| ["read","publish"] | a notice reads "You can publish this version but not edit it" with "Fields are read-only. Publish stays available."; the title and body are read-only and the toolbar is hidden; "Publish French" (or Unpublish) is available |
|
||||||
|
| ["read","edit"] | a notice reads "Publishing needs the publish permission" with "Saving keeps it as a draft."; Save shows and no Publish control renders |
|
||||||
|
|
||||||
|
Scenario: The read-only menu has no Add translation row
|
||||||
|
Given the caller holds ["read"] on article 12 and cannot create on the site
|
||||||
|
When the language menu opens
|
||||||
|
Then no "Add translation" row is offered for any missing language
|
||||||
|
|
||||||
|
Scenario: Server checks still win
|
||||||
|
Given the caller's capabilities say ["read","edit"] but PATCH /v1/article/12 answers 403 "missing_permission"
|
||||||
|
Then a toast reports the failure, the edit is kept and the state is Save failed
|
||||||
|
|
||||||
|
Rule: Session expiry and local draft recovery
|
||||||
|
|
||||||
|
Scenario: A 401 on save mirrors the draft for this user and article
|
||||||
|
Given user 7 is on /article/12/edit with an unsaved title "Couts reels", body and search fields
|
||||||
|
When the owner activates Save and PATCH /v1/article/12 answers 401
|
||||||
|
Then the title, the body and the search fields are stored in this browser under user 7 and article 12 with the time of the mirror
|
||||||
|
And the app navigates to /login?redirect=/article/12/edit&reason=expired
|
||||||
|
|
||||||
|
Scenario: Restoring after login
|
||||||
|
Given user 7 stored a draft for article 12 at 14:18
|
||||||
|
When user 7 logs in again and the editor loads article 12 (GET answers 200 with older content)
|
||||||
|
Then the editor shows the stored title, body and search fields, not the loaded ones
|
||||||
|
And an info alert reads "Restored unsaved changes from 14:18" with "You logged in again. Your French title and body were kept in this browser."
|
||||||
|
And the state is unsaved
|
||||||
|
|
||||||
|
Scenario: Another account never sees the draft
|
||||||
|
Given user 7 stored a draft for article 12
|
||||||
|
When user 8 opens /article/12/edit
|
||||||
|
Then the loaded article is shown unchanged with no alert and user 7's draft is not read
|
||||||
|
|
||||||
|
Scenario: A draft is for one article
|
||||||
|
Given user 7 stored a draft for article 12
|
||||||
|
When user 7 opens /article/10/edit
|
||||||
|
Then nothing is restored
|
||||||
|
|
||||||
|
Scenario Outline: When the local draft is deleted
|
||||||
|
Given user 7 stored a draft for article 12
|
||||||
|
When <event>
|
||||||
|
Then the stored draft for user 7 and article 12 is removed
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| event |
|
||||||
|
| a save of article 12 answers 200 |
|
||||||
|
| the owner activates "Discard and open English" or "Discard changes" |
|
||||||
|
| user 7 logs out explicitly |
|
||||||
|
| GET /v1/article/12 answers 404 |
|
||||||
|
| GET /v1/article/12 returns capabilities without "edit" |
|
||||||
|
|
||||||
|
Scenario: A session expiry keeps the draft
|
||||||
|
Given a 401 redirects to /login with reason=expired
|
||||||
|
Then the stored draft is not removed by the redirect
|
||||||
|
|
||||||
|
Scenario: A stored draft grants nothing
|
||||||
|
Given a stored draft exists for article 12 and the caller can no longer read it
|
||||||
|
Then the editor shows the Version unavailable state and the draft is removed, never rendered
|
||||||
|
|
||||||
|
Rule: Existing editor behaviour that stays
|
||||||
|
|
||||||
|
Scenario: Skeleton, preview, offline and the leave guard are unchanged
|
||||||
|
Then features/editor_core.feature keeps its skeleton after 150 ms, Preview, Waiting for connection, OfflineBanner and "Leave without saving?" (Discard changes / Keep editing) when navigating away, except where this feature replaces them
|
||||||
|
|
||||||
|
Scenario: Replaced behaviour
|
||||||
|
Then "The title is never editable" in features/editor_core.feature is superseded by the title field above
|
||||||
|
And the session draft key of features/editor_core.feature scenario "Session expired mid-save" is superseded by the user and article scoped key above
|
||||||
|
And the 404-on-load copy "This article doesn't exist." is superseded by the Version unavailable copy above
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# QA: Editor language navigation, title, header actions and permission states
|
||||||
|
|
||||||
|
Accepted together with features/localization_editor_navigation.feature. SPA only, nothing outside web/ changes.
|
||||||
|
|
||||||
|
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||||
|
|
||||||
|
Mock `useApi`'s `request` per case (no backend). Mount EditorView in DashboardShell with a resolved verified session (user id 7, username "marta") and a test router at `/article/:id/edit`. Use fake timers for the 150 ms delays. Specs live in `web/src/views/__tests__/` and `web/src/composables/__tests__/`, reusing `editorMetaKit.ts`.
|
||||||
|
|
||||||
|
Fixture (journal, story 5; add `lang`, `story_id`, `capabilities`, `translations` to the article mock): article 10 en online "Renovation costs"; article 12 fr offline "Couts de renovation", updated_at "2026-09-26T11:02:00Z"; article 13 ar online "Takalif"; article 11 de is never in `translations`. `translations` order en, ar, fr. `GET /v1/site/journal/languages` mocked with en (default), fr, de, ar enabled and es disabled. Capability fixtures: full ["read","edit","publish","delete","create_translation"], ro ["read"], p2 ["read","edit"], p3 ["read","publish"], p6 (full on 12, no delete on a sibling). Error mocks reject with `ApiError` carrying a plain-text body.
|
||||||
|
|
||||||
|
Language menu
|
||||||
|
1. **Menu rows**: open the control "Language: French. Switch version"; assert role menu, rows en, ar, fr in order, no German version row (German only as an Add translation row for create_translation), publication badges, current row marked (loc.l5.menu).
|
||||||
|
2. **Two and many languages**: two-language site shows two rows, no filter (loc.l5.two); many-language site scrolls and the filter field narrows by name (loc.l5.many).
|
||||||
|
3. **Sibling tags**: Examples on sibling capabilities: `["read"]` gives "Read only" tag (loc.perm.siblingReadOnly); absent entry gives no row, no count, no placeholder (loc.perm.siblingHidden); disabled language is listed and marked.
|
||||||
|
4. **Add translation rows**: with create_translation the missing languages (including unreadable existing versions) show "Add translation" rows (loc.l5b.withAdd); without it nothing is offered (loc.l5b.noAdd); read-only menu has none (loc.l5b.roMenu).
|
||||||
|
5. **Keyboard**: Enter/ArrowDown opens, arrows move, Enter chooses, Escape closes and refocuses the control.
|
||||||
|
6. **Single-language site**: no language control, label "Title", primary reads "Publish".
|
||||||
|
|
||||||
|
Switching
|
||||||
|
7. **Clean switch**: choose English; assert `GET /v1/article/10` before `router.push('/article/10/edit')`, then title "Renovation costs".
|
||||||
|
8. **Loading**: GET unresolved past 150 ms shows Loading version and keeps the current version (loc.l5.loading); resolved faster shows nothing.
|
||||||
|
9. **Switch failed**: GET 10 rejects with 500 and with a network error; toast "Couldn't open the English version" / "You're still editing French. Try again."; route and edits untouched (loc.l5.navFail).
|
||||||
|
10. **Version unavailable**: GET 9999 answers 404; only the state with "This version isn't available" and "Back to Articles" renders (loc.l5.gone).
|
||||||
|
11. **Unsaved switch dialog, Examples-driven**: Keep editing, Discard and open English, Save and open English; assert dialog copy and button order (loc.l5.unsaved), requests and route per choice, local draft removed on discard.
|
||||||
|
12. **Save and open English fails**: PATCH 500; route unchanged, no GET for article 10, Save failed shown.
|
||||||
|
|
||||||
|
Title
|
||||||
|
13. **Title field states, Examples-driven**: pristine, focus, editing (counter "11 / 200"), unsaved, empty ("Enter a title" as alert, aria-invalid, Save blocked), "ab" ("Use at least 3 characters"), 203 characters ("203 / 200", "Use 200 characters or fewer"); ids loc.l5.focus, titleEdit, titleUnsaved, invalid, titleShort, titleLong.
|
||||||
|
14. **Title counts code points**: 200 repetitions of the 4-byte emoji is valid.
|
||||||
|
15. **Save sends title with the other edits** in one PATCH, shows "Saving title…" (loc.l5.titleSaving), updates header, breadcrumb and menu row on 200, sends no slug.
|
||||||
|
16. **Title save failed**: PATCH 500; text kept, "Couldn't save the title. Your text is kept.", "Try again" resends and refocuses the field (loc.l5.failed). A 400 shows the plain-text message in the invalid state.
|
||||||
|
|
||||||
|
Header and actions
|
||||||
|
17. **Action matrix, Examples-driven** over every row of the feature table: assert the single filled button, the secondary buttons, the hints and the online alert; assert at most one filled button for every combination of state and capabilities (loc.l5.default, dirty, dirtyPublish, cleanOnline, onlineDirty, saving, saved).
|
||||||
|
18. **Save failed**: banner "Couldn't save French", primary "Try saving again", nothing published (loc.l5.saveFail).
|
||||||
|
19. **Collapse**: with the viewport mocked at 1440, 1024 and 390 assert where Unpublish, Edit slug, Move, Delete and the bottom bar render (loc.l5.ovf for the overflow menu and sheet). Touch targets at 390 have class or computed height of at least 44 px.
|
||||||
|
20. **Overflow items follow capabilities**, disabled items carry `aria-disabled` and the reason text; P6 shows "You can't delete this article." for Delete article and Move, with "Delete French version" enabled (loc.perm.createNotAll). Edit slug, Move article and Delete article carry `aria-disabled` and the reason "Not available yet." until wired by a later slice; Delete French version stays enabled.
|
||||||
|
|
||||||
|
Save and publish
|
||||||
|
21. **Save and publish order**: confirm dialog copy; assert PATCH 12 resolves before PATCH 12/publish is called; no request for 10, 11 or 13; toast "French published" (loc.l8.saveAndPublish, loc.l8.done).
|
||||||
|
22. **Save fails**: publish never called; toast and banner copy (loc.l8.saveFailPublish).
|
||||||
|
23. **Publish fails after save**: Saved state, banner "Saved as a draft. Publishing failed. Try publishing again.", "Retry publish" sends only publish (loc.l5.pubFail).
|
||||||
|
24. **Language disabled**: publish 422 "language_disabled" shows the warning, Save still works (loc.lang.publishDisabled).
|
||||||
|
25. **Clean publish and unpublish dialogs**, Examples-driven, plus failure toast (loc.l8.publish, unpublish, fail).
|
||||||
|
26. **Unpublish with unsaved edits**: only `/unpublish` called, edits kept, still unsaved (loc.l8.unpublishDirty).
|
||||||
|
|
||||||
|
Permissions
|
||||||
|
27. **Read-only, Examples-driven over caps**: ["read"] (loc.perm.readonly), ["read","publish"] (loc.perm.publishNoEdit), ["read","edit"] (loc.perm.editNoPublish): banner copy, `aria-readonly`, toolbar hidden or shown, which action buttons exist, Ctrl/⌘ S does nothing without edit.
|
||||||
|
28. **Server 403 wins** over capabilities: Save failed with edits kept.
|
||||||
|
|
||||||
|
Recovery
|
||||||
|
29. **401 mirrors the draft** keyed by user 7 and article 12 with title, body, search fields and the mirror time; router goes to `/login?redirect=/article/12/edit&reason=expired`.
|
||||||
|
30. **Restore after login**: older server content, stored draft with time "14:18" shown instead; alert "Restored unsaved changes from 14:18"; unsaved state (loc.l11.restored).
|
||||||
|
31. **Isolation**: user 8 sees no alert and no restored content; user 7 on article 10 sees none.
|
||||||
|
32. **Cleanup, Examples-driven**: saved 200, discard, explicit logout, GET 404, GET without "edit"; the redirect after a 401 does not clear it.
|
||||||
|
33. **Draft grants nothing**: stored draft plus 404 shows Version unavailable and removes the draft.
|
||||||
|
|
||||||
|
Regression
|
||||||
|
34. Re-run `EditorView.spec.ts`, `EditorSeo*.spec.ts` and `useArticleDraft.spec.ts`; update the assertions that this feature supersedes (immutable title tooltip, draft key, 404 copy, "Save draft" label) and nothing else.
|
||||||
|
|
||||||
|
## Visual (`task visual:check`)
|
||||||
|
|
||||||
|
Reference: docs/design/screens/localization-editor.md and docs/design/screens/localization-conflicts-moves.md, boards in docs/design/source/. Add each state's setup to `web/visual/localizationStates.ts` first, reusing the board's Journal fixture data from `locale-fixtures.js` (titles, languages, counts, the 14:18 restore time). Every capture must be within 10 %, at the listed sizes, light and dark. No exceptions or threshold changes without the user's approval.
|
||||||
|
|
||||||
|
The checker fails a state with no setup, so run it state by state during the slice: `task visual:check -- localization-editor <state>`, and `task visual:check -- localization-conflicts-moves loc.l11.session`, `... loc.l11.restored`. The full-screen run of localization-editor only passes once localization_translation_flows adds the deletion states; this slice does not run it whole (see open question 9).
|
||||||
|
|
||||||
|
In scope, with sizes:
|
||||||
|
- L5 (1440, 1024, 390): default, dirty, dirtyPublish, cleanOnline, onlineDirty, saveFail, pubFail.
|
||||||
|
- L5 (1440, 390): saving, saved, ovf, menu, two, many, unsaved, loading, navFail, gone, focus, titleEdit, titleUnsaved, invalid, titleShort, titleLong, titleSaving, failed.
|
||||||
|
- L5b (1440, 1024, 390): perm.readonly, perm.editNoPublish, perm.publishNoEdit.
|
||||||
|
- L5b (1440, 390): l5b.roMenu, perm.siblingReadOnly, perm.siblingHidden, perm.createNotAll, l5b.noAdd, l5b.withAdd.
|
||||||
|
- L8 (1440, 390): publish, publishDisabled, saveAndPublish, saveFailPublish, unpublish, unpublishDirty, done, fail.
|
||||||
|
- L11 (1440, 390): session, restored.
|
||||||
|
|
||||||
|
Clock-sensitive: none expected. The board draws fixed times (Saved, 14:18); the setups freeze `now` as in features/editor_core.qa.md. The relative "Saved n min ago" form is not asserted visually. If a toast or SaveStatus depends on the real clock in a setup, list it as `skip: clock-sensitive`.
|
||||||
|
|
||||||
|
## Manual
|
||||||
|
|
||||||
|
- Focus order: title, language control, status, Unpublish, Save, primary, More actions; focus returns to the control on menu close, to the field on Try again, to the button after a save.
|
||||||
|
- Screen reader announcements: "Editing French version", "Saving title", "Title saved", "French saved", "Saved as a draft. Publishing failed. Try publishing again."
|
||||||
|
- Hover and pressed states of the language rows, Read only tag contrast, motion (no slide or fade under reduced motion; the spinner becomes a static icon).
|
||||||
|
- Real round trip against `task dev`: switch versions, a real 401 after cookie expiry with draft restore, and logout clearing the draft (Vitest mocks `useApi` and `sessionStorage`).
|
||||||
|
- Between 1024 and 1440 the header is specified, not drawn: resize by hand and check nothing overlaps.
|
||||||
|
|
||||||
|
## Known gaps and scope decisions
|
||||||
|
|
||||||
|
- loc.l11.session (the "Your session expired" modal) is not implemented: the existing redirect to /login?reason=expired stays. The visual checker records it as a known gap.
|
||||||
|
- The Add translation row in the language menu renders from capabilities but does nothing until localization_translation_flows.
|
||||||
|
- No if_revision is sent in this slice; the conflict dialog and the "Save over theirs" destructive cue belong to localization_translation_flows.
|
||||||
|
- features/editor_core.feature and features/editor_core.qa.md are amended in this slice for the editable title, the user and article scoped draft key, the Version unavailable copy and the "Save draft" label.
|
||||||
|
- The full-screen run of task visual:check for localization-editor passes only after localization_translation_flows; this slice checks state by state.
|
||||||
+14
-2
@@ -2,11 +2,18 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { en, fr } from '@nuxt/ui/locale'
|
import { en, fr } from '@nuxt/ui/locale'
|
||||||
import { locale } from '@/i18n'
|
import { locale } from '@/i18n'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView, type RouteLocationNormalizedLoaded } from 'vue-router'
|
||||||
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
|
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
|
||||||
|
import { liveMessage } from '@/composables/useLiveAnnouncement'
|
||||||
|
|
||||||
const BULK_BAR_TOAST_OFFSET = 'max-sm:bottom-20'
|
const BULK_BAR_TOAST_OFFSET = 'max-sm:bottom-20'
|
||||||
|
|
||||||
|
const EDITOR_PATH = '/article/:id/edit'
|
||||||
|
|
||||||
|
function routeKey(route: RouteLocationNormalizedLoaded) {
|
||||||
|
return route.matched.some((record) => record.path === EDITOR_PATH) ? route.params.id : undefined
|
||||||
|
}
|
||||||
|
|
||||||
const toaster = computed(() => ({
|
const toaster = computed(() => ({
|
||||||
ui: { viewport: toastsAboveBulkBar.value ? BULK_BAR_TOAST_OFFSET : '' },
|
ui: { viewport: toastsAboveBulkBar.value ? BULK_BAR_TOAST_OFFSET : '' },
|
||||||
}))
|
}))
|
||||||
@@ -14,6 +21,11 @@ const toaster = computed(() => ({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UApp class="isolate" :toaster="toaster" :locale="locale === 'fr' ? fr : en">
|
<UApp class="isolate" :toaster="toaster" :locale="locale === 'fr' ? fr : en">
|
||||||
<RouterView />
|
<RouterView v-slot="{ Component, route }">
|
||||||
|
<component :is="Component" :key="routeKey(route)" />
|
||||||
|
</RouterView>
|
||||||
|
<div aria-live="polite" class="sr-only" data-testid="live-announcement">
|
||||||
|
{{ liveMessage }}
|
||||||
|
</div>
|
||||||
</UApp>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
@@ -7,7 +7,7 @@ import { useLocalizedToast } from '@/i18n/presentation'
|
|||||||
import type { Article } from '@/composables/useArticles'
|
import type { Article } from '@/composables/useArticles'
|
||||||
import { bodyImageCount } from '@/lib/bodyImageIds'
|
import { bodyImageCount } from '@/lib/bodyImageIds'
|
||||||
|
|
||||||
const props = defineProps<{ article: Article }>()
|
const props = defineProps<{ article: Article; multilingual?: boolean }>()
|
||||||
|
|
||||||
const emit = defineEmits<{ 'copy-url': [] }>()
|
const emit = defineEmits<{ 'copy-url': [] }>()
|
||||||
|
|
||||||
@@ -15,8 +15,13 @@ const toast = useLocalizedToast()
|
|||||||
|
|
||||||
const open = defineModel<boolean>('open', { default: false })
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
|
|
||||||
const TITLE_NOTE =
|
const titleNote = computed(() =>
|
||||||
"Titles can't be changed after an article is created. To use a different title, create a new article and delete this one."
|
props.multilingual
|
||||||
|
? t(
|
||||||
|
"Each language has its own title. Changing it doesn't change the URL; edit the slug in Search and sharing.",
|
||||||
|
)
|
||||||
|
: t("Changing the title doesn't change the URL; edit the slug in Search and sharing."),
|
||||||
|
)
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
const MONTH_NAMES = [
|
||||||
'Jan',
|
'Jan',
|
||||||
@@ -141,7 +146,7 @@ async function copyUrl() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1 border-t border-default pt-3 text-sm">
|
<div class="flex flex-col gap-1 border-t border-default pt-3 text-sm">
|
||||||
<p class="text-muted">{{ t('Title') }}</p>
|
<p class="text-muted">{{ t('Title') }}</p>
|
||||||
<p class="text-xs/[18px] text-toned">{{ t(TITLE_NOTE) }}</p>
|
<p class="text-xs/[18px] text-toned">{{ titleNote }}</p>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,13 @@ const props = withDefaults(
|
|||||||
modelValue: string
|
modelValue: string
|
||||||
articleId: number
|
articleId: number
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
readonly?: boolean
|
||||||
|
label?: string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
readonly: false,
|
||||||
|
label: undefined,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,6 +51,15 @@ const extensions = [
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const editorProps = computed(() => ({
|
||||||
|
attributes: {
|
||||||
|
role: 'textbox',
|
||||||
|
'aria-multiline': 'true',
|
||||||
|
...(props.label ? { 'aria-label': props.label } : {}),
|
||||||
|
...(props.readonly ? { 'aria-readonly': 'true' } : {}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
function captureEditor(editor: Editor) {
|
function captureEditor(editor: Editor) {
|
||||||
editorInstance.value = editor
|
editorInstance.value = editor
|
||||||
return ''
|
return ''
|
||||||
@@ -359,14 +372,15 @@ defineExpose({
|
|||||||
:starter-kit="{ link: false }"
|
:starter-kit="{ link: false }"
|
||||||
:image="false"
|
:image="false"
|
||||||
content-type="html"
|
content-type="html"
|
||||||
:editable="!props.disabled"
|
:editable="!props.disabled && !props.readonly"
|
||||||
|
:editor-props="editorProps"
|
||||||
:ui="{ base: 'article-content' }"
|
:ui="{ base: 'article-content' }"
|
||||||
class="article-editor grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] grid-cols-[minmax(0,1fr)] lg:grid-cols-[minmax(0,1fr)_17.5rem]"
|
class="article-editor grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] grid-cols-[minmax(0,1fr)] lg:grid-cols-[minmax(0,1fr)_17.5rem]"
|
||||||
@update:model-value="onUpdate"
|
@update:model-value="onUpdate"
|
||||||
>
|
>
|
||||||
<span class="hidden">{{ captureEditor(editor) }}</span>
|
<span class="hidden">{{ captureEditor(editor) }}</span>
|
||||||
<UEditorDragHandle :editor="editor" />
|
<UEditorDragHandle :editor="editor" />
|
||||||
<div class="relative col-span-full row-start-1 min-w-0">
|
<div v-if="!props.readonly" class="relative col-span-full row-start-1 min-w-0">
|
||||||
<UEditorToolbar
|
<UEditorToolbar
|
||||||
ref="toolbarRef"
|
ref="toolbarRef"
|
||||||
:editor="editor"
|
:editor="editor"
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
|
||||||
|
import LocaleDialog from '@/components/localization/LocaleDialog.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
confirmLabel: string
|
||||||
|
notice?: { title: string; description: string }
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [confirmed: boolean] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LocaleDialog
|
||||||
|
:title="title"
|
||||||
|
:description="description"
|
||||||
|
:confirm-label="confirmLabel"
|
||||||
|
@close="emit('close', false)"
|
||||||
|
@confirm="emit('close', true)"
|
||||||
|
>
|
||||||
|
<LocaleAlert
|
||||||
|
v-if="notice"
|
||||||
|
tone="neutral"
|
||||||
|
icon="i-lucide-info"
|
||||||
|
:title="notice.title"
|
||||||
|
:description="notice.description"
|
||||||
|
/>
|
||||||
|
</LocaleDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import AppSwitch from '@/components/AppSwitch.vue'
|
||||||
|
import EditorOverflowMenu from '@/components/EditorOverflowMenu.vue'
|
||||||
|
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||||
|
import { primaryLabel, unpublishLabel } from '@/lib/editorLabels'
|
||||||
|
import type { ActionPlan } from '@/lib/editorActions'
|
||||||
|
import type { OverflowItem } from '@/lib/editorOverflow'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
plan: ActionPlan
|
||||||
|
language: string | null
|
||||||
|
offline: boolean
|
||||||
|
unpublishVisible: boolean
|
||||||
|
overflow: OverflowItem[]
|
||||||
|
previewable: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ save: []; primary: []; unpublish: [] }>()
|
||||||
|
|
||||||
|
const previewOn = defineModel<boolean>('preview', { required: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
<AppSwitch
|
||||||
|
v-if="previewable"
|
||||||
|
v-model="previewOn"
|
||||||
|
:label="t('Preview')"
|
||||||
|
class="me-2"
|
||||||
|
:ui="{
|
||||||
|
root: 'items-center gap-2',
|
||||||
|
wrapper: 'ms-0',
|
||||||
|
label: 'text-sm font-normal text-toned',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<LocaleButton
|
||||||
|
v-if="plan.unpublish && unpublishVisible"
|
||||||
|
:disabled="offline"
|
||||||
|
@click="emit('unpublish')"
|
||||||
|
>
|
||||||
|
{{ unpublishLabel(language) }}
|
||||||
|
</LocaleButton>
|
||||||
|
<LocaleButton
|
||||||
|
v-if="plan.saveButton"
|
||||||
|
data-testid="save-button"
|
||||||
|
icon="i-lucide-save"
|
||||||
|
:disabled="plan.saveButton.disabled || offline"
|
||||||
|
@click="emit('save')"
|
||||||
|
>
|
||||||
|
{{ t('Save') }}
|
||||||
|
</LocaleButton>
|
||||||
|
<LocaleButton
|
||||||
|
v-if="plan.primary"
|
||||||
|
data-testid="primary-action"
|
||||||
|
kind="primary"
|
||||||
|
:loading="plan.primary.busy"
|
||||||
|
:class="plan.primary.busy ? '!opacity-40' : ''"
|
||||||
|
:aria-busy="plan.primary.busy ? 'true' : undefined"
|
||||||
|
:disabled="plan.primary.disabled || offline"
|
||||||
|
@click="emit('primary')"
|
||||||
|
>
|
||||||
|
{{ primaryLabel(plan.primary.kind, language) }}
|
||||||
|
</LocaleButton>
|
||||||
|
<EditorOverflowMenu v-if="overflow.length" :items="overflow" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,111 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppSwitch from '@/components/AppSwitch.vue'
|
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
defineEmits<{ back: [] }>()
|
||||||
import PublishControl from '@/components/PublishControl.vue'
|
|
||||||
import SaveStatus from '@/components/SaveStatus.vue'
|
|
||||||
import type { Article } from '@/composables/useArticles'
|
|
||||||
import type { ArticleDraftState } from '@/composables/useArticleDraft'
|
|
||||||
|
|
||||||
withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
article: Article
|
|
||||||
state: ArticleDraftState
|
|
||||||
savedAt: Date | null
|
|
||||||
countText: string
|
|
||||||
saveEnabled: boolean
|
|
||||||
publishPending: string | undefined
|
|
||||||
publishDisabled: boolean
|
|
||||||
canPublish?: boolean
|
|
||||||
canDelete?: boolean
|
|
||||||
deleteReason?: string
|
|
||||||
}>(),
|
|
||||||
{ canPublish: true, canDelete: true, deleteReason: '' },
|
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
back: []
|
|
||||||
retry: []
|
|
||||||
save: []
|
|
||||||
publish: []
|
|
||||||
unpublish: []
|
|
||||||
delete: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const previewOn = defineModel<boolean>('preview', { required: true })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-default bg-default px-4 py-3 lg:gap-3 lg:px-6 lg:py-3.5"
|
class="flex shrink-0 flex-col gap-2.5 border-b border-default bg-default px-4 py-3 lg:px-6 lg:py-3.5"
|
||||||
>
|
>
|
||||||
<UButton
|
<div class="flex items-start gap-2">
|
||||||
icon="i-lucide-arrow-left"
|
<span class="hidden h-15 items-end lg:flex">
|
||||||
color="neutral"
|
<UButton
|
||||||
variant="ghost"
|
icon="i-lucide-arrow-left"
|
||||||
class="hidden size-8 justify-center text-toned lg:inline-flex"
|
color="neutral"
|
||||||
:aria-label="t('Back to Articles')"
|
variant="ghost"
|
||||||
@click="emit('back')"
|
class="size-4 justify-center p-0 text-toned"
|
||||||
/>
|
:aria-label="t('Back to Articles')"
|
||||||
<div
|
@click="$emit('back')"
|
||||||
class="flex min-w-0 flex-1 basis-full flex-wrap items-center gap-2.5 lg:min-w-50 lg:basis-50"
|
/>
|
||||||
>
|
</span>
|
||||||
<h1
|
<div class="min-w-0 flex-1">
|
||||||
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
|
<slot name="title" />
|
||||||
:title="t('The title can\'t be changed after creation.')"
|
</div>
|
||||||
>
|
|
||||||
{{ article.title }}
|
|
||||||
</h1>
|
|
||||||
<ArticleStatusBadge :online="article.is_online" size="lg" />
|
|
||||||
<span v-if="!canPublish && !article.is_online" class="text-xs text-muted">{{
|
|
||||||
t('Ask an editor to publish.')
|
|
||||||
}}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<SaveStatus :state="state" :saved-at="savedAt" @retry="emit('retry')" />
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||||
<span class="ml-auto font-mono text-xs text-muted lg:hidden">{{ countText }}</span>
|
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||||
<div class="hidden items-center gap-3 lg:flex">
|
<slot name="meta" />
|
||||||
<AppSwitch
|
</div>
|
||||||
v-model="previewOn"
|
<div class="hidden shrink-0 lg:flex">
|
||||||
:label="t('Preview')"
|
<slot name="actions" />
|
||||||
:ui="{
|
</div>
|
||||||
root: 'items-center gap-2',
|
|
||||||
wrapper: 'ms-0',
|
|
||||||
label: 'text-sm font-normal text-toned',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
data-testid="save-button"
|
|
||||||
icon="i-lucide-save"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
class="h-8 gap-1.5 px-3"
|
|
||||||
:disabled="!saveEnabled"
|
|
||||||
@click="emit('save')"
|
|
||||||
>
|
|
||||||
{{ canPublish ? t('Save') : t('Save draft') }}
|
|
||||||
</UButton>
|
|
||||||
<PublishControl
|
|
||||||
v-if="canPublish"
|
|
||||||
class="h-8 gap-1.5 px-3"
|
|
||||||
:online="article.is_online"
|
|
||||||
:dirty="state === 'dirty'"
|
|
||||||
:pending="publishPending"
|
|
||||||
:disabled="publishDisabled"
|
|
||||||
@publish="emit('publish')"
|
|
||||||
@unpublish="emit('unpublish')"
|
|
||||||
/>
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-trash-2"
|
|
||||||
color="error"
|
|
||||||
variant="ghost"
|
|
||||||
class="h-8 gap-1.5 px-3"
|
|
||||||
:disabled="!canDelete"
|
|
||||||
:title="canDelete ? undefined : deleteReason"
|
|
||||||
@click="emit('delete')"
|
|
||||||
>
|
|
||||||
{{ t('Delete') }}
|
|
||||||
</UButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import EditorLanguageRow from '@/components/EditorLanguageRow.vue'
|
||||||
|
import { useIsDesktop } from '@/composables/useIsDesktop'
|
||||||
|
import { MENU_FILTER_THRESHOLD, filterRows, type VersionRow } from '@/lib/editorVersions'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rows: VersionRow[]
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ choose: [row: VersionRow] }>()
|
||||||
|
|
||||||
|
const MENU_ITEM_SELECTOR = '[role="menuitem"]'
|
||||||
|
const CURRENT_ROW_SELECTOR = `${MENU_ITEM_SELECTOR} [aria-current="true"]`
|
||||||
|
const CURRENT_ROW_CLASS = 'bg-(--rc-sand) text-(--rc-on-sand)'
|
||||||
|
|
||||||
|
const desktop = useIsDesktop('64rem')
|
||||||
|
const open = ref(false)
|
||||||
|
const query = ref('')
|
||||||
|
const trigger = ref<{ $el: HTMLElement } | null>(null)
|
||||||
|
const desktopMenuTop = ref<HTMLElement | null>(null)
|
||||||
|
const sheetMenu = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const filterable = computed(() => props.rows.length > MENU_FILTER_THRESHOLD)
|
||||||
|
const visibleRows = computed(() => filterRows(props.rows, query.value))
|
||||||
|
const triggerLabel = computed(() => t('Language: {0}. Switch version', [props.name]))
|
||||||
|
|
||||||
|
const dropdownItems = computed(() =>
|
||||||
|
visibleRows.value.map((row) => ({
|
||||||
|
slot: 'row',
|
||||||
|
row,
|
||||||
|
class: [
|
||||||
|
'min-h-10 items-center rounded-none px-3 py-1.5 max-lg:min-h-13',
|
||||||
|
row.current ? CURRENT_ROW_CLASS : row.addTranslation ? 'text-toned' : '',
|
||||||
|
],
|
||||||
|
onSelect: () => emit('choose', row),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
function focusCurrentRow(menu: Element | null | undefined) {
|
||||||
|
const current = menu
|
||||||
|
?.querySelector(CURRENT_ROW_SELECTOR)
|
||||||
|
?.closest<HTMLElement>(MENU_ITEM_SELECTOR)
|
||||||
|
const first = menu?.querySelector<HTMLElement>(MENU_ITEM_SELECTOR)
|
||||||
|
;(current ?? first)?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusDesktopMenu() {
|
||||||
|
focusCurrentRow(desktopMenuTop.value?.closest('[role="menu"]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(open, (value) => {
|
||||||
|
if (value) {
|
||||||
|
query.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(sheetMenu, (menu) => focusCurrentRow(menu), { flush: 'post' })
|
||||||
|
watch(desktopMenuTop, focusDesktopMenu, { flush: 'post' })
|
||||||
|
|
||||||
|
function closeFromFilter() {
|
||||||
|
open.value = false
|
||||||
|
trigger.value?.$el?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(row: VersionRow) {
|
||||||
|
open.value = false
|
||||||
|
emit('choose', row)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UDropdownMenu
|
||||||
|
v-if="desktop"
|
||||||
|
v-model:open="open"
|
||||||
|
:items="[dropdownItems]"
|
||||||
|
:content="{ align: 'start', loop: true }"
|
||||||
|
:ui="{
|
||||||
|
content: 'w-100 max-h-105 rounded-none p-0 shadow-(--shadow-popover)',
|
||||||
|
viewport: 'max-h-80 p-0',
|
||||||
|
group: 'p-0',
|
||||||
|
item: 'rounded-none',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
ref="trigger"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-languages"
|
||||||
|
trailing-icon="i-lucide-chevrons-up-down"
|
||||||
|
:aria-label="triggerLabel"
|
||||||
|
class="h-8 max-w-65 gap-2 border bg-default px-2.5 font-medium text-highlighted ring-0"
|
||||||
|
:class="open ? 'border-primary' : 'border-accented'"
|
||||||
|
:ui="{ leadingIcon: 'size-4', trailingIcon: 'size-3.5 ms-0' }"
|
||||||
|
>
|
||||||
|
<span class="whitespace-nowrap">{{ name }}</span>
|
||||||
|
<span class="font-mono text-xs text-muted">{{ code }}</span>
|
||||||
|
</UButton>
|
||||||
|
<template #content-top>
|
||||||
|
<div ref="desktopMenuTop" class="flex flex-col gap-1.5 px-3 pt-2.5 pb-1.5">
|
||||||
|
<span class="text-2xs font-semibold tracking-wide text-muted uppercase">{{
|
||||||
|
t('Versions you can open')
|
||||||
|
}}</span>
|
||||||
|
<UInput
|
||||||
|
v-if="filterable"
|
||||||
|
v-model="query"
|
||||||
|
:placeholder="t('Find a language')"
|
||||||
|
:aria-label="t('Find a language')"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ base: 'h-8 border border-accented bg-default text-sm ring-0' }"
|
||||||
|
@keydown.stop
|
||||||
|
@keydown.esc="closeFromFilter"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #row="{ item }">
|
||||||
|
<EditorLanguageRow :row="(item as unknown as { row: VersionRow }).row" />
|
||||||
|
</template>
|
||||||
|
<template #content-bottom>
|
||||||
|
<div class="flex gap-2 border-t border-default px-3 py-2.5 text-xs/4.5 text-muted">
|
||||||
|
<UIcon name="i-lucide-info" class="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
{{ t('Switching opens that version. The interface language stays the same.') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UDropdownMenu>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-languages"
|
||||||
|
trailing-icon="i-lucide-chevrons-up-down"
|
||||||
|
:aria-label="triggerLabel"
|
||||||
|
class="h-11 max-w-65 gap-2 border border-accented bg-default px-2.5 font-medium text-highlighted ring-0"
|
||||||
|
:ui="{ leadingIcon: 'size-4', trailingIcon: 'size-3.5 ms-0' }"
|
||||||
|
@click="open = true"
|
||||||
|
>
|
||||||
|
<span class="whitespace-nowrap">{{ name }}</span>
|
||||||
|
<span class="font-mono text-xs text-muted">{{ code }}</span>
|
||||||
|
</UButton>
|
||||||
|
<USlideover
|
||||||
|
v-model:open="open"
|
||||||
|
side="bottom"
|
||||||
|
:overlay="false"
|
||||||
|
:title="t('Versions you can open')"
|
||||||
|
:description="t('Versions you can open')"
|
||||||
|
:ui="{
|
||||||
|
content:
|
||||||
|
'max-h-7/10 pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
||||||
|
header: 'sr-only',
|
||||||
|
body: 'flex min-h-0 flex-col p-0 sm:p-0',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="flex flex-col gap-1.5 px-3 pt-2.5 pb-1.5">
|
||||||
|
<span class="text-2xs font-semibold tracking-wide text-muted uppercase">{{
|
||||||
|
t('Versions you can open')
|
||||||
|
}}</span>
|
||||||
|
<UInput
|
||||||
|
v-if="filterable"
|
||||||
|
v-model="query"
|
||||||
|
:placeholder="t('Find a language')"
|
||||||
|
:aria-label="t('Find a language')"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ base: 'h-11 border border-accented bg-default text-sm ring-0' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref="sheetMenu"
|
||||||
|
role="menu"
|
||||||
|
:aria-label="t('Versions of this article')"
|
||||||
|
class="flex min-h-0 flex-col overflow-y-auto"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="row in visibleRows"
|
||||||
|
:key="row.key"
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
class="flex min-h-13 items-center px-3 py-1.5 text-start"
|
||||||
|
:class="
|
||||||
|
row.current ? CURRENT_ROW_CLASS : row.addTranslation ? 'text-toned' : ''
|
||||||
|
"
|
||||||
|
@click="choose(row)"
|
||||||
|
>
|
||||||
|
<EditorLanguageRow :row="row" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 border-t border-default px-3 py-2.5 text-xs/4.5 text-muted">
|
||||||
|
<UIcon name="i-lucide-info" class="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
{{ t('Switching opens that version. The interface language stays the same.') }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</USlideover>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import LocaleBadge from '@/components/localization/LocaleBadge.vue'
|
||||||
|
import type { VersionRow } from '@/lib/editorVersions'
|
||||||
|
|
||||||
|
defineProps<{ row: VersionRow }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="flex w-full items-center gap-2.5" :aria-current="row.current ? 'true' : undefined">
|
||||||
|
<span class="flex w-4 shrink-0">
|
||||||
|
<UIcon v-if="row.current" name="i-lucide-check" class="size-4" />
|
||||||
|
</span>
|
||||||
|
<span class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span
|
||||||
|
class="truncate text-base"
|
||||||
|
:class="row.current ? 'font-semibold' : 'font-medium'"
|
||||||
|
>{{ row.name }}</span
|
||||||
|
>
|
||||||
|
<span class="font-mono text-xs" :class="row.current ? '' : 'text-muted'">{{
|
||||||
|
row.code
|
||||||
|
}}</span>
|
||||||
|
<span v-if="row.title" class="sr-only">{{ row.title }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="row.addTranslation" class="inline-flex items-center gap-1 font-medium">
|
||||||
|
<UIcon name="i-lucide-plus" class="size-3.5" />
|
||||||
|
{{ t('Add translation') }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="flex flex-wrap justify-end gap-1">
|
||||||
|
<span v-if="row.readOnly" class="inline-flex items-center gap-1 text-xs text-muted">
|
||||||
|
<UIcon name="i-lucide-lock" class="size-3" />
|
||||||
|
{{ t('Read only') }}
|
||||||
|
</span>
|
||||||
|
<LocaleBadge
|
||||||
|
:tone="row.online ? 'success' : 'neutral'"
|
||||||
|
:icon="row.online ? 'i-lucide-circle-check' : 'i-lucide-pencil'"
|
||||||
|
>
|
||||||
|
{{ row.online ? t('Published') : t('Draft') }}
|
||||||
|
</LocaleBadge>
|
||||||
|
<LocaleBadge v-if="row.languageDisabled" tone="neutral" icon="i-lucide-eye-off">
|
||||||
|
{{ t('Disabled') }}
|
||||||
|
</LocaleBadge>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
|
||||||
|
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||||
|
|
||||||
|
export type LeaveChoice = 'keep' | 'discard' | 'save'
|
||||||
|
|
||||||
|
const props = defineProps<{ language: string; target: string }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [choice: LeaveChoice] }>()
|
||||||
|
|
||||||
|
const description = computed(() =>
|
||||||
|
t("Your changes to the {0} version aren't saved.", [props.language]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const CLOSE_BUTTON = {
|
||||||
|
color: 'neutral',
|
||||||
|
variant: 'ghost',
|
||||||
|
class: 'size-11 justify-center p-0 text-muted md:size-7',
|
||||||
|
} as const
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal
|
||||||
|
:open="true"
|
||||||
|
:title="t('Leave without saving?')"
|
||||||
|
:description="description"
|
||||||
|
:close="CLOSE_BUTTON"
|
||||||
|
:ui="{
|
||||||
|
content: `${SHEET_CONTENT_CLASS} sm:top-25 sm:max-w-115`,
|
||||||
|
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: 'hidden',
|
||||||
|
footer: SHEET_FOOTER_CLASS,
|
||||||
|
}"
|
||||||
|
@update:open="(value: boolean) => !value && emit('close', 'keep')"
|
||||||
|
>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex w-full flex-col-reverse gap-2 md:flex-row md:flex-wrap md:justify-end">
|
||||||
|
<LocaleButton block class="md:w-auto" @click="emit('close', 'keep')">
|
||||||
|
{{ t('Keep editing') }}
|
||||||
|
</LocaleButton>
|
||||||
|
<LocaleButton
|
||||||
|
kind="danger"
|
||||||
|
block
|
||||||
|
class="md:w-auto"
|
||||||
|
@click="emit('close', 'discard')"
|
||||||
|
>
|
||||||
|
{{ t('Discard and open {0}', [target]) }}
|
||||||
|
</LocaleButton>
|
||||||
|
<LocaleButton kind="primary" block class="md:w-auto" @click="emit('close', 'save')">
|
||||||
|
{{ t('Save and open {0}', [target]) }}
|
||||||
|
</LocaleButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
@@ -1,59 +1,68 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
import PublishControl from '@/components/PublishControl.vue'
|
import EditorOverflowMenu from '@/components/EditorOverflowMenu.vue'
|
||||||
import type { ArticleDraftState } from '@/composables/useArticleDraft'
|
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||||
|
import { primaryLabel, unpublishLabel } from '@/lib/editorLabels'
|
||||||
|
import type { ActionPlan } from '@/lib/editorActions'
|
||||||
|
import type { OverflowItem } from '@/lib/editorOverflow'
|
||||||
|
|
||||||
withDefaults(
|
defineProps<{
|
||||||
defineProps<{
|
hint: string
|
||||||
online: boolean
|
plan: ActionPlan
|
||||||
state: ArticleDraftState
|
language: string | null
|
||||||
saveEnabled: boolean
|
offline: boolean
|
||||||
publishPending: string | undefined
|
overflow: OverflowItem[]
|
||||||
publishDisabled: boolean
|
|
||||||
canPublish?: boolean
|
|
||||||
}>(),
|
|
||||||
{ canPublish: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
details: []
|
|
||||||
save: []
|
|
||||||
publish: []
|
|
||||||
unpublish: []
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ details: []; save: []; primary: []; unpublish: [] }>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden">
|
<div
|
||||||
<UButton
|
class="flex flex-shrink-0 flex-col gap-2 border-t border-default bg-default px-4 py-3 lg:hidden"
|
||||||
icon="i-lucide-panel-right"
|
>
|
||||||
color="neutral"
|
<p v-if="hint" class="text-xs text-muted">{{ hint }}</p>
|
||||||
variant="outline"
|
<div class="flex items-center gap-2">
|
||||||
class="size-11 justify-center"
|
<LocaleButton
|
||||||
:aria-label="t('Details')"
|
icon="i-lucide-panel-right"
|
||||||
@click="emit('details')"
|
icon-only
|
||||||
/>
|
:aria-label="t('Details')"
|
||||||
<UButton
|
@click="emit('details')"
|
||||||
data-testid="save-button-mobile"
|
/>
|
||||||
color="neutral"
|
<LocaleButton
|
||||||
variant="outline"
|
v-if="plan.primary"
|
||||||
class="h-11 flex-1 justify-center"
|
data-testid="primary-action-mobile"
|
||||||
:disabled="!saveEnabled"
|
kind="primary"
|
||||||
@click="emit('save')"
|
block
|
||||||
>
|
class="min-w-0 flex-1"
|
||||||
{{ canPublish ? t('Save') : t('Save draft') }}
|
:loading="plan.primary.busy"
|
||||||
</UButton>
|
:class="plan.primary.busy ? '!opacity-40' : ''"
|
||||||
<PublishControl
|
:aria-busy="plan.primary.busy ? 'true' : undefined"
|
||||||
v-if="canPublish"
|
:disabled="plan.primary.disabled || offline"
|
||||||
class="h-11 flex-1 justify-center"
|
@click="emit('primary')"
|
||||||
hide-icon
|
>
|
||||||
:online="online"
|
{{ primaryLabel(plan.primary.kind, language) }}
|
||||||
:dirty="state === 'dirty'"
|
</LocaleButton>
|
||||||
:pending="publishPending"
|
<LocaleButton
|
||||||
:disabled="publishDisabled"
|
v-if="plan.saveButton"
|
||||||
@publish="emit('publish')"
|
data-testid="save-button-mobile"
|
||||||
@unpublish="emit('unpublish')"
|
:disabled="plan.saveButton.disabled || offline"
|
||||||
/>
|
@click="emit('save')"
|
||||||
|
>
|
||||||
|
{{ t('Save') }}
|
||||||
|
</LocaleButton>
|
||||||
|
<LocaleButton
|
||||||
|
v-if="!plan.primary && !plan.saveButton && plan.unpublish"
|
||||||
|
block
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
icon="i-lucide-eye-off"
|
||||||
|
:disabled="offline"
|
||||||
|
@click="emit('unpublish')"
|
||||||
|
>
|
||||||
|
{{ unpublishLabel(language) }}
|
||||||
|
</LocaleButton>
|
||||||
|
<EditorOverflowMenu v-if="overflow.length" :items="overflow" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import LocaleAlert from '@/components/localization/LocaleAlert.vue'
|
||||||
|
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||||
|
import type { EditorNotice } from '@/lib/editorNotices'
|
||||||
|
|
||||||
|
defineProps<{ notices: EditorNotice[] }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ retryPublish: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="notices.length"
|
||||||
|
class="flex shrink-0 flex-col gap-2 border-b border-default px-4 py-3 lg:px-6"
|
||||||
|
>
|
||||||
|
<LocaleAlert
|
||||||
|
v-for="notice in notices"
|
||||||
|
:key="notice.key"
|
||||||
|
:tone="notice.tone"
|
||||||
|
:icon="notice.icon"
|
||||||
|
:title="notice.title"
|
||||||
|
:description="notice.description"
|
||||||
|
>
|
||||||
|
<template v-if="notice.action" #actions>
|
||||||
|
<LocaleButton small @click="emit('retryPublish')">{{
|
||||||
|
t('Retry publish')
|
||||||
|
}}</LocaleButton>
|
||||||
|
</template>
|
||||||
|
</LocaleAlert>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useIsDesktop } from '@/composables/useIsDesktop'
|
||||||
|
import type { OverflowItem } from '@/lib/editorOverflow'
|
||||||
|
|
||||||
|
const props = defineProps<{ items: OverflowItem[] }>()
|
||||||
|
|
||||||
|
const desktop = useIsDesktop('64rem')
|
||||||
|
const open = ref(false)
|
||||||
|
|
||||||
|
const dropdownItems = computed(() =>
|
||||||
|
props.items.map((item) => ({
|
||||||
|
slot: 'entry',
|
||||||
|
label: item.label,
|
||||||
|
icon: item.icon,
|
||||||
|
color: item.danger ? ('error' as const) : undefined,
|
||||||
|
disabled: item.disabled,
|
||||||
|
reason: item.reason,
|
||||||
|
class: item.disabled ? 'opacity-60' : '',
|
||||||
|
onSelect: item.onSelect,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
function select(item: OverflowItem) {
|
||||||
|
if (item.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
open.value = false
|
||||||
|
item.onSelect()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UDropdownMenu
|
||||||
|
v-if="desktop"
|
||||||
|
:items="[dropdownItems]"
|
||||||
|
:content="{ align: 'end' }"
|
||||||
|
:ui="{
|
||||||
|
content: 'w-80 rounded-none p-1 shadow-(--shadow-popover)',
|
||||||
|
group: 'p-0',
|
||||||
|
item: 'min-h-10 items-start gap-2.5 rounded-none px-3 py-2 text-base',
|
||||||
|
itemLeadingIcon: 'size-4',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-ellipsis"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
class="size-8 justify-center border border-accented bg-default text-highlighted ring-0"
|
||||||
|
:aria-label="t('More actions')"
|
||||||
|
/>
|
||||||
|
<template #entry="{ item }">
|
||||||
|
<UIcon :name="item.icon as string" class="mt-0.5 size-4 shrink-0" />
|
||||||
|
<span class="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span class="font-medium">{{ item.label }}</span>
|
||||||
|
<span v-if="item.reason" class="text-xs/4 font-normal text-muted">{{
|
||||||
|
item.reason
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</UDropdownMenu>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-ellipsis"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
class="size-11 justify-center border border-accented bg-default text-highlighted ring-0"
|
||||||
|
:aria-label="t('More actions')"
|
||||||
|
@click="open = true"
|
||||||
|
/>
|
||||||
|
<USlideover
|
||||||
|
v-model:open="open"
|
||||||
|
side="bottom"
|
||||||
|
:overlay="false"
|
||||||
|
:title="t('More actions')"
|
||||||
|
:description="t('More actions')"
|
||||||
|
:ui="{
|
||||||
|
content:
|
||||||
|
'max-h-none pt-3 pb-4 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
||||||
|
header: 'sr-only',
|
||||||
|
body: 'p-0 sm:p-0',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div role="menu" :aria-label="t('More actions')" class="flex flex-col py-1">
|
||||||
|
<button
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.key"
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
:aria-disabled="item.disabled ? 'true' : undefined"
|
||||||
|
class="flex min-h-13 items-center gap-2.5 px-3 py-2 text-start text-md"
|
||||||
|
:class="[
|
||||||
|
item.danger ? 'text-error' : 'text-highlighted',
|
||||||
|
item.disabled ? 'opacity-60' : '',
|
||||||
|
]"
|
||||||
|
@click="select(item)"
|
||||||
|
>
|
||||||
|
<UIcon :name="item.icon" class="size-4 shrink-0" />
|
||||||
|
<span class="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span class="font-medium">{{ item.label }}</span>
|
||||||
|
<span v-if="item.reason" class="text-xs/4 font-normal text-muted">{{
|
||||||
|
item.reason
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</USlideover>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
defineProps<{ headerless?: boolean }>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
back: []
|
back: []
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div aria-busy="true" class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
<div
|
||||||
|
aria-busy="true"
|
||||||
|
class="flex min-h-0 flex-1 flex-col lg:flex-row"
|
||||||
|
:class="headerless ? 'bg-default [&_.animate-pulse]:bg-accented' : ''"
|
||||||
|
>
|
||||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
<div
|
<div
|
||||||
|
v-if="!headerless"
|
||||||
class="flex flex-shrink-0 items-center gap-2.5 border-b border-default bg-default px-6 py-3.5"
|
class="flex flex-shrink-0 items-center gap-2.5 border-b border-default bg-default px-6 py-3.5"
|
||||||
>
|
>
|
||||||
<UButton
|
<UButton
|
||||||
@@ -23,8 +30,10 @@ const emit = defineEmits<{
|
|||||||
<USkeleton class="h-5 w-80 rounded-none" data-testid="skeleton-title" />
|
<USkeleton class="h-5 w-80 rounded-none" data-testid="skeleton-title" />
|
||||||
<USkeleton class="h-5.5 w-15 rounded-none" />
|
<USkeleton class="h-5.5 w-15 rounded-none" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="headerless" class="h-13.5 shrink-0 border-b border-default lg:h-10.25" />
|
||||||
<div
|
<div
|
||||||
class="min-h-0 max-w-168 flex-1 space-y-3.5 px-12 py-10"
|
class="min-h-0 max-w-168 flex-1 space-y-3.5"
|
||||||
|
:class="headerless ? 'px-4 py-6 lg:px-12 lg:py-10' : 'px-12 py-10'"
|
||||||
data-testid="skeleton-content"
|
data-testid="skeleton-content"
|
||||||
>
|
>
|
||||||
<USkeleton class="h-3.5 w-full rounded-none" />
|
<USkeleton class="h-3.5 w-full rounded-none" />
|
||||||
@@ -37,6 +46,7 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="w-70 flex-shrink-0 space-y-2.5 border-default p-5 lg:border-l"
|
class="w-70 flex-shrink-0 space-y-2.5 border-default p-5 lg:border-l"
|
||||||
|
:class="headerless ? 'max-lg:hidden' : ''"
|
||||||
data-testid="skeleton-details"
|
data-testid="skeleton-details"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-3 border-b border-default pb-4">
|
<div class="flex flex-col gap-3 border-b border-default pb-4">
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed, nextTick, ref, useId, watch } from 'vue'
|
||||||
|
import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||||
|
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||||
|
import { TITLE_MAX, titleLength, titleVerdict } from '@/lib/editorTitle'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
label: string
|
||||||
|
lang?: string
|
||||||
|
readonly: boolean
|
||||||
|
changed: boolean
|
||||||
|
saving: boolean
|
||||||
|
failed: boolean
|
||||||
|
serverError: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ retry: [] }>()
|
||||||
|
|
||||||
|
const title = defineModel<string>({ required: true })
|
||||||
|
|
||||||
|
const inputId = useId()
|
||||||
|
const helpId = useId()
|
||||||
|
const focused = ref(false)
|
||||||
|
const input = ref<{ textareaRef: HTMLTextAreaElement | null } | null>(null)
|
||||||
|
|
||||||
|
const verdict = computed(() => (props.serverError ? 'server' : titleVerdict(title.value)))
|
||||||
|
const invalidText = computed(() => {
|
||||||
|
const messages = {
|
||||||
|
empty: t('Enter a title'),
|
||||||
|
short: t('Use at least 3 characters'),
|
||||||
|
long: t('Use 200 characters or fewer'),
|
||||||
|
server: props.serverError,
|
||||||
|
ok: '',
|
||||||
|
}
|
||||||
|
return messages[verdict.value]
|
||||||
|
})
|
||||||
|
const invalid = computed(() => verdict.value !== 'ok')
|
||||||
|
const COUNTER_END_PADDING = 'pe-23'
|
||||||
|
|
||||||
|
const showsCounter = computed(
|
||||||
|
() => focused.value || verdict.value === 'short' || verdict.value === 'long',
|
||||||
|
)
|
||||||
|
const showsAddressNote = computed(() => focused.value || props.changed)
|
||||||
|
const errored = computed(() => invalid.value || props.failed)
|
||||||
|
|
||||||
|
const helper = computed(() => {
|
||||||
|
if (invalidText.value) {
|
||||||
|
return invalidText.value
|
||||||
|
}
|
||||||
|
if (props.failed) {
|
||||||
|
return t("Couldn't save the title. Your text is kept.")
|
||||||
|
}
|
||||||
|
if (props.saving) {
|
||||||
|
return t('Saving title…')
|
||||||
|
}
|
||||||
|
return showsAddressNote.value ? t("Changing the title doesn't change the address.") : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const trailingIcon = computed(() => {
|
||||||
|
if (props.saving) {
|
||||||
|
return 'i-lucide-loader-circle'
|
||||||
|
}
|
||||||
|
return errored.value ? 'i-lucide-circle-x' : 'i-lucide-pencil'
|
||||||
|
})
|
||||||
|
|
||||||
|
const borderClass = computed(() => {
|
||||||
|
if (props.readonly) {
|
||||||
|
return 'border-0 bg-transparent px-0 py-0 pe-5 lg:min-h-9'
|
||||||
|
}
|
||||||
|
if (errored.value) {
|
||||||
|
return 'border-error'
|
||||||
|
}
|
||||||
|
return focused.value ? 'border-primary' : 'border-accented'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function remeasure() {
|
||||||
|
await nextTick()
|
||||||
|
input.value?.textareaRef?.dispatchEvent(new Event('input'))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(showsCounter, remeasure)
|
||||||
|
|
||||||
|
function focusField() {
|
||||||
|
input.value?.textareaRef?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focus: focusField })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-w-0 flex-col" :class="readonly ? 'gap-0.5' : 'gap-1'">
|
||||||
|
<label :for="inputId" class="text-xs/5 text-muted">{{ label }}</label>
|
||||||
|
<UTextarea
|
||||||
|
:id="inputId"
|
||||||
|
ref="input"
|
||||||
|
v-model="title"
|
||||||
|
:lang="lang"
|
||||||
|
:rows="1"
|
||||||
|
autoresize
|
||||||
|
:readonly="readonly"
|
||||||
|
:aria-readonly="readonly ? 'true' : undefined"
|
||||||
|
:aria-invalid="errored ? 'true' : undefined"
|
||||||
|
:aria-describedby="readonly ? undefined : helpId"
|
||||||
|
:trailing-icon="readonly ? 'i-lucide-lock' : trailingIcon"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{
|
||||||
|
root: 'w-full',
|
||||||
|
base: [
|
||||||
|
'min-h-11 resize-none border bg-default px-2.5 py-0.5 !text-lead/7 font-semibold lg:!text-xl/7 text-highlighted ring-0 lg:min-h-10 lg:py-1.25',
|
||||||
|
borderClass,
|
||||||
|
showsCounter ? COUNTER_END_PADDING : '',
|
||||||
|
focused && !errored && !changed
|
||||||
|
? 'outline-2 outline-offset-1 outline-primary'
|
||||||
|
: '',
|
||||||
|
],
|
||||||
|
trailing: ['items-start gap-2 pt-2.5', readonly ? 'pe-0' : 'pe-2.5'],
|
||||||
|
trailingIcon: [
|
||||||
|
'size-3.5',
|
||||||
|
saving ? 'animate-spin' : '',
|
||||||
|
errored ? 'text-error' : 'text-muted',
|
||||||
|
],
|
||||||
|
}"
|
||||||
|
@keydown.enter.prevent
|
||||||
|
@focus="focused = true"
|
||||||
|
@blur="focused = false"
|
||||||
|
>
|
||||||
|
<template v-if="showsCounter" #trailing>
|
||||||
|
<CharacterCounter :value="titleLength(title)" :max="TITLE_MAX" variant="field" />
|
||||||
|
<UIcon
|
||||||
|
:name="trailingIcon"
|
||||||
|
class="size-3.5"
|
||||||
|
:class="[saving ? 'animate-spin' : '', errored ? 'text-error' : 'text-muted']"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</UTextarea>
|
||||||
|
<div
|
||||||
|
v-if="!readonly"
|
||||||
|
:id="helpId"
|
||||||
|
:role="invalid ? 'alert' : undefined"
|
||||||
|
class="flex flex-wrap items-center gap-2 text-xs/4.5"
|
||||||
|
:class="errored ? 'text-error' : 'text-muted'"
|
||||||
|
>
|
||||||
|
<UIcon v-if="errored" name="i-lucide-circle-x" class="size-3.5" />
|
||||||
|
<span v-if="helper">{{ helper }}</span>
|
||||||
|
<LocaleButton v-if="failed && !invalid" small @click="emit('retry')">
|
||||||
|
{{ t('Try again') }}
|
||||||
|
</LocaleButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import LocaleButton from '@/components/localization/LocaleButton.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ back: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div role="alert" class="flex flex-col items-start gap-4 p-4 lg:p-6">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<h1 tabindex="-1" class="text-xl/7 font-semibold text-highlighted">
|
||||||
|
{{ t("This version isn't available") }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-toned">
|
||||||
|
{{ t('It may have been deleted, or you may not have access.') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<LocaleButton @click="emit('back')">{{ t('Back to Articles') }}</LocaleButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -6,7 +6,7 @@ import { useRouter } from 'vue-router'
|
|||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
kind: 'not-found' | 'error' | 'article-not-found'
|
kind: 'not-found' | 'error'
|
||||||
breadcrumb?: string
|
breadcrumb?: string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
@@ -33,18 +33,6 @@ const copy = computed(() => {
|
|||||||
secondary: t('All articles'),
|
secondary: t('All articles'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (props.kind === 'article-not-found') {
|
|
||||||
return {
|
|
||||||
get title() {
|
|
||||||
return t("This article doesn't exist.")
|
|
||||||
},
|
|
||||||
get text() {
|
|
||||||
return t('It may have been deleted, or the link is wrong.')
|
|
||||||
},
|
|
||||||
primary: t('All articles'),
|
|
||||||
secondary: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
get title() {
|
get title() {
|
||||||
return t('Something went wrong')
|
return t('Something went wrong')
|
||||||
@@ -64,10 +52,6 @@ function onPrimary() {
|
|||||||
emit('retry')
|
emit('retry')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (props.kind === 'article-not-found') {
|
|
||||||
router.push('/article')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
router.push('/home')
|
router.push('/home')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,15 +66,9 @@ function onSecondary() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div role="alert" class="flex max-w-140 flex-col gap-3.5 p-8">
|
<div role="alert" class="flex max-w-140 flex-col gap-3.5 p-8">
|
||||||
<nav v-if="kind !== 'article-not-found'" :aria-label="t('Breadcrumb')">
|
<nav :aria-label="t('Breadcrumb')">
|
||||||
<span>{{ breadcrumb ?? t('Home') }}</span>
|
<span>{{ breadcrumb ?? t('Home') }}</span>
|
||||||
</nav>
|
</nav>
|
||||||
<span
|
|
||||||
v-if="kind === 'article-not-found'"
|
|
||||||
aria-hidden="true"
|
|
||||||
class="font-mono text-sm text-muted"
|
|
||||||
>404</span
|
|
||||||
>
|
|
||||||
<h1 tabindex="-1" class="text-2xl font-semibold text-highlighted">
|
<h1 tabindex="-1" class="text-2xl font-semibold text-highlighted">
|
||||||
{{ copy.title }}
|
{{ copy.title }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { t } from '@/i18n'
|
|
||||||
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
online: boolean
|
|
||||||
dirty: boolean
|
|
||||||
pending?: string
|
|
||||||
disabled?: boolean
|
|
||||||
hideIcon?: boolean
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
pending: undefined,
|
|
||||||
disabled: false,
|
|
||||||
hideIcon: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
defineEmits<{ publish: []; unpublish: [] }>()
|
|
||||||
|
|
||||||
const label = computed(() => {
|
|
||||||
if (props.pending) {
|
|
||||||
return props.pending
|
|
||||||
}
|
|
||||||
if (props.online) {
|
|
||||||
return t('Unpublish')
|
|
||||||
}
|
|
||||||
if (props.dirty) {
|
|
||||||
return t('Save and publish')
|
|
||||||
}
|
|
||||||
return t('Publish')
|
|
||||||
})
|
|
||||||
|
|
||||||
const icon = computed(() => {
|
|
||||||
if (props.hideIcon) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
if (props.online) {
|
|
||||||
return 'i-lucide-globe-lock'
|
|
||||||
}
|
|
||||||
return 'i-lucide-globe'
|
|
||||||
})
|
|
||||||
|
|
||||||
const variant = computed(() => (props.online ? 'outline' : 'solid'))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<UButton
|
|
||||||
:color="online ? 'neutral' : 'primary'"
|
|
||||||
:variant="variant"
|
|
||||||
:icon="icon"
|
|
||||||
:disabled="disabled"
|
|
||||||
:loading="Boolean(pending)"
|
|
||||||
@click="online ? $emit('unpublish') : $emit('publish')"
|
|
||||||
>
|
|
||||||
{{ label }}
|
|
||||||
</UButton>
|
|
||||||
</template>
|
|
||||||
@@ -3,10 +3,14 @@ import { t } from '@/i18n'
|
|||||||
|
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
state: 'clean' | 'dirty' | 'saving' | 'saved' | 'error' | 'offline'
|
defineProps<{
|
||||||
savedAt?: Date | null
|
state: 'clean' | 'dirty' | 'saving' | 'saved' | 'error' | 'offline'
|
||||||
}>()
|
savedAt?: Date | null
|
||||||
|
retryable?: boolean
|
||||||
|
}>(),
|
||||||
|
{ savedAt: null, retryable: true },
|
||||||
|
)
|
||||||
|
|
||||||
defineEmits<{ retry: [] }>()
|
defineEmits<{ retry: [] }>()
|
||||||
|
|
||||||
@@ -85,6 +89,7 @@ const savedText = computed(() => {
|
|||||||
<UIcon name="i-lucide-circle-alert" class="size-3.5" />
|
<UIcon name="i-lucide-circle-alert" class="size-3.5" />
|
||||||
<span>{{ t('Save failed') }}</span>
|
<span>{{ t('Save failed') }}</span>
|
||||||
<UButton
|
<UButton
|
||||||
|
v-if="retryable"
|
||||||
size="xs"
|
size="xs"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ async function mountArticleEditor(body = '<p>Hello</p>') {
|
|||||||
title: 'T',
|
title: 'T',
|
||||||
body,
|
body,
|
||||||
is_online: false,
|
is_online: false,
|
||||||
|
capabilities: ['read', 'edit', 'publish', 'delete'],
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
updated_at: '2026-01-01T00:00:00Z',
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ function articleResponse(body: string) {
|
|||||||
title: 'How we price a bathroom renovation',
|
title: 'How we price a bathroom renovation',
|
||||||
body,
|
body,
|
||||||
is_online: false,
|
is_online: false,
|
||||||
|
capabilities: ['read', 'edit', 'publish', 'delete'],
|
||||||
created_at: '2026-01-01T00:00:00Z',
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
updated_at: '2026-01-01T00:00:00Z',
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
|
||||||
import PublishControl from '../PublishControl.vue'
|
|
||||||
|
|
||||||
function mountControl(props: { online: boolean; dirty: boolean; disabled?: boolean }) {
|
|
||||||
return mount(PublishControl, {
|
|
||||||
props,
|
|
||||||
global: { plugins: [uiPlugin] },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Draft, clean', () => {
|
|
||||||
it('reads "Publish"', () => {
|
|
||||||
const wrapper = mountControl({ online: false, dirty: false })
|
|
||||||
|
|
||||||
expect(wrapper.text()).toBe('Publish')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Draft, dirty', () => {
|
|
||||||
it('reads "Save and publish"', () => {
|
|
||||||
const wrapper = mountControl({ online: false, dirty: true })
|
|
||||||
|
|
||||||
expect(wrapper.text()).toBe('Save and publish')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Online', () => {
|
|
||||||
it('reads "Unpublish" regardless of dirty state', () => {
|
|
||||||
const wrapper = mountControl({ online: true, dirty: true })
|
|
||||||
|
|
||||||
expect(wrapper.text()).toBe('Unpublish')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Disabled', () => {
|
|
||||||
it('disables the button', () => {
|
|
||||||
const wrapper = mountControl({
|
|
||||||
online: false,
|
|
||||||
dirty: false,
|
|
||||||
disabled: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect((wrapper.find('button').element as HTMLButtonElement).disabled).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
export type LocaleBadgeTone = 'neutral' | 'info' | 'success' | 'warning' | 'error'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
tone: 'neutral' | 'info'
|
tone: LocaleBadgeTone
|
||||||
icon: string
|
icon: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const TONE_CLASS: Record<LocaleBadgeTone, string> = {
|
||||||
|
neutral: 'border-(--ui-bg-elevated) bg-elevated text-toned',
|
||||||
|
info: 'border-(--rc-info-soft) bg-(--rc-info-soft) text-info',
|
||||||
|
success: 'border-(--rc-success-soft) bg-(--rc-success-soft) text-success',
|
||||||
|
warning: 'border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
|
||||||
|
error: 'border-(--rc-error-soft) bg-(--rc-error-soft) text-error',
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -11,13 +21,9 @@ defineProps<{
|
|||||||
variant="soft"
|
variant="soft"
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
class="border"
|
class="border"
|
||||||
:class="
|
:class="TONE_CLASS[tone]"
|
||||||
tone === 'info'
|
|
||||||
? 'border-(--rc-info-soft) bg-(--rc-info-soft) text-info'
|
|
||||||
: 'border-(--ui-bg-elevated) bg-elevated text-toned'
|
|
||||||
"
|
|
||||||
:ui="{
|
:ui="{
|
||||||
base: 'h-5 shrink-0 gap-1 px-1.5 py-0 text-xs whitespace-nowrap',
|
base: 'h-5 shrink-0 gap-1 px-1.25 py-0 text-xs whitespace-nowrap',
|
||||||
leadingIcon: 'size-3',
|
leadingIcon: 'size-3',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { setActivePinia, createPinia } from 'pinia'
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
import { useArticleDraft, readMirroredDraft } from '@/composables/useArticleDraft'
|
import { useArticleDraft } from '@/composables/useArticleDraft'
|
||||||
|
import { readStoredDraft } from '@/lib/storedDraft'
|
||||||
|
|
||||||
|
const USER_ID = 7
|
||||||
|
|
||||||
function mockResponse(status: number, body: unknown = null) {
|
function mockResponse(status: number, body: unknown = null) {
|
||||||
return {
|
return {
|
||||||
@@ -19,7 +22,7 @@ afterEach(() => {
|
|||||||
describe('Loading an article without a mirrored draft starts clean', () => {
|
describe('Loading an article without a mirrored draft starts clean', () => {
|
||||||
it('sets the body and clean state from the given article', () => {
|
it('sets the body and clean state from the given article', () => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
const draft = useArticleDraft(12)
|
const draft = useArticleDraft(12, USER_ID)
|
||||||
|
|
||||||
draft.load('<p>Original</p>', new Date('2026-09-26T11:02:00Z'))
|
draft.load('<p>Original</p>', new Date('2026-09-26T11:02:00Z'))
|
||||||
|
|
||||||
@@ -32,8 +35,11 @@ describe('Loading an article without a mirrored draft starts clean', () => {
|
|||||||
describe('Loading an article with a mirrored draft restores it as dirty', () => {
|
describe('Loading an article with a mirrored draft restores it as dirty', () => {
|
||||||
it('prefers the sessionStorage body over the loaded one', () => {
|
it('prefers the sessionStorage body over the loaded one', () => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
sessionStorage.setItem('renewcms:article-draft:12', '<p>Restored</p>')
|
sessionStorage.setItem(
|
||||||
const draft = useArticleDraft(12)
|
'renewcms:article-draft:7:12',
|
||||||
|
JSON.stringify({ body: '<p>Restored</p>', mirrored_at: '2026-09-26T14:18:00Z' }),
|
||||||
|
)
|
||||||
|
const draft = useArticleDraft(12, USER_ID)
|
||||||
|
|
||||||
draft.load('<p>Original</p>', new Date('2026-09-26T11:02:00Z'))
|
draft.load('<p>Original</p>', new Date('2026-09-26T11:02:00Z'))
|
||||||
|
|
||||||
@@ -45,7 +51,7 @@ describe('Loading an article with a mirrored draft restores it as dirty', () =>
|
|||||||
describe('Editing moves the state to dirty', () => {
|
describe('Editing moves the state to dirty', () => {
|
||||||
it('updates the body and sets state to dirty', () => {
|
it('updates the body and sets state to dirty', () => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
const draft = useArticleDraft(12)
|
const draft = useArticleDraft(12, USER_ID)
|
||||||
draft.load('<p>Original</p>', new Date())
|
draft.load('<p>Original</p>', new Date())
|
||||||
|
|
||||||
draft.edit('<p>Changed</p>')
|
draft.edit('<p>Changed</p>')
|
||||||
@@ -61,7 +67,7 @@ describe('A successful save sets state saved and records the time', () => {
|
|||||||
global.fetch = vi
|
global.fetch = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue(mockResponse(200, { id: 12, body: '<p>Changed</p>' }))
|
.mockResolvedValue(mockResponse(200, { id: 12, body: '<p>Changed</p>' }))
|
||||||
const draft = useArticleDraft(12)
|
const draft = useArticleDraft(12, USER_ID)
|
||||||
draft.load('<p>Original</p>', new Date())
|
draft.load('<p>Original</p>', new Date())
|
||||||
draft.edit('<p>Changed</p>')
|
draft.edit('<p>Changed</p>')
|
||||||
|
|
||||||
@@ -77,7 +83,7 @@ describe('A 500 response sets state error and keeps the body', () => {
|
|||||||
it('resolves "error" without touching the body', async () => {
|
it('resolves "error" without touching the body', async () => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
global.fetch = vi.fn().mockResolvedValue(mockResponse(500))
|
global.fetch = vi.fn().mockResolvedValue(mockResponse(500))
|
||||||
const draft = useArticleDraft(12)
|
const draft = useArticleDraft(12, USER_ID)
|
||||||
draft.load('<p>Original</p>', new Date())
|
draft.load('<p>Original</p>', new Date())
|
||||||
draft.edit('<p>Changed</p>')
|
draft.edit('<p>Changed</p>')
|
||||||
|
|
||||||
@@ -93,7 +99,7 @@ describe('A 404 response keeps the body and does not enter the error state', ()
|
|||||||
it('resolves "not-found" and stays dirty', async () => {
|
it('resolves "not-found" and stays dirty', async () => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
global.fetch = vi.fn().mockResolvedValue(mockResponse(404))
|
global.fetch = vi.fn().mockResolvedValue(mockResponse(404))
|
||||||
const draft = useArticleDraft(12)
|
const draft = useArticleDraft(12, USER_ID)
|
||||||
draft.load('<p>Original</p>', new Date())
|
draft.load('<p>Original</p>', new Date())
|
||||||
draft.edit('<p>Changed</p>')
|
draft.edit('<p>Changed</p>')
|
||||||
|
|
||||||
@@ -105,17 +111,17 @@ describe('A 404 response keeps the body and does not enter the error state', ()
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('A 401 response mirrors the body to sessionStorage', () => {
|
describe('A 401 response mirrors the body to sessionStorage', () => {
|
||||||
it('resolves "unauthorized" and stores the body keyed by the article id', async () => {
|
it('resolves "unauthorized" and stores the body keyed by the user and the article id', async () => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
global.fetch = vi.fn().mockResolvedValue(mockResponse(401))
|
global.fetch = vi.fn().mockResolvedValue(mockResponse(401))
|
||||||
const draft = useArticleDraft(12)
|
const draft = useArticleDraft(12, USER_ID)
|
||||||
draft.load('<p>Original</p>', new Date())
|
draft.load('<p>Original</p>', new Date())
|
||||||
draft.edit('<p>Paragraph added just before the session expired.</p>')
|
draft.edit('<p>Paragraph added just before the session expired.</p>')
|
||||||
|
|
||||||
const outcome = await draft.save()
|
const outcome = await draft.save()
|
||||||
|
|
||||||
expect(outcome).toBe('unauthorized')
|
expect(outcome).toBe('unauthorized')
|
||||||
expect(readMirroredDraft(12)).toBe(
|
expect(readStoredDraft(USER_ID, 12)?.body).toBe(
|
||||||
'<p>Paragraph added just before the session expired.</p>',
|
'<p>Paragraph added just before the session expired.</p>',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const saved: ArticleSeo = {
|
|||||||
function loaded(sent = vi.fn()) {
|
function loaded(sent = vi.fn()) {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
global.fetch = sent.mockResolvedValue(reply(200, {})) as unknown as typeof fetch
|
global.fetch = sent.mockResolvedValue(reply(200, {})) as unknown as typeof fetch
|
||||||
const draft = useArticleDraft(12)
|
const draft = useArticleDraft(12, 7)
|
||||||
draft.load('<p>Body</p>', new Date('2026-09-26T11:02:00Z'), undefined, saved)
|
draft.load('<p>Body</p>', new Date('2026-09-26T11:02:00Z'), undefined, saved)
|
||||||
return { draft, sent }
|
return { draft, sent }
|
||||||
}
|
}
|
||||||
@@ -162,12 +162,14 @@ describe('The SEO fields are mirrored in sessionStorage', () => {
|
|||||||
const outcome = await draft.save()
|
const outcome = await draft.save()
|
||||||
|
|
||||||
expect(outcome).toBe('unauthorized')
|
expect(outcome).toBe('unauthorized')
|
||||||
expect(JSON.parse(sessionStorage.getItem('renewcms:article-draft-seo:12')!)).toMatchObject({
|
expect(
|
||||||
|
JSON.parse(sessionStorage.getItem('renewcms:article-draft:7:12')!).seo,
|
||||||
|
).toMatchObject({
|
||||||
seo_title: 'Kept across the login',
|
seo_title: 'Kept across the login',
|
||||||
})
|
})
|
||||||
|
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
const restored = useArticleDraft(12)
|
const restored = useArticleDraft(12, 7)
|
||||||
restored.load('<p>Body</p>', new Date(), undefined, saved)
|
restored.load('<p>Body</p>', new Date(), undefined, saved)
|
||||||
expect(restored.state.value).toBe('dirty')
|
expect(restored.state.value).toBe('dirty')
|
||||||
expect(restored.seo.value.seo_title).toBe('Kept across the login')
|
expect(restored.seo.value.seo_title).toBe('Kept across the login')
|
||||||
@@ -178,11 +180,11 @@ describe('The SEO fields are mirrored in sessionStorage', () => {
|
|||||||
const { draft } = loaded()
|
const { draft } = loaded()
|
||||||
draft.editSeo({ seo_title: 'Soon saved' })
|
draft.editSeo({ seo_title: 'Soon saved' })
|
||||||
draft.mirrorToStorage()
|
draft.mirrorToStorage()
|
||||||
expect(sessionStorage.getItem('renewcms:article-draft-seo:12')).not.toBeNull()
|
expect(sessionStorage.getItem('renewcms:article-draft:7:12')).not.toBeNull()
|
||||||
|
|
||||||
await draft.save()
|
await draft.save()
|
||||||
|
|
||||||
expect(sessionStorage.getItem('renewcms:article-draft-seo:12')).toBeNull()
|
expect(sessionStorage.getItem('renewcms:article-draft:7:12')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps working in memory when sessionStorage throws', async () => {
|
it('keeps working in memory when sessionStorage throws', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useEditorVersions } from '@/composables/useEditorVersions'
|
||||||
|
import type { Article, ArticleCapability, CoverageEntry } from '@/lib/articleTypes'
|
||||||
|
import type { Site } from '@/composables/useSites'
|
||||||
|
|
||||||
|
const getLanguages = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/composables/useSiteLanguages', () => ({ useSiteLanguages: () => ({ getLanguages }) }))
|
||||||
|
|
||||||
|
const FULL: ArticleCapability[] = ['read', 'edit', 'publish', 'delete', 'create_translation']
|
||||||
|
|
||||||
|
function entry(id: number, lang: string, overrides: Partial<CoverageEntry> = {}): CoverageEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
lang,
|
||||||
|
title: `T${id}`,
|
||||||
|
is_online: false,
|
||||||
|
language_enabled: true,
|
||||||
|
capabilities: FULL,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function articleWith(translations: CoverageEntry[], overrides: Partial<Article> = {}): Article {
|
||||||
|
return {
|
||||||
|
id: 12,
|
||||||
|
title: 'T12',
|
||||||
|
body: '',
|
||||||
|
is_online: false,
|
||||||
|
lang: 'fr',
|
||||||
|
site_id: 1,
|
||||||
|
capabilities: FULL,
|
||||||
|
translations,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sites = ref([{ id: 1, key: 'journal' }] as Site[])
|
||||||
|
|
||||||
|
function languages(...tags: [string, boolean][]) {
|
||||||
|
return {
|
||||||
|
languages: tags.map(([lang, enabled]) => ({ lang, enabled })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(article: Article | null) {
|
||||||
|
return useEditorVersions(ref(article), sites, ref(FULL))
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
getLanguages.mockReset()
|
||||||
|
getLanguages.mockResolvedValue(languages(['fr', true], ['en', true]))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The editor language versions', () => {
|
||||||
|
it('is not multilingual with a single enabled language and a single translation', async () => {
|
||||||
|
getLanguages.mockResolvedValue(languages(['fr', true]))
|
||||||
|
const versions = setup(articleWith([entry(12, 'fr')]))
|
||||||
|
await versions.loadLanguages()
|
||||||
|
|
||||||
|
expect(versions.multilingual.value).toBe(false)
|
||||||
|
expect(versions.sentenceName.value).toBeNull()
|
||||||
|
expect(versions.rows.value).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is multilingual with two translations and lists the siblings', () => {
|
||||||
|
const versions = setup(articleWith([entry(12, 'fr'), entry(10, 'en', { is_online: true })]))
|
||||||
|
|
||||||
|
expect(versions.multilingual.value).toBe(true)
|
||||||
|
expect(versions.siblings.value.map((sibling) => sibling.id)).toEqual([10])
|
||||||
|
expect(versions.onlineSiblingNames.value).toHaveLength(1)
|
||||||
|
expect(versions.rows.value.map((row) => row.lang)).toEqual(['fr', 'en'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers a missing enabled language as a translation to add', async () => {
|
||||||
|
getLanguages.mockResolvedValue(languages(['fr', true], ['en', true], ['de', true]))
|
||||||
|
const versions = setup(articleWith([entry(12, 'fr'), entry(10, 'en')]))
|
||||||
|
await versions.loadLanguages()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
versions.rows.value.filter((row) => row.addTranslation).map((row) => row.lang),
|
||||||
|
).toEqual(['de'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags a disabled language on the current version', () => {
|
||||||
|
const versions = setup(
|
||||||
|
articleWith([entry(12, 'fr', { language_enabled: false }), entry(10, 'en')]),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(versions.languageDisabled.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to no languages when loading them fails', async () => {
|
||||||
|
getLanguages.mockRejectedValue(new Error('offline'))
|
||||||
|
const versions = setup(articleWith([entry(12, 'fr')]))
|
||||||
|
|
||||||
|
await versions.loadLanguages()
|
||||||
|
|
||||||
|
expect(versions.multilingual.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not load languages for an article without a language tag', async () => {
|
||||||
|
const versions = setup(articleWith([], { lang: undefined }))
|
||||||
|
|
||||||
|
await versions.loadLanguages()
|
||||||
|
|
||||||
|
expect(getLanguages).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a translation online and retitles it in place', () => {
|
||||||
|
const versions = setup(articleWith([entry(12, 'fr'), entry(10, 'en')]))
|
||||||
|
|
||||||
|
versions.markOnline(10, true)
|
||||||
|
versions.markTitle(10, 'New')
|
||||||
|
|
||||||
|
expect(versions.siblings.value[0]).toMatchObject({ is_online: true, title: 'New' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { nextTick, watch } from 'vue'
|
||||||
|
import { announce, liveMessage } from '@/composables/useLiveAnnouncement'
|
||||||
|
|
||||||
|
describe('The live announcement', () => {
|
||||||
|
it('exposes the announced text', async () => {
|
||||||
|
announce('Editing French version')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(liveMessage.value).toBe('Editing French version')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the text first so an identical message changes the region again', async () => {
|
||||||
|
announce('Saved')
|
||||||
|
await nextTick()
|
||||||
|
const seen: string[] = []
|
||||||
|
const stop = watchValues(seen)
|
||||||
|
|
||||||
|
announce('Saved')
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
stop()
|
||||||
|
|
||||||
|
expect(seen).toEqual(['', 'Saved'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function watchValues(seen: string[]) {
|
||||||
|
return watch(liveMessage, (value) => seen.push(value), { flush: 'sync' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import router from '@/router'
|
||||||
|
import { useShellLogout } from '@/composables/useShellSession'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import { clearStashedArticle, stashArticle, takeStashedArticle } from '@/lib/prefetchedArticle'
|
||||||
|
|
||||||
|
const request = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: () => ({ request }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => request.mockReset().mockResolvedValue(undefined))
|
||||||
|
afterEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
clearStashedArticle()
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountLogout() {
|
||||||
|
let logout!: () => Promise<void>
|
||||||
|
mount(
|
||||||
|
defineComponent({
|
||||||
|
setup() {
|
||||||
|
logout = useShellLogout().logout
|
||||||
|
return () => null
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ global: { plugins: [router] } },
|
||||||
|
)
|
||||||
|
return logout
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('An explicit logout removes the stored drafts of that user only', () => {
|
||||||
|
it('removes every draft of user 7 and keeps the drafts of user 8', async () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
useSessionStore().session = { id: 7, verified: true, displayName: '' }
|
||||||
|
sessionStorage.setItem('renewcms:article-draft:7:12', '{}')
|
||||||
|
sessionStorage.setItem('renewcms:article-draft:7:30', '{}')
|
||||||
|
sessionStorage.setItem('renewcms:article-draft:8:12', '{}')
|
||||||
|
const logout = mountLogout()
|
||||||
|
|
||||||
|
await logout()
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('renewcms:article-draft:7:12')).toBeNull()
|
||||||
|
expect(sessionStorage.getItem('renewcms:article-draft:7:30')).toBeNull()
|
||||||
|
expect(sessionStorage.getItem('renewcms:article-draft:8:12')).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Logout cleans up even when the request fails', () => {
|
||||||
|
it('still removes the drafts, the prefetched article and the session', async () => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const session = useSessionStore()
|
||||||
|
session.session = { id: 7, verified: true, displayName: '' }
|
||||||
|
sessionStorage.setItem('renewcms:article-draft:7:12', '{}')
|
||||||
|
stashArticle({
|
||||||
|
id: 12,
|
||||||
|
title: 'T',
|
||||||
|
body: '',
|
||||||
|
is_online: false,
|
||||||
|
capabilities: ['read'],
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
request.mockRejectedValueOnce(new Error('network'))
|
||||||
|
const logout = mountLogout()
|
||||||
|
|
||||||
|
await logout()
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('renewcms:article-draft:7:12')).toBeNull()
|
||||||
|
expect(takeStashedArticle(12)).toBeNull()
|
||||||
|
expect(session.session).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { defineComponent, h } from 'vue'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { createMemoryHistory, createRouter, RouterView } from 'vue-router'
|
||||||
|
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
||||||
|
|
||||||
|
const open = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@nuxt/ui/composables', () => ({
|
||||||
|
useOverlay: () => ({ create: () => ({ open }) }),
|
||||||
|
useToast: () => ({ add: vi.fn(), toasts: { value: [] } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
let dirty = true
|
||||||
|
const onDiscard = vi.fn()
|
||||||
|
|
||||||
|
const Editor = defineComponent({
|
||||||
|
setup() {
|
||||||
|
useUnsavedChangesGuard({ isDirty: () => dirty, title: () => 'Draft', onDiscard })
|
||||||
|
return () => null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function mountAt(path: string) {
|
||||||
|
const router = createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/article/:id/edit', component: Editor },
|
||||||
|
{ path: '/article', component: { render: () => null } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await router.push(path)
|
||||||
|
await router.isReady()
|
||||||
|
mount({ render: () => h(RouterView) }, { global: { plugins: [router] } })
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
open.mockReset()
|
||||||
|
onDiscard.mockReset()
|
||||||
|
dirty = true
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The unsaved changes guard', () => {
|
||||||
|
it('asks before leaving the page while the draft is dirty', async () => {
|
||||||
|
open.mockResolvedValue(true)
|
||||||
|
const router = await mountAt('/article/1/edit')
|
||||||
|
|
||||||
|
await router.push('/article')
|
||||||
|
|
||||||
|
expect(open).toHaveBeenCalledOnce()
|
||||||
|
expect(router.currentRoute.value.path).toBe('/article/1/edit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks before a param-only navigation to another article', async () => {
|
||||||
|
open.mockResolvedValue(true)
|
||||||
|
const router = await mountAt('/article/1/edit')
|
||||||
|
|
||||||
|
await router.push('/article/2/edit')
|
||||||
|
|
||||||
|
expect(open).toHaveBeenCalledOnce()
|
||||||
|
expect(router.currentRoute.value.path).toBe('/article/1/edit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('discards and follows a param-only navigation when the user chooses to discard', async () => {
|
||||||
|
open.mockResolvedValue(false)
|
||||||
|
const router = await mountAt('/article/1/edit')
|
||||||
|
|
||||||
|
await router.push('/article/2/edit')
|
||||||
|
|
||||||
|
expect(onDiscard).toHaveBeenCalledOnce()
|
||||||
|
expect(router.currentRoute.value.path).toBe('/article/2/edit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not ask when nothing is unsaved', async () => {
|
||||||
|
dirty = false
|
||||||
|
const router = await mountAt('/article/1/edit')
|
||||||
|
|
||||||
|
await router.push('/article/2/edit')
|
||||||
|
|
||||||
|
expect(open).not.toHaveBeenCalled()
|
||||||
|
expect(router.currentRoute.value.path).toBe('/article/2/edit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not ask when only the query changes', async () => {
|
||||||
|
const router = await mountAt('/article/1/edit')
|
||||||
|
|
||||||
|
await router.push('/article/1/edit?tab=seo')
|
||||||
|
|
||||||
|
expect(open).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { useVersionSwitch } from '@/composables/useVersionSwitch'
|
||||||
|
import { takeStashedArticle } from '@/lib/prefetchedArticle'
|
||||||
|
import type { VersionRow } from '@/lib/editorVersions'
|
||||||
|
|
||||||
|
const push = vi.fn()
|
||||||
|
const request = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
|
||||||
|
vi.mock('@/composables/useApi', () => ({
|
||||||
|
useApi: () => ({ request }),
|
||||||
|
authRedirectPending: { value: false },
|
||||||
|
}))
|
||||||
|
vi.mock('@nuxt/ui/composables', () => ({
|
||||||
|
useOverlay: () => ({ create: () => ({ open: () => Promise.resolve('save') }) }),
|
||||||
|
useToast: () => ({ add: vi.fn(), toasts: { value: [] } }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const row: VersionRow = {
|
||||||
|
key: 'version-11',
|
||||||
|
lang: 'de',
|
||||||
|
name: 'German',
|
||||||
|
code: 'DE',
|
||||||
|
articleId: 11,
|
||||||
|
title: 'T',
|
||||||
|
current: false,
|
||||||
|
online: false,
|
||||||
|
readOnly: false,
|
||||||
|
languageDisabled: false,
|
||||||
|
addTranslation: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = {
|
||||||
|
id: 11,
|
||||||
|
title: 'T',
|
||||||
|
body: '',
|
||||||
|
is_online: false,
|
||||||
|
capabilities: ['read'],
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountSwitch() {
|
||||||
|
const allowLeave = vi.fn()
|
||||||
|
const cancelLeave = vi.fn()
|
||||||
|
let choose!: (row: VersionRow) => Promise<void>
|
||||||
|
mount(
|
||||||
|
defineComponent({
|
||||||
|
setup() {
|
||||||
|
choose = useVersionSwitch({
|
||||||
|
currentName: () => 'French',
|
||||||
|
hasUnsavedChanges: () => false,
|
||||||
|
save: async () => 'saved',
|
||||||
|
discard: () => undefined,
|
||||||
|
allowLeave,
|
||||||
|
cancelLeave,
|
||||||
|
}).choose
|
||||||
|
return () => null
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return { choose, allowLeave, cancelLeave }
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
push.mockReset()
|
||||||
|
request.mockReset()
|
||||||
|
request.mockResolvedValue(target)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Switching to another version', () => {
|
||||||
|
it('lets the leave through and stashes the fetched article before navigating', async () => {
|
||||||
|
push.mockResolvedValue(undefined)
|
||||||
|
const { choose, allowLeave, cancelLeave } = mountSwitch()
|
||||||
|
|
||||||
|
await choose(row)
|
||||||
|
|
||||||
|
expect(allowLeave).toHaveBeenCalledOnce()
|
||||||
|
expect(cancelLeave).not.toHaveBeenCalled()
|
||||||
|
expect(push).toHaveBeenCalledWith('/article/11/edit')
|
||||||
|
expect(takeStashedArticle(11)?.id).toBe(11)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['throws', () => push.mockRejectedValue(new Error('boom'))],
|
||||||
|
['is cancelled', () => push.mockResolvedValue({ type: 4 })],
|
||||||
|
])(
|
||||||
|
'restores the leave guard and clears the stash when the navigation %s',
|
||||||
|
async (_, arrange) => {
|
||||||
|
arrange()
|
||||||
|
const { choose, cancelLeave } = mountSwitch()
|
||||||
|
|
||||||
|
await choose(row)
|
||||||
|
|
||||||
|
expect(cancelLeave).toHaveBeenCalledOnce()
|
||||||
|
expect(takeStashedArticle(11)).toBeNull()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('never lets the leave through when the fetch fails', async () => {
|
||||||
|
request.mockRejectedValue(new Error('offline'))
|
||||||
|
const { choose, allowLeave } = mountSwitch()
|
||||||
|
|
||||||
|
await choose(row)
|
||||||
|
|
||||||
|
expect(allowLeave).not.toHaveBeenCalled()
|
||||||
|
expect(push).not.toHaveBeenCalled()
|
||||||
|
expect(takeStashedArticle(11)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,6 +4,13 @@ import type { Article, Byline } from '@/composables/useArticles'
|
|||||||
import { sameIds, toBylines } from '@/lib/articleMeta'
|
import { sameIds, toBylines } from '@/lib/articleMeta'
|
||||||
import { EMPTY_SEO, seoChanges, seoOf, type ArticleSeo } from '@/lib/articleSeo'
|
import { EMPTY_SEO, seoChanges, seoOf, type ArticleSeo } from '@/lib/articleSeo'
|
||||||
import { AUTHOR_CODES, SITE_CODES, codeOf, statusOf } from '@/lib/directoryErrors'
|
import { AUTHOR_CODES, SITE_CODES, codeOf, statusOf } from '@/lib/directoryErrors'
|
||||||
|
import { TITLE_SERVER_MESSAGE } from '@/lib/editorTitle'
|
||||||
|
import {
|
||||||
|
readStoredDraft,
|
||||||
|
removeStoredDraft,
|
||||||
|
writeStoredDraft,
|
||||||
|
type StoredDraft,
|
||||||
|
} from '@/lib/storedDraft'
|
||||||
|
|
||||||
export type ArticleDraftBusinessState = 'clean' | 'dirty' | 'saving' | 'saved' | 'error'
|
export type ArticleDraftBusinessState = 'clean' | 'dirty' | 'saving' | 'saved' | 'error'
|
||||||
export type ArticleDraftState = ArticleDraftBusinessState | 'offline'
|
export type ArticleDraftState = ArticleDraftBusinessState | 'offline'
|
||||||
@@ -16,86 +23,24 @@ export type SaveOutcome =
|
|||||||
| 'author-gone'
|
| 'author-gone'
|
||||||
| 'slug-taken'
|
| 'slug-taken'
|
||||||
| 'invalid-url'
|
| 'invalid-url'
|
||||||
|
| 'invalid-title'
|
||||||
|
|
||||||
export interface ArticleMeta {
|
export interface ArticleMeta {
|
||||||
siteId: number | null
|
siteId: number | null
|
||||||
authors: Byline[]
|
authors: Byline[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function draftStorageKey(articleId: number) {
|
|
||||||
return `renewcms:article-draft:${articleId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function metaStorageKey(articleId: number) {
|
|
||||||
return `renewcms:article-draft-meta:${articleId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function seoStorageKey(articleId: number) {
|
|
||||||
return `renewcms:article-draft-seo:${articleId}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const SLUG_TAKEN_CODE = 'slug_taken'
|
const SLUG_TAKEN_CODE = 'slug_taken'
|
||||||
const INVALID_URL_CODE = 'invalid_url'
|
const INVALID_URL_CODE = 'invalid_url'
|
||||||
|
|
||||||
function readStorage(key: string): string | null {
|
export function useArticleDraft(articleId: number, userId: number | null = null) {
|
||||||
try {
|
|
||||||
return sessionStorage.getItem(key)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeStorage(key: string, value: string) {
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(key, value)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeStorage(key: string) {
|
|
||||||
try {
|
|
||||||
sessionStorage.removeItem(key)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readMirroredMeta(articleId: number): ArticleMeta | null {
|
|
||||||
const raw = readStorage(metaStorageKey(articleId))
|
|
||||||
if (raw === null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as {
|
|
||||||
site_id: number | null
|
|
||||||
authors: Byline[]
|
|
||||||
}
|
|
||||||
return { siteId: parsed.site_id, authors: toBylines(parsed.authors) }
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readMirroredSeo(articleId: number, base: ArticleSeo): ArticleSeo | null {
|
|
||||||
const raw = readStorage(seoStorageKey(articleId))
|
|
||||||
if (raw === null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return seoOf(JSON.parse(raw), base)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readMirroredDraft(articleId: number): string | null {
|
|
||||||
return readStorage(draftStorageKey(articleId))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useArticleDraft(articleId: number) {
|
|
||||||
const { request } = useApi()
|
const { request } = useApi()
|
||||||
|
|
||||||
|
const title = ref('')
|
||||||
|
const savedTitle = ref('')
|
||||||
|
const titleServerError = ref('')
|
||||||
|
const savingTitle = ref(false)
|
||||||
|
const restoredAt = ref<Date | null>(null)
|
||||||
const body = ref('')
|
const body = ref('')
|
||||||
const bodyEdited = ref(false)
|
const bodyEdited = ref(false)
|
||||||
const meta = ref<ArticleMeta>({ siteId: null, authors: [] })
|
const meta = ref<ArticleMeta>({ siteId: null, authors: [] })
|
||||||
@@ -140,28 +85,29 @@ export function useArticleDraft(articleId: number) {
|
|||||||
return seoChanges(seo.value, savedSeo.value)
|
return seoChanges(seo.value, savedSeo.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function mirrorToStorage() {
|
function titleChanged() {
|
||||||
if (bodyEdited.value) {
|
return title.value !== savedTitle.value
|
||||||
writeStorage(draftStorageKey(articleId), body.value)
|
|
||||||
}
|
|
||||||
if (siteChanged() || authorsChanged()) {
|
|
||||||
writeStorage(
|
|
||||||
metaStorageKey(articleId),
|
|
||||||
JSON.stringify({
|
|
||||||
site_id: meta.value.siteId,
|
|
||||||
authors: meta.value.authors,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (Object.keys(seoPatch()).length > 0) {
|
|
||||||
writeStorage(seoStorageKey(articleId), JSON.stringify(seo.value))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearStorage() {
|
function mirrorToStorage() {
|
||||||
removeStorage(draftStorageKey(articleId))
|
const draft: StoredDraft = { mirrored_at: new Date().toISOString() }
|
||||||
removeStorage(metaStorageKey(articleId))
|
if (titleChanged()) {
|
||||||
removeStorage(seoStorageKey(articleId))
|
draft.title = title.value
|
||||||
|
}
|
||||||
|
if (bodyEdited.value) {
|
||||||
|
draft.body = body.value
|
||||||
|
}
|
||||||
|
if (siteChanged() || authorsChanged()) {
|
||||||
|
draft.meta = { site_id: meta.value.siteId, authors: meta.value.authors }
|
||||||
|
}
|
||||||
|
if (Object.keys(seoPatch()).length > 0) {
|
||||||
|
draft.seo = seo.value
|
||||||
|
}
|
||||||
|
writeStoredDraft(userId, articleId, draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardStored() {
|
||||||
|
removeStoredDraft(userId, articleId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function load(
|
function load(
|
||||||
@@ -169,19 +115,28 @@ export function useArticleDraft(articleId: number) {
|
|||||||
initialSavedAt: Date,
|
initialSavedAt: Date,
|
||||||
initialMeta?: ArticleMeta,
|
initialMeta?: ArticleMeta,
|
||||||
initialSeo?: ArticleSeo,
|
initialSeo?: ArticleSeo,
|
||||||
|
initialTitle = '',
|
||||||
) {
|
) {
|
||||||
const mirrored = readMirroredDraft(articleId)
|
const stored = readStoredDraft(userId, articleId)
|
||||||
const mirroredMeta = readMirroredMeta(articleId)
|
|
||||||
savedAt.value = initialSavedAt
|
savedAt.value = initialSavedAt
|
||||||
|
savedTitle.value = initialTitle
|
||||||
|
title.value = stored?.title ?? initialTitle
|
||||||
savedMeta.value = initialMeta ?? { siteId: null, authors: [] }
|
savedMeta.value = initialMeta ?? { siteId: null, authors: [] }
|
||||||
meta.value = mirroredMeta ?? savedMeta.value
|
meta.value = stored?.meta
|
||||||
|
? { siteId: stored.meta.site_id, authors: toBylines(stored.meta.authors) }
|
||||||
|
: savedMeta.value
|
||||||
savedSeo.value = initialSeo ?? { ...EMPTY_SEO }
|
savedSeo.value = initialSeo ?? { ...EMPTY_SEO }
|
||||||
const mirroredSeo = readMirroredSeo(articleId, savedSeo.value)
|
seo.value = stored?.seo ? seoOf(stored.seo, savedSeo.value) : savedSeo.value
|
||||||
seo.value = mirroredSeo ?? savedSeo.value
|
bodyEdited.value = stored?.body !== undefined
|
||||||
bodyEdited.value = mirrored !== null
|
body.value = stored?.body ?? initialBody
|
||||||
body.value = mirrored ?? initialBody
|
restoredAt.value = stored ? new Date(stored.mirrored_at) : null
|
||||||
const restored = mirrored !== null || mirroredMeta !== null || mirroredSeo !== null
|
businessState.value = stored ? 'dirty' : 'clean'
|
||||||
businessState.value = restored ? 'dirty' : 'clean'
|
}
|
||||||
|
|
||||||
|
function editTitle(next: string) {
|
||||||
|
title.value = next
|
||||||
|
titleServerError.value = ''
|
||||||
|
businessState.value = 'dirty'
|
||||||
}
|
}
|
||||||
|
|
||||||
function edit(newBody: string) {
|
function edit(newBody: string) {
|
||||||
@@ -213,6 +168,7 @@ export function useArticleDraft(articleId: number) {
|
|||||||
function savePayload() {
|
function savePayload() {
|
||||||
return {
|
return {
|
||||||
...seoPatch(),
|
...seoPatch(),
|
||||||
|
...(titleChanged() ? { title: title.value } : {}),
|
||||||
...(bodyEdited.value ? { body: body.value } : {}),
|
...(bodyEdited.value ? { body: body.value } : {}),
|
||||||
...(siteChanged() ? { site_id: meta.value.siteId } : {}),
|
...(siteChanged() ? { site_id: meta.value.siteId } : {}),
|
||||||
...(authorsChanged()
|
...(authorsChanged()
|
||||||
@@ -223,6 +179,10 @@ export function useArticleDraft(articleId: number) {
|
|||||||
|
|
||||||
function rejectedOutcome(err: unknown): SaveOutcome | null {
|
function rejectedOutcome(err: unknown): SaveOutcome | null {
|
||||||
const code = codeOf(err)
|
const code = codeOf(err)
|
||||||
|
if (statusOf(err) === 400 && code === TITLE_SERVER_MESSAGE) {
|
||||||
|
titleServerError.value = code
|
||||||
|
return 'invalid-title'
|
||||||
|
}
|
||||||
if (statusOf(err) === 409 && code === SLUG_TAKEN_CODE) {
|
if (statusOf(err) === 409 && code === SLUG_TAKEN_CODE) {
|
||||||
return 'slug-taken'
|
return 'slug-taken'
|
||||||
}
|
}
|
||||||
@@ -242,6 +202,7 @@ export function useArticleDraft(articleId: number) {
|
|||||||
|
|
||||||
async function save(): Promise<SaveOutcome> {
|
async function save(): Promise<SaveOutcome> {
|
||||||
businessState.value = 'saving'
|
businessState.value = 'saving'
|
||||||
|
savingTitle.value = titleChanged()
|
||||||
const payload = savePayload()
|
const payload = savePayload()
|
||||||
try {
|
try {
|
||||||
const saved =
|
const saved =
|
||||||
@@ -253,14 +214,18 @@ export function useArticleDraft(articleId: number) {
|
|||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
businessState.value = 'saved'
|
businessState.value = 'saved'
|
||||||
|
savingTitle.value = false
|
||||||
savedAt.value = new Date()
|
savedAt.value = new Date()
|
||||||
|
savedTitle.value = title.value
|
||||||
|
restoredAt.value = null
|
||||||
savedMeta.value = meta.value
|
savedMeta.value = meta.value
|
||||||
savedSeo.value = seoOf(saved, seo.value)
|
savedSeo.value = seoOf(saved, seo.value)
|
||||||
seo.value = savedSeo.value
|
seo.value = savedSeo.value
|
||||||
bodyEdited.value = false
|
bodyEdited.value = false
|
||||||
clearStorage()
|
discardStored()
|
||||||
return 'saved'
|
return 'saved'
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
savingTitle.value = false
|
||||||
const rejected = rejectedOutcome(err)
|
const rejected = rejectedOutcome(err)
|
||||||
if (rejected) {
|
if (rejected) {
|
||||||
businessState.value = 'dirty'
|
businessState.value = 'dirty'
|
||||||
@@ -286,16 +251,23 @@ export function useArticleDraft(articleId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
title,
|
||||||
|
titleServerError,
|
||||||
|
savingTitle,
|
||||||
|
restoredAt,
|
||||||
body,
|
body,
|
||||||
meta,
|
meta,
|
||||||
seo,
|
seo,
|
||||||
savedSeo,
|
savedSeo,
|
||||||
bodyEdited,
|
bodyEdited,
|
||||||
mirrorToStorage,
|
mirrorToStorage,
|
||||||
|
discardStored,
|
||||||
state,
|
state,
|
||||||
|
businessState,
|
||||||
savedAt,
|
savedAt,
|
||||||
load,
|
load,
|
||||||
edit,
|
edit,
|
||||||
|
editTitle,
|
||||||
editMeta,
|
editMeta,
|
||||||
editSeo,
|
editSeo,
|
||||||
markDirty,
|
markDirty,
|
||||||
|
|||||||
@@ -1,26 +1,8 @@
|
|||||||
import type { SuggestionCode } from '@/lib/seoTypes'
|
|
||||||
import type { ArticleSeo } from '@/lib/articleSeo'
|
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import type { Byline } from '@/lib/articleTypes'
|
import type { Article, ArticleCapability, Byline, CoverageEntry } from '@/lib/articleTypes'
|
||||||
import type { ArticleStatus, ArticleTab, SEO_FILTER } from '@/lib/articleListQuery'
|
import type { ArticleStatus, ArticleTab, SEO_FILTER } from '@/lib/articleListQuery'
|
||||||
|
|
||||||
export type { Byline }
|
export type { Article, ArticleCapability, Byline, CoverageEntry }
|
||||||
|
|
||||||
export interface Article extends Partial<ArticleSeo> {
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
body: string
|
|
||||||
is_online: boolean
|
|
||||||
lang?: string
|
|
||||||
site_id?: number
|
|
||||||
created_by?: number
|
|
||||||
authors?: Byline[]
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
seo_status?: string
|
|
||||||
seo_dismissed?: SuggestionCode[]
|
|
||||||
seo_suggestions?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { ArticleStatus }
|
export type { ArticleStatus }
|
||||||
|
|
||||||
@@ -49,17 +31,6 @@ export interface ListArticlesParams {
|
|||||||
seo?: '' | typeof SEO_FILTER
|
seo?: '' | typeof SEO_FILTER
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ArticleCapability = 'read' | 'edit' | 'publish' | 'delete' | 'create_translation'
|
|
||||||
|
|
||||||
export interface CoverageEntry {
|
|
||||||
id: number
|
|
||||||
lang: string
|
|
||||||
title: string
|
|
||||||
is_online: boolean
|
|
||||||
language_enabled: boolean
|
|
||||||
capabilities: ArticleCapability[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoryItem extends Article {
|
export interface StoryItem extends Article {
|
||||||
story_id?: number
|
story_id?: number
|
||||||
story_updated_at: string
|
story_updated_at: string
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||||
|
import EditorActionDialog from '@/components/EditorActionDialog.vue'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import type { Article } from '@/composables/useArticles'
|
||||||
|
import type { SaveOutcome } from '@/composables/useArticleDraft'
|
||||||
|
import { statusOf, codeOf } from '@/lib/directoryErrors'
|
||||||
|
import { joinNames } from '@/lib/nameList'
|
||||||
|
|
||||||
|
const LANGUAGE_DISABLED_CODE = 'language_disabled'
|
||||||
|
|
||||||
|
interface PublishingOptions {
|
||||||
|
articleId: number
|
||||||
|
language: () => string
|
||||||
|
siblingNames: () => string[]
|
||||||
|
onlineSiblingNames: () => string[]
|
||||||
|
hasUnsavedChanges: () => boolean
|
||||||
|
save: () => Promise<SaveOutcome>
|
||||||
|
onUpdated: (updated: Article) => void
|
||||||
|
onGone: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogCopy {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
confirmLabel: string
|
||||||
|
notice?: { title: string; description: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEditorPublishing(options: PublishingOptions) {
|
||||||
|
const { request } = useApi()
|
||||||
|
const overlay = useLocalizedOverlay()
|
||||||
|
const toast = useLocalizedToast()
|
||||||
|
const publishing = ref(false)
|
||||||
|
const publishFailed = ref(false)
|
||||||
|
const languageBlocked = ref(false)
|
||||||
|
|
||||||
|
function confirm(copy: () => DialogCopy): Promise<boolean> {
|
||||||
|
return overlay
|
||||||
|
.create(EditorActionDialog, {
|
||||||
|
props: {
|
||||||
|
get title() {
|
||||||
|
return copy().title
|
||||||
|
},
|
||||||
|
get description() {
|
||||||
|
return copy().description
|
||||||
|
},
|
||||||
|
get confirmLabel() {
|
||||||
|
return copy().confirmLabel
|
||||||
|
},
|
||||||
|
get notice() {
|
||||||
|
return copy().notice
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.open()
|
||||||
|
.then(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function otherVersionsLine() {
|
||||||
|
const names = options.siblingNames()
|
||||||
|
if (names.length === 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return names.length === 1
|
||||||
|
? t("{0} isn't affected.", [names[0]])
|
||||||
|
: t("{0} aren't affected.", [joinNames(names)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishCopy(): DialogCopy {
|
||||||
|
const language = options.language()
|
||||||
|
return {
|
||||||
|
title: t('Publish {0}?', [language]),
|
||||||
|
description:
|
||||||
|
`${t('Only the {0} version goes online.', [language])} ${otherVersionsLine()}`.trim(),
|
||||||
|
confirmLabel: t('Publish {0}', [language]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAndPublishCopy(): DialogCopy {
|
||||||
|
const language = options.language()
|
||||||
|
return {
|
||||||
|
title: t('Save and publish {0}?', [language]),
|
||||||
|
description: `${t(
|
||||||
|
'Your unsaved changes to the {0} version are saved first, then only the {0} version goes online.',
|
||||||
|
[language],
|
||||||
|
)} ${otherVersionsLine()}`.trim(),
|
||||||
|
confirmLabel: t('Save and publish {0}', [language]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stayingOnline() {
|
||||||
|
const online = options.onlineSiblingNames()
|
||||||
|
if (online.length === 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return online.length === 1
|
||||||
|
? t('{0} stays online.', [online[0]])
|
||||||
|
: t('{0} stay online.', [joinNames(online)])
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpublishCopy(): DialogCopy {
|
||||||
|
const language = options.language()
|
||||||
|
const dirty = options.hasUnsavedChanges()
|
||||||
|
const lead = dirty
|
||||||
|
? t('The saved {0} version goes offline and becomes a draft.', [language])
|
||||||
|
: t('The {0} version goes back to draft and stops being public.', [language])
|
||||||
|
return {
|
||||||
|
title: t('Unpublish {0}?', [language]),
|
||||||
|
description: `${lead} ${stayingOnline()}`.trim(),
|
||||||
|
confirmLabel: t('Unpublish {0}', [language]),
|
||||||
|
notice: dirty
|
||||||
|
? {
|
||||||
|
title: t('Your edits stay in the editor'),
|
||||||
|
description: t(
|
||||||
|
'Your unsaved changes are kept here. Nothing is saved when you unpublish. Save them yourself when you are ready.',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function othersLine() {
|
||||||
|
return t("Other versions weren't changed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
function announceDone(title: string) {
|
||||||
|
toast.add(() => ({
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-circle-check',
|
||||||
|
title,
|
||||||
|
get description() {
|
||||||
|
return othersLine()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function announceFailure(title: string) {
|
||||||
|
toast.add(() => ({
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-lucide-circle-x',
|
||||||
|
title,
|
||||||
|
get description() {
|
||||||
|
return t('Nothing changed. Try again.')
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function announceSaveFailure() {
|
||||||
|
toast.add(() => ({
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-lucide-circle-x',
|
||||||
|
get title() {
|
||||||
|
return t("Couldn't save {0}", [options.language()])
|
||||||
|
},
|
||||||
|
get description() {
|
||||||
|
return t('Nothing was published. Your changes are kept. Try again.')
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLanguageDisabled(err: unknown) {
|
||||||
|
return statusOf(err) === 422 && codeOf(err) === LANGUAGE_DISABLED_CODE
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(makeOnline: boolean) {
|
||||||
|
publishing.value = true
|
||||||
|
try {
|
||||||
|
const updated = await request<Article>(
|
||||||
|
`/v1/article/${options.articleId}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
|
||||||
|
{ method: 'PATCH' },
|
||||||
|
)
|
||||||
|
publishFailed.value = false
|
||||||
|
options.onUpdated(updated)
|
||||||
|
announceDone(
|
||||||
|
makeOnline
|
||||||
|
? t('{0} published', [options.language()])
|
||||||
|
: t('{0} unpublished', [options.language()]),
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
if (statusOf(err) === 404) {
|
||||||
|
options.onGone()
|
||||||
|
} else if (isLanguageDisabled(err)) {
|
||||||
|
languageBlocked.value = true
|
||||||
|
} else {
|
||||||
|
announceFailure(
|
||||||
|
makeOnline
|
||||||
|
? t("Couldn't publish {0}", [options.language()])
|
||||||
|
: t("Couldn't unpublish {0}", [options.language()]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
publishing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publish() {
|
||||||
|
if (await confirm(publishCopy)) {
|
||||||
|
await send(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unpublish() {
|
||||||
|
if (await confirm(unpublishCopy)) {
|
||||||
|
await send(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryPublish() {
|
||||||
|
await send(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAndPublish() {
|
||||||
|
if (!(await confirm(saveAndPublishCopy))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const outcome = await options.save()
|
||||||
|
if (outcome !== 'saved') {
|
||||||
|
if (outcome === 'error') {
|
||||||
|
announceSaveFailure()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const published = await send(true)
|
||||||
|
publishFailed.value = !published && !languageBlocked.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPublishFailure() {
|
||||||
|
publishFailed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
publishing,
|
||||||
|
publishFailed,
|
||||||
|
languageBlocked,
|
||||||
|
publish,
|
||||||
|
unpublish,
|
||||||
|
retryPublish,
|
||||||
|
saveAndPublish,
|
||||||
|
clearPublishFailure,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { computed, ref, type Ref } from 'vue'
|
||||||
|
import { locale } from '@/i18n'
|
||||||
|
import type { Article, ArticleCapability } from '@/composables/useArticles'
|
||||||
|
import { useSiteLanguages } from '@/composables/useSiteLanguages'
|
||||||
|
import type { Site } from '@/composables/useSites'
|
||||||
|
import { versionRows } from '@/lib/editorVersions'
|
||||||
|
import { languageName, type SiteLanguage } from '@/lib/siteLanguages'
|
||||||
|
import { nameInSentence } from '@/lib/storyList'
|
||||||
|
|
||||||
|
export function useEditorVersions(
|
||||||
|
article: Ref<Article | null>,
|
||||||
|
sites: Ref<Site[]>,
|
||||||
|
capabilities: Ref<ArticleCapability[]>,
|
||||||
|
) {
|
||||||
|
const { getLanguages } = useSiteLanguages()
|
||||||
|
const siteLanguages = ref<SiteLanguage[]>([])
|
||||||
|
|
||||||
|
const tag = computed(() => article.value?.lang ?? '')
|
||||||
|
const translations = computed(() => article.value?.translations ?? [])
|
||||||
|
const enabledLanguages = computed(() =>
|
||||||
|
siteLanguages.value.filter((language) => language.enabled).map((language) => language.lang),
|
||||||
|
)
|
||||||
|
const multilingual = computed(
|
||||||
|
() =>
|
||||||
|
tag.value !== '' &&
|
||||||
|
(enabledLanguages.value.length > 1 || translations.value.length > 1),
|
||||||
|
)
|
||||||
|
const displayName = computed(() => (tag.value ? languageName(tag.value, locale.value) : ''))
|
||||||
|
const sentenceName = computed<string | null>(() =>
|
||||||
|
multilingual.value ? nameInSentence(tag.value) : null,
|
||||||
|
)
|
||||||
|
const currentTranslation = computed(() =>
|
||||||
|
translations.value.find((entry) => entry.id === article.value?.id),
|
||||||
|
)
|
||||||
|
const languageDisabled = computed(() => currentTranslation.value?.language_enabled === false)
|
||||||
|
const siblings = computed(() =>
|
||||||
|
translations.value.filter((entry) => entry.id !== article.value?.id),
|
||||||
|
)
|
||||||
|
const rows = computed(() =>
|
||||||
|
multilingual.value
|
||||||
|
? versionRows({
|
||||||
|
translations: translations.value,
|
||||||
|
currentId: article.value?.id ?? 0,
|
||||||
|
capabilities: capabilities.value,
|
||||||
|
enabledLanguages: enabledLanguages.value,
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
const siblingNames = computed(() => siblings.value.map((entry) => nameInSentence(entry.lang)))
|
||||||
|
const onlineSiblingNames = computed(() =>
|
||||||
|
siblings.value
|
||||||
|
.filter((entry) => entry.is_online)
|
||||||
|
.map((entry) => nameInSentence(entry.lang)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadLanguages() {
|
||||||
|
const current = article.value
|
||||||
|
const key = sites.value.find((site) => site.id === current?.site_id)?.key
|
||||||
|
if (!current?.lang || !key) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
siteLanguages.value = (await getLanguages(key)).languages
|
||||||
|
} catch {
|
||||||
|
siteLanguages.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markOnline(id: number, online: boolean) {
|
||||||
|
const current = article.value
|
||||||
|
if (!current?.translations) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current.translations = current.translations.map((entry) =>
|
||||||
|
entry.id === id ? { ...entry, is_online: online } : entry,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function markTitle(id: number, title: string) {
|
||||||
|
const current = article.value
|
||||||
|
if (!current?.translations) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current.translations = current.translations.map((entry) =>
|
||||||
|
entry.id === id ? { ...entry, title } : entry,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tag,
|
||||||
|
displayName,
|
||||||
|
sentenceName,
|
||||||
|
multilingual,
|
||||||
|
languageDisabled,
|
||||||
|
siblings,
|
||||||
|
rows,
|
||||||
|
siblingNames,
|
||||||
|
onlineSiblingNames,
|
||||||
|
loadLanguages,
|
||||||
|
markOnline,
|
||||||
|
markTitle,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { nextTick, readonly, ref } from 'vue'
|
||||||
|
|
||||||
|
const message = ref('')
|
||||||
|
|
||||||
|
export const liveMessage = readonly(message)
|
||||||
|
|
||||||
|
export function announce(text: string) {
|
||||||
|
message.value = ''
|
||||||
|
nextTick(() => {
|
||||||
|
message.value = text
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { ref, watch } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||||
|
import { clearStashedArticle } from '@/lib/prefetchedArticle'
|
||||||
|
import { removeStoredDraftsOf } from '@/lib/storedDraft'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
|
||||||
const LOGIN_PATH = '/login'
|
const LOGIN_PATH = '/login'
|
||||||
@@ -31,11 +33,10 @@ export function useShellLogout() {
|
|||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
loggingOut.value = true
|
loggingOut.value = true
|
||||||
try {
|
await api.request('/v1/auth/logout', { method: 'POST' }).catch(() => undefined)
|
||||||
await api.request('/v1/auth/logout', { method: 'POST' })
|
loggingOut.value = false
|
||||||
} finally {
|
removeStoredDraftsOf(session.session?.id)
|
||||||
loggingOut.value = false
|
clearStashedArticle()
|
||||||
}
|
|
||||||
session.clear()
|
session.clear()
|
||||||
await router.push(LOGIN_PATH)
|
await router.push(LOGIN_PATH)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { onBeforeRouteLeave } from 'vue-router'
|
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import { useLocalizedOverlay } from '@/i18n/presentation'
|
import { useLocalizedOverlay } from '@/i18n/presentation'
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
@@ -7,12 +7,13 @@ import { authRedirectPending } from '@/composables/useApi'
|
|||||||
interface UnsavedChangesOptions {
|
interface UnsavedChangesOptions {
|
||||||
isDirty: () => boolean
|
isDirty: () => boolean
|
||||||
title: () => string
|
title: () => string
|
||||||
|
onDiscard?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUnsavedChangesGuard({ isDirty, title }: UnsavedChangesOptions) {
|
export function useUnsavedChangesGuard({ isDirty, title, onDiscard }: UnsavedChangesOptions) {
|
||||||
const overlay = useLocalizedOverlay()
|
const overlay = useLocalizedOverlay()
|
||||||
|
|
||||||
onBeforeRouteLeave(async () => {
|
async function allowsLeaving() {
|
||||||
if (authRedirectPending.value || !isDirty()) {
|
if (authRedirectPending.value || !isDirty()) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -40,6 +41,13 @@ export function useUnsavedChangesGuard({ isDirty, title }: UnsavedChangesOptions
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.open()
|
.open()
|
||||||
return !confirmed
|
if (confirmed) {
|
||||||
})
|
return false
|
||||||
|
}
|
||||||
|
onDiscard?.()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeRouteLeave(allowsLeaving)
|
||||||
|
onBeforeRouteUpdate((to, from) => (to.params.id === from.params.id ? true : allowsLeaving()))
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||||
|
import EditorLeaveDialog, { type LeaveChoice } from '@/components/EditorLeaveDialog.vue'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import type { Article } from '@/composables/useArticles'
|
||||||
|
import type { SaveOutcome } from '@/composables/useArticleDraft'
|
||||||
|
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||||
|
import { announce } from '@/composables/useLiveAnnouncement'
|
||||||
|
import type { VersionRow } from '@/lib/editorVersions'
|
||||||
|
import { clearStashedArticle, stashArticle } from '@/lib/prefetchedArticle'
|
||||||
|
|
||||||
|
interface VersionSwitchOptions {
|
||||||
|
currentName: () => string
|
||||||
|
hasUnsavedChanges: () => boolean
|
||||||
|
save: () => Promise<SaveOutcome>
|
||||||
|
discard: () => void
|
||||||
|
allowLeave: () => void
|
||||||
|
cancelLeave: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVersionSwitch(options: VersionSwitchOptions) {
|
||||||
|
const { request } = useApi()
|
||||||
|
const router = useRouter()
|
||||||
|
const overlay = useLocalizedOverlay()
|
||||||
|
const toast = useLocalizedToast()
|
||||||
|
const loading = useDelayedSkeleton()
|
||||||
|
const opening = ref(false)
|
||||||
|
|
||||||
|
function announceFailure(row: VersionRow) {
|
||||||
|
toast.add(() => ({
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-lucide-circle-x',
|
||||||
|
get title() {
|
||||||
|
return t("Couldn't open the {0} version", [row.name])
|
||||||
|
},
|
||||||
|
get description() {
|
||||||
|
return t("You're still editing {0}. Try again.", [options.currentName()])
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(row: VersionRow) {
|
||||||
|
opening.value = true
|
||||||
|
loading.start()
|
||||||
|
try {
|
||||||
|
const target = await request<Article>(`/v1/article/${row.articleId}?format=html`)
|
||||||
|
stashArticle(target)
|
||||||
|
options.allowLeave()
|
||||||
|
const failure = await router.push(`/article/${row.articleId}/edit`)
|
||||||
|
if (failure) {
|
||||||
|
throw failure
|
||||||
|
}
|
||||||
|
announce(t('Editing {0} version', [row.name]))
|
||||||
|
} catch {
|
||||||
|
options.cancelLeave()
|
||||||
|
clearStashedArticle()
|
||||||
|
announceFailure(row)
|
||||||
|
} finally {
|
||||||
|
opening.value = false
|
||||||
|
loading.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askToLeave(row: VersionRow): Promise<LeaveChoice> {
|
||||||
|
return overlay
|
||||||
|
.create(EditorLeaveDialog, {
|
||||||
|
props: {
|
||||||
|
get language() {
|
||||||
|
return options.currentName()
|
||||||
|
},
|
||||||
|
get target() {
|
||||||
|
return row.name
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.open() as Promise<LeaveChoice>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleUnsaved(row: VersionRow) {
|
||||||
|
const choice = await askToLeave(row)
|
||||||
|
if (choice === 'discard') {
|
||||||
|
options.discard()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return choice === 'save' && (await options.save()) === 'saved'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choose(row: VersionRow) {
|
||||||
|
if (row.current || row.addTranslation || row.articleId === null || opening.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (options.hasUnsavedChanges() && !(await settleUnsaved(row))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await open(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { choose, loadingVisible: loading.visible }
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { editorMessages } from './editorMessages'
|
||||||
|
import { messages } from './messages'
|
||||||
|
|
||||||
|
const SOURCES = [
|
||||||
|
'components/EditorActionDialog.vue',
|
||||||
|
'components/EditorActions.vue',
|
||||||
|
'components/EditorLanguageMenu.vue',
|
||||||
|
'components/EditorLanguageRow.vue',
|
||||||
|
'components/EditorLeaveDialog.vue',
|
||||||
|
'components/EditorMobileBar.vue',
|
||||||
|
'components/EditorNotices.vue',
|
||||||
|
'components/EditorOverflowMenu.vue',
|
||||||
|
'components/EditorTitleField.vue',
|
||||||
|
'components/EditorVersionGone.vue',
|
||||||
|
'composables/useEditorPublishing.ts',
|
||||||
|
'composables/useVersionSwitch.ts',
|
||||||
|
'lib/editorLabels.ts',
|
||||||
|
'lib/editorNotices.ts',
|
||||||
|
'lib/editorOverflow.ts',
|
||||||
|
'views/EditorView.vue',
|
||||||
|
]
|
||||||
|
|
||||||
|
const SAME_IN_FRENCH = new Set(['Save', 'Draft', 'Title', 'Body', 'Articles'])
|
||||||
|
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
|
||||||
|
const NUMBERED = /\{\d+\}/g
|
||||||
|
|
||||||
|
function keysIn(source: string) {
|
||||||
|
return [...source.matchAll(LITERAL)].map((match) =>
|
||||||
|
(match[1] ?? match[2] ?? '').replace(/\\(['"\\])/g, '$1'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Editor messages', () => {
|
||||||
|
const keys = SOURCES.flatMap((file) =>
|
||||||
|
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
|
||||||
|
file,
|
||||||
|
key,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
it('finds the strings of the editor', () => {
|
||||||
|
expect(keys.length).toBeGreaterThan(60)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(keys.map(({ file, key }) => [file, key]))(
|
||||||
|
'%s: "%s" has a French entry',
|
||||||
|
(_file, key) => {
|
||||||
|
const french = (messages as Record<string, string>)[key as string]
|
||||||
|
|
||||||
|
expect(french).toMatch(/\S/)
|
||||||
|
if (!SAME_IN_FRENCH.has(key as string)) {
|
||||||
|
expect(french).not.toBe(key)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it.each(Object.entries(editorMessages))(
|
||||||
|
'keeps the placeholders of "%s" in French',
|
||||||
|
(english, french) => {
|
||||||
|
expect([...french.matchAll(NUMBERED)].map((m) => m[0]).sort()).toEqual(
|
||||||
|
[...english.matchAll(NUMBERED)].map((m) => m[0]).sort(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
export const editorMessages = {
|
||||||
|
'Language: {0}. Switch version': 'Langue : {0}. Changer de version',
|
||||||
|
'Versions you can open': 'Versions que vous pouvez ouvrir',
|
||||||
|
'Find a language': 'Rechercher une langue',
|
||||||
|
'Switching opens that version. The interface language stays the same.':
|
||||||
|
'Changer ouvre cette version. La langue de l’interface ne change pas.',
|
||||||
|
'Versions of this article': 'Versions de cet article',
|
||||||
|
'Add translation': 'Ajouter une traduction',
|
||||||
|
"Your changes to the {0} version aren't saved.":
|
||||||
|
'Vos modifications de la version {0} ne sont pas enregistrées.',
|
||||||
|
'Discard and open {0}': 'Abandonner et ouvrir {0}',
|
||||||
|
'Save and open {0}': 'Enregistrer et ouvrir {0}',
|
||||||
|
'Retry publish': 'Réessayer la publication',
|
||||||
|
'Enter a title': 'Saisissez un titre',
|
||||||
|
'Use at least 3 characters': 'Utilisez au moins 3 caractères',
|
||||||
|
'Use 200 characters or fewer': 'Utilisez 200 caractères au plus',
|
||||||
|
"Couldn't save the title. Your text is kept.":
|
||||||
|
'Impossible d’enregistrer le titre. Votre texte est conservé.',
|
||||||
|
'Saving title…': 'Enregistrement du titre…',
|
||||||
|
"Changing the title doesn't change the address.": 'Modifier le titre ne modifie pas l’adresse.',
|
||||||
|
"This version isn't available": 'Cette version n’est pas disponible',
|
||||||
|
'It may have been deleted, or you may not have access.':
|
||||||
|
'Elle a peut-être été supprimée, ou vous n’y avez pas accès.',
|
||||||
|
"{0} isn't affected.": '{0} n’est pas concerné.',
|
||||||
|
"{0} aren't affected.": '{0} ne sont pas concernés.',
|
||||||
|
'Publish {0}?': 'Publier {0} ?',
|
||||||
|
'Only the {0} version goes online.': 'Seule la version {0} est mise en ligne.',
|
||||||
|
'Save and publish {0}?': 'Enregistrer et publier {0} ?',
|
||||||
|
'Your unsaved changes to the {0} version are saved first, then only the {0} version goes online.':
|
||||||
|
'Vos modifications non enregistrées de la version {0} sont d’abord enregistrées, puis seule la version {0} est mise en ligne.',
|
||||||
|
'Save and publish {0}': 'Enregistrer et publier {0}',
|
||||||
|
'{0} stays online.': '{0} reste en ligne.',
|
||||||
|
'{0} stay online.': '{0} restent en ligne.',
|
||||||
|
'The saved {0} version goes offline and becomes a draft.':
|
||||||
|
'La version {0} enregistrée passe hors ligne et redevient un brouillon.',
|
||||||
|
'The {0} version goes back to draft and stops being public.':
|
||||||
|
'La version {0} redevient un brouillon et n’est plus publique.',
|
||||||
|
'Unpublish {0}?': 'Dépublier {0} ?',
|
||||||
|
'Your edits stay in the editor': 'Vos modifications restent dans l’éditeur',
|
||||||
|
'Your unsaved changes are kept here. Nothing is saved when you unpublish. Save them yourself when you are ready.':
|
||||||
|
'Vos modifications non enregistrées sont conservées ici. Rien n’est enregistré quand vous dépubliez. Enregistrez-les vous-même quand vous êtes prêt.',
|
||||||
|
"Other versions weren't changed.": 'Les autres versions n’ont pas été modifiées.',
|
||||||
|
"Couldn't save {0}": 'Impossible d’enregistrer {0}',
|
||||||
|
'Nothing was published. Your changes are kept. Try again.':
|
||||||
|
'Rien n’a été publié. Vos modifications sont conservées. Réessayez.',
|
||||||
|
'{0} unpublished': '{0} dépublié',
|
||||||
|
"Couldn't publish {0}": 'Impossible de publier {0}',
|
||||||
|
"Couldn't unpublish {0}": 'Impossible de dépublier {0}',
|
||||||
|
"Couldn't open the {0} version": 'Impossible d’ouvrir la version {0}',
|
||||||
|
"You're still editing {0}. Try again.": 'Vous modifiez toujours {0}. Réessayez.',
|
||||||
|
'Editing {0} version': 'Modification de la version {0}',
|
||||||
|
'Try saving again': 'Réessayer d’enregistrer',
|
||||||
|
'Title ({0})': 'Titre ({0})',
|
||||||
|
'Restored unsaved changes from {0}': 'Modifications non enregistrées restaurées de {0}',
|
||||||
|
'You logged in again. Your {0} title and body were kept in this browser.':
|
||||||
|
'Vous vous êtes reconnecté. Votre titre et votre texte en {0} ont été conservés dans ce navigateur.',
|
||||||
|
'You logged in again. Your title and body were kept in this browser.':
|
||||||
|
'Vous vous êtes reconnecté. Votre titre et votre texte ont été conservés dans ce navigateur.',
|
||||||
|
"Couldn't save": 'Impossible d’enregistrer',
|
||||||
|
'Your changes are kept here. Nothing was published. Try saving again.':
|
||||||
|
'Vos modifications sont conservées ici. Rien n’a été publié. Réessayez d’enregistrer.',
|
||||||
|
'Saved as a draft. Publishing failed. Try publishing again.':
|
||||||
|
'Enregistré comme brouillon. La publication a échoué. Réessayez de publier.',
|
||||||
|
'Your edits are saved. Only the publishing step failed.':
|
||||||
|
'Vos modifications sont enregistrées. Seule l’étape de publication a échoué.',
|
||||||
|
'{0} is disabled on this site. Re-enable it to publish.':
|
||||||
|
'{0} est désactivé sur ce site. Réactivez-le pour publier.',
|
||||||
|
'You can still edit and save this version. Ask an owner to re-enable {0}.':
|
||||||
|
'Vous pouvez toujours modifier et enregistrer cette version. Demandez à un propriétaire de réactiver {0}.',
|
||||||
|
"You can read this version. You can't edit it.":
|
||||||
|
'Vous pouvez lire cette version. Vous ne pouvez pas la modifier.',
|
||||||
|
'The source reference and the versions you can read stay available.':
|
||||||
|
'La référence source et les versions que vous pouvez lire restent disponibles.',
|
||||||
|
'You can publish this version but not edit it':
|
||||||
|
'Vous pouvez publier cette version mais pas la modifier',
|
||||||
|
'Fields are read-only. Publish stays available.':
|
||||||
|
'Les champs sont en lecture seule. La publication reste disponible.',
|
||||||
|
'Publishing needs the publish permission': 'Publier demande l’autorisation de publier',
|
||||||
|
'Saving keeps it as a draft.': 'L’enregistrement la garde en brouillon.',
|
||||||
|
'This version is online. Saving updates the live page right away.':
|
||||||
|
'Cette version est en ligne. L’enregistrement met à jour la page publique immédiatement.',
|
||||||
|
'Not available yet.': 'Pas encore disponible.',
|
||||||
|
'Move article': 'Déplacer l’article',
|
||||||
|
"You can't delete this article.": 'Vous ne pouvez pas supprimer cet article.',
|
||||||
|
'{0} saved': '{0} enregistré',
|
||||||
|
"{0} wasn't changed.": '{0} n’a pas été modifié.',
|
||||||
|
"Each language has its own title. Changing it doesn't change the URL; edit the slug in Search and sharing.":
|
||||||
|
'Chaque langue a son propre titre. Le modifier ne change pas l’URL ; modifiez le slug dans Recherche et partage.',
|
||||||
|
"Changing the title doesn't change the URL; edit the slug in Search and sharing.":
|
||||||
|
'Modifier le titre ne change pas l’URL ; modifiez le slug dans Recherche et partage.',
|
||||||
|
} as const
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { articleListMessages } from './articleListMessages'
|
import { articleListMessages } from './articleListMessages'
|
||||||
|
import { editorMessages } from './editorMessages'
|
||||||
import { localizationMessages } from './localizationMessages'
|
import { localizationMessages } from './localizationMessages'
|
||||||
|
|
||||||
export const messages = {
|
export const messages = {
|
||||||
...localizationMessages,
|
...localizationMessages,
|
||||||
...articleListMessages,
|
...articleListMessages,
|
||||||
|
...editorMessages,
|
||||||
'Not found': 'Introuvable',
|
'Not found': 'Introuvable',
|
||||||
'Edit article': 'Modifier l’article',
|
'Edit article': 'Modifier l’article',
|
||||||
unselected: 'désélectionnée',
|
unselected: 'désélectionnée',
|
||||||
@@ -241,8 +243,6 @@ export const messages = {
|
|||||||
'Clear search': 'Effacer la recherche',
|
'Clear search': 'Effacer la recherche',
|
||||||
Title: 'Titre',
|
Title: 'Titre',
|
||||||
'3 to 200 characters.': 'De 3 à 200 caractères.',
|
'3 to 200 characters.': 'De 3 à 200 caractères.',
|
||||||
"The title can't be changed after creation.":
|
|
||||||
'Le titre ne peut pas être modifié après la création.',
|
|
||||||
'It starts as a draft, so nothing appears on your site until you publish it.':
|
'It starts as a draft, so nothing appears on your site until you publish it.':
|
||||||
'Il commence comme brouillon : rien n’apparaît sur votre site avant sa publication.',
|
'Il commence comme brouillon : rien n’apparaît sur votre site avant sa publication.',
|
||||||
"It's saved as a draft. Titles can't be changed later, so check it now.":
|
"It's saved as a draft. Titles can't be changed later, so check it now.":
|
||||||
@@ -303,8 +303,6 @@ export const messages = {
|
|||||||
'Something went wrong on the server. Try again in a moment.':
|
'Something went wrong on the server. Try again in a moment.':
|
||||||
'Une erreur est survenue sur le serveur. Réessayez dans un instant.',
|
'Une erreur est survenue sur le serveur. Réessayez dans un instant.',
|
||||||
'Live. Your website can read this article.': 'Publié. Votre site peut lire cet article.',
|
'Live. Your website can read this article.': 'Publié. Votre site peut lire cet article.',
|
||||||
"Titles can't be changed after an article is created. To use a different title, create a new article and delete this one.":
|
|
||||||
'Les titres ne peuvent pas être modifiés après la création d’un article. Pour changer de titre, créez un nouvel article et supprimez celui-ci.',
|
|
||||||
'Back to Articles': 'Retour aux articles',
|
'Back to Articles': 'Retour aux articles',
|
||||||
'Back to Integration': 'Retour à l’intégration',
|
'Back to Integration': 'Retour à l’intégration',
|
||||||
'Your changes to "{0}" haven\'t been saved.':
|
'Your changes to "{0}" haven\'t been saved.':
|
||||||
@@ -775,9 +773,6 @@ export const messages = {
|
|||||||
'Something went wrong. Try again.': 'Une erreur est survenue. Réessayez.',
|
'Something went wrong. Try again.': 'Une erreur est survenue. Réessayez.',
|
||||||
"This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.":
|
"This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.":
|
||||||
'Cette page n’a pas pu se charger. Votre travail enregistré est conservé. Réessayez et, si le problème persiste, redémarrez le serveur RenewCMS.',
|
'Cette page n’a pas pu se charger. Votre travail enregistré est conservé. Réessayez et, si le problème persiste, redémarrez le serveur RenewCMS.',
|
||||||
"This article doesn't exist.": 'Cet article n’existe pas.',
|
|
||||||
'It may have been deleted, or the link is wrong.':
|
|
||||||
'Il a peut-être été supprimé ou le lien est incorrect.',
|
|
||||||
Dismiss: 'Fermer',
|
Dismiss: 'Fermer',
|
||||||
"You're offline. You can keep writing, but saving and publishing will wait until you're back online.":
|
"You're offline. You can keep writing, but saving and publishing will wait until you're back online.":
|
||||||
'Vous êtes hors ligne. Vous pouvez continuer à écrire, mais l’enregistrement et la publication attendront le retour de la connexion.',
|
'Vous êtes hors ligne. Vous pouvez continuer à écrire, mais l’enregistrement et la publication attendront le retour de la connexion.',
|
||||||
@@ -2910,7 +2905,6 @@ export const messages = {
|
|||||||
'You can write and edit all articles. Publishing is done by an editor.':
|
'You can write and edit all articles. Publishing is done by an editor.':
|
||||||
'Vous pouvez rédiger et modifier tous les articles. La publication est faite par un éditeur.',
|
'Vous pouvez rédiger et modifier tous les articles. La publication est faite par un éditeur.',
|
||||||
"Your role can't delete files.": 'Votre rôle ne permet pas de supprimer des fichiers.',
|
"Your role can't delete files.": 'Votre rôle ne permet pas de supprimer des fichiers.',
|
||||||
'Save draft': 'Enregistrer le brouillon',
|
|
||||||
'The id of the role to give. Absent or 0 means the Writer role. The Owner role is refused.':
|
'The id of the role to give. Absent or 0 means the Writer role. The Owner role is refused.':
|
||||||
'L’identifiant du rôle à donner. Absent ou 0, c’est le rôle Rédacteur. Le rôle Propriétaire est refusé.',
|
'L’identifiant du rôle à donner. Absent ou 0, c’est le rôle Rédacteur. Le rôle Propriétaire est refusé.',
|
||||||
Access: 'Accès',
|
Access: 'Accès',
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const KEYS = [
|
|||||||
"Your role can't delete articles.",
|
"Your role can't delete articles.",
|
||||||
'Ask an editor to publish.',
|
'Ask an editor to publish.',
|
||||||
'You can write and edit all articles. Publishing is done by an editor.',
|
'You can write and edit all articles. Publishing is done by an editor.',
|
||||||
'Save draft',
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
describe('Permission copy', () => {
|
describe('Permission copy', () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { messages } from './messages'
|
|||||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||||
import { formatSize, pluralArticles } from '@/lib/mediaFormat'
|
import { formatSize, pluralArticles } from '@/lib/mediaFormat'
|
||||||
import { storyEnvelope, storyListUrl } from '@/views/__tests__/articleEnvelope'
|
import { storyEnvelope, storyListUrl } from '@/views/__tests__/articleEnvelope'
|
||||||
|
import type { ArticleCapability } from '@/lib/articleTypes'
|
||||||
import { accessFor, ownerAccess } from '@/test/access'
|
import { accessFor, ownerAccess } from '@/test/access'
|
||||||
|
|
||||||
vi.mock('@/composables/useApi', async () => {
|
vi.mock('@/composables/useApi', async () => {
|
||||||
@@ -367,6 +368,7 @@ describe('Hardener localization outcomes', () => {
|
|||||||
title: 'Data title',
|
title: 'Data title',
|
||||||
body: '',
|
body: '',
|
||||||
is_online: false,
|
is_online: false,
|
||||||
|
capabilities: ['read', 'edit', 'publish', 'delete'] as ArticleCapability[],
|
||||||
created_at: '2025-02-14T12:00:00Z',
|
created_at: '2025-02-14T12:00:00Z',
|
||||||
updated_at: '2025-02-14T12:00:00Z',
|
updated_at: '2025-02-14T12:00:00Z',
|
||||||
}
|
}
|
||||||
@@ -438,6 +440,7 @@ describe('Hardener localization outcomes', () => {
|
|||||||
body: '<p><img src="/static/uploadedImages/photo.png"></p>',
|
body: '<p><img src="/static/uploadedImages/photo.png"></p>',
|
||||||
is_online: false,
|
is_online: false,
|
||||||
images: [],
|
images: [],
|
||||||
|
capabilities: ['read', 'edit', 'publish', 'delete'] as ArticleCapability[],
|
||||||
created_at: '2025-02-14T12:00:00Z',
|
created_at: '2025-02-14T12:00:00Z',
|
||||||
updated_at: '2025-02-14T12:00:00Z',
|
updated_at: '2025-02-14T12:00:00Z',
|
||||||
}
|
}
|
||||||
@@ -459,10 +462,15 @@ describe('Hardener localization outcomes', () => {
|
|||||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||||
.find((b) => b.textContent?.trim() === 'Delete…')!
|
.find((b) => b.textContent?.trim() === 'Delete…')!
|
||||||
.click()
|
.click()
|
||||||
} else
|
} else {
|
||||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
|
document.body
|
||||||
.find((b) => b.textContent?.trim() === 'Delete')!
|
.querySelector<HTMLButtonElement>('[aria-label="More actions"]')!
|
||||||
.click()
|
.click()
|
||||||
|
await flushPromises()
|
||||||
|
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||||
|
.find((b) => b.textContent?.trim() === 'Delete article')!
|
||||||
|
.click()
|
||||||
|
}
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
const snapshots = []
|
const snapshots = []
|
||||||
for (const language of ['en', 'fr', 'en'] as const) {
|
for (const language of ['en', 'fr', 'en'] as const) {
|
||||||
@@ -498,6 +506,7 @@ describe('Hardener localization outcomes', () => {
|
|||||||
body: '',
|
body: '',
|
||||||
images: [],
|
images: [],
|
||||||
is_online: false,
|
is_online: false,
|
||||||
|
capabilities: ['read', 'edit', 'publish', 'delete'] as ArticleCapability[],
|
||||||
created_at: '2025-02-14T12:00:00Z',
|
created_at: '2025-02-14T12:00:00Z',
|
||||||
updated_at: '2025-02-14T12:00:00Z',
|
updated_at: '2025-02-14T12:00:00Z',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { ArticleCapability } from '@/composables/useArticles'
|
||||||
|
import { planActions, type ActionInput, type SaveMood } from '@/lib/editorActions'
|
||||||
|
|
||||||
|
const EDIT_PUBLISH: ArticleCapability[] = ['read', 'edit', 'publish']
|
||||||
|
const EDIT: ArticleCapability[] = ['read', 'edit']
|
||||||
|
const PUBLISH: ArticleCapability[] = ['read', 'publish']
|
||||||
|
const READ: ArticleCapability[] = ['read']
|
||||||
|
|
||||||
|
function input(overrides: Partial<ActionInput>): ActionInput {
|
||||||
|
return {
|
||||||
|
capabilities: EDIT_PUBLISH,
|
||||||
|
online: false,
|
||||||
|
mood: 'clean',
|
||||||
|
titleInvalid: false,
|
||||||
|
languageDisabled: false,
|
||||||
|
publishing: false,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summary(overrides: Partial<ActionInput>) {
|
||||||
|
const plan = planActions(input(overrides))
|
||||||
|
return {
|
||||||
|
primary: plan.primary?.kind ?? null,
|
||||||
|
save: plan.saveButton ? (plan.saveButton.disabled ? 'disabled' : 'enabled') : null,
|
||||||
|
unpublish: plan.unpublish,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Header priority and action matrix', () => {
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'clean draft with publish',
|
||||||
|
{},
|
||||||
|
{ primary: 'publish', save: 'disabled', unpublish: false },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'clean draft with edit only',
|
||||||
|
{ capabilities: EDIT },
|
||||||
|
{ primary: null, save: 'disabled', unpublish: false },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'dirty draft with edit only',
|
||||||
|
{ capabilities: EDIT, mood: 'dirty' },
|
||||||
|
{ primary: 'save', save: null, unpublish: false },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'dirty draft with publish',
|
||||||
|
{ mood: 'dirty' },
|
||||||
|
{ primary: 'saveAndPublish', save: 'enabled', unpublish: false },
|
||||||
|
],
|
||||||
|
['clean online', { online: true }, { primary: null, save: 'disabled', unpublish: true }],
|
||||||
|
[
|
||||||
|
'dirty online',
|
||||||
|
{ online: true, mood: 'dirty' },
|
||||||
|
{ primary: 'save', save: null, unpublish: true },
|
||||||
|
],
|
||||||
|
['saving', { mood: 'saving' }, { primary: 'saving', save: null, unpublish: false }],
|
||||||
|
['saved', { mood: 'saved' }, { primary: 'publish', save: 'disabled', unpublish: false }],
|
||||||
|
[
|
||||||
|
'save failed',
|
||||||
|
{ capabilities: EDIT, mood: 'failed' },
|
||||||
|
{ primary: 'trySaveAgain', save: null, unpublish: false },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'publish failed after save',
|
||||||
|
{ mood: 'publishFailed' },
|
||||||
|
{ primary: 'retryPublish', save: 'disabled', unpublish: false },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'publish without edit',
|
||||||
|
{ capabilities: PUBLISH },
|
||||||
|
{ primary: 'publish', save: null, unpublish: false },
|
||||||
|
],
|
||||||
|
['read only', { capabilities: READ }, { primary: null, save: null, unpublish: false }],
|
||||||
|
] as [string, Partial<ActionInput>, ReturnType<typeof summary>][])(
|
||||||
|
'%s',
|
||||||
|
(_name, overrides, expected) => {
|
||||||
|
expect(summary(overrides)).toEqual(expected)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('keeps Save as the only filled button when saving an online version without publish', () => {
|
||||||
|
const plan = planActions(input({ capabilities: EDIT, online: true, mood: 'dirty' }))
|
||||||
|
|
||||||
|
expect(plan.primary?.kind).toBe('save')
|
||||||
|
expect(plan.unpublish).toBe(false)
|
||||||
|
expect(plan.onlineNotice).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks the primary action while the title is invalid', () => {
|
||||||
|
expect(planActions(input({ mood: 'dirty', titleInvalid: true })).primary?.disabled).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables Publish when the language is disabled and falls back to Save when dirty', () => {
|
||||||
|
expect(planActions(input({ languageDisabled: true })).primary).toEqual({
|
||||||
|
kind: 'publish',
|
||||||
|
disabled: true,
|
||||||
|
busy: false,
|
||||||
|
})
|
||||||
|
expect(planActions(input({ languageDisabled: true, mood: 'dirty' })).primary?.kind).toBe(
|
||||||
|
'save',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the primary busy while publishing', () => {
|
||||||
|
expect(planActions(input({ publishing: true })).primary).toEqual({
|
||||||
|
kind: 'publishing',
|
||||||
|
disabled: true,
|
||||||
|
busy: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('At most one filled button', () => {
|
||||||
|
const moods: SaveMood[] = ['clean', 'dirty', 'saving', 'saved', 'failed', 'publishFailed']
|
||||||
|
const capabilitySets = [READ, EDIT, PUBLISH, EDIT_PUBLISH]
|
||||||
|
|
||||||
|
it.each(
|
||||||
|
capabilitySets.flatMap((capabilities) =>
|
||||||
|
moods.flatMap((mood) =>
|
||||||
|
[true, false].map((online) => ({ capabilities, mood, online })),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)('$mood online=$online $capabilities', (combination) => {
|
||||||
|
const plan = planActions(input(combination))
|
||||||
|
const filled = [plan.primary].filter(Boolean)
|
||||||
|
|
||||||
|
expect(filled.length).toBeLessThanOrEqual(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Permission notices', () => {
|
||||||
|
it.each([
|
||||||
|
['readOnly', READ, false],
|
||||||
|
['publishOnly', PUBLISH, false],
|
||||||
|
['editOnly', EDIT, false],
|
||||||
|
[null, EDIT, true],
|
||||||
|
[null, EDIT_PUBLISH, false],
|
||||||
|
] as [string | null, ArticleCapability[], boolean][])(
|
||||||
|
'shows %s for %j online=%s',
|
||||||
|
(notice, capabilities, online) => {
|
||||||
|
expect(planActions(input({ capabilities, online })).notice).toBe(notice)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Every combination keeps the action invariants', () => {
|
||||||
|
const moods: SaveMood[] = ['clean', 'dirty', 'saving', 'saved', 'failed', 'publishFailed']
|
||||||
|
const everyCapability: ArticleCapability[] = [
|
||||||
|
'read',
|
||||||
|
'edit',
|
||||||
|
'publish',
|
||||||
|
'delete',
|
||||||
|
'create_translation',
|
||||||
|
]
|
||||||
|
const capabilitySubsets = everyCapability.reduce<ArticleCapability[][]>(
|
||||||
|
(subsets, capability) => [...subsets, ...subsets.map((subset) => [...subset, capability])],
|
||||||
|
[[]],
|
||||||
|
)
|
||||||
|
const booleans = [true, false]
|
||||||
|
const combinations = capabilitySubsets.flatMap((capabilities) =>
|
||||||
|
moods.flatMap((mood) =>
|
||||||
|
booleans.flatMap((online) =>
|
||||||
|
booleans.flatMap((titleInvalid) =>
|
||||||
|
booleans.flatMap((languageDisabled) =>
|
||||||
|
booleans.map((publishing) => ({
|
||||||
|
capabilities,
|
||||||
|
mood,
|
||||||
|
online,
|
||||||
|
titleInvalid,
|
||||||
|
languageDisabled,
|
||||||
|
publishing,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const SAVE_KINDS = ['saving', 'publishing', 'save', 'trySaveAgain']
|
||||||
|
const EDIT_KINDS = ['saving', 'save', 'trySaveAgain', 'saveAndPublish']
|
||||||
|
const PUBLISH_KINDS = ['publishing', 'publish', 'retryPublish', 'saveAndPublish']
|
||||||
|
|
||||||
|
it('covers every combination', () => {
|
||||||
|
expect(combinations).toHaveLength(32 * 6 * 16)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('implies the capability behind every primary action', () => {
|
||||||
|
for (const combination of combinations) {
|
||||||
|
const kind = planActions(input(combination)).primary?.kind
|
||||||
|
const label = JSON.stringify(combination)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
!kind || !EDIT_KINDS.includes(kind) || combination.capabilities.includes('edit'),
|
||||||
|
label,
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
!kind ||
|
||||||
|
!PUBLISH_KINDS.includes(kind) ||
|
||||||
|
combination.capabilities.includes('publish'),
|
||||||
|
label,
|
||||||
|
).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the Save button exactly when the caller can edit and the primary is not save-like', () => {
|
||||||
|
for (const combination of combinations) {
|
||||||
|
const plan = planActions(input(combination))
|
||||||
|
const saveLike = plan.primary !== null && SAVE_KINDS.includes(plan.primary.kind)
|
||||||
|
const expected = combination.capabilities.includes('edit') && !saveLike
|
||||||
|
|
||||||
|
expect(plan.saveButton !== null, JSON.stringify(combination)).toBe(expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers Unpublish only to a publisher while the version is online and not saving', () => {
|
||||||
|
for (const combination of combinations) {
|
||||||
|
const plan = planActions(input(combination))
|
||||||
|
|
||||||
|
if (plan.unpublish) {
|
||||||
|
expect(
|
||||||
|
combination.capabilities.includes('publish'),
|
||||||
|
JSON.stringify(combination),
|
||||||
|
).toBe(true)
|
||||||
|
expect(combination.online, JSON.stringify(combination)).toBe(true)
|
||||||
|
expect(combination.mood, JSON.stringify(combination)).not.toBe('saving')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { holds } from '@/lib/editorCapabilities'
|
||||||
|
|
||||||
|
describe('Holding a capability', () => {
|
||||||
|
it('is true when the capability is listed', () => {
|
||||||
|
expect(holds(['read', 'edit'], 'edit')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is false when it is not listed', () => {
|
||||||
|
expect(holds(['read'], 'edit')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([null, undefined])('is false for %s capabilities', (missing) => {
|
||||||
|
expect(holds(missing, 'read')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { titleLength, titleVerdict } from '@/lib/editorTitle'
|
||||||
|
|
||||||
|
describe('titleVerdict', () => {
|
||||||
|
it.each([
|
||||||
|
['', 'empty'],
|
||||||
|
['ab', 'short'],
|
||||||
|
['abc', 'ok'],
|
||||||
|
['a'.repeat(200), 'ok'],
|
||||||
|
['a'.repeat(201), 'long'],
|
||||||
|
[' ', 'ok'],
|
||||||
|
])('judges %j as %s', (title, verdict) => {
|
||||||
|
expect(titleVerdict(title)).toBe(verdict)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts code points, so 200 four-byte emoji are valid', () => {
|
||||||
|
const emoji = '😀'.repeat(200)
|
||||||
|
|
||||||
|
expect(titleLength(emoji)).toBe(200)
|
||||||
|
expect(titleVerdict(emoji)).toBe('ok')
|
||||||
|
expect(titleVerdict(`${emoji}😀`)).toBe('long')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { ArticleCapability, CoverageEntry } from '@/composables/useArticles'
|
||||||
|
import { filterRows, versionRows } from '@/lib/editorVersions'
|
||||||
|
|
||||||
|
function entry(
|
||||||
|
id: number,
|
||||||
|
lang: string,
|
||||||
|
capabilities: ArticleCapability[] = ['read', 'edit'],
|
||||||
|
language_enabled = true,
|
||||||
|
): CoverageEntry {
|
||||||
|
return { id, lang, title: lang, is_online: id === 10, language_enabled, capabilities }
|
||||||
|
}
|
||||||
|
|
||||||
|
const translations = [entry(10, 'en'), entry(13, 'ar', ['read']), entry(12, 'fr')]
|
||||||
|
|
||||||
|
function rows(capabilities: ArticleCapability[], enabled = ['en', 'fr', 'de', 'ar']) {
|
||||||
|
return versionRows({ translations, currentId: 12, capabilities, enabledLanguages: enabled })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('versionRows', () => {
|
||||||
|
it('tolerates an entry without capabilities and tags it read only', () => {
|
||||||
|
const broken = { ...entry(13, 'ar'), capabilities: null } as unknown as CoverageEntry
|
||||||
|
const list = versionRows({
|
||||||
|
translations: [entry(12, 'fr'), broken],
|
||||||
|
currentId: 12,
|
||||||
|
capabilities: ['read'],
|
||||||
|
enabledLanguages: ['fr', 'ar'],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(list.find((row) => row.lang === 'ar')?.readOnly).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('follows translations[] order and marks the current row', () => {
|
||||||
|
const list = rows(['read', 'edit'])
|
||||||
|
|
||||||
|
expect(list.map((row) => [row.lang, row.current])).toEqual([
|
||||||
|
['en', false],
|
||||||
|
['ar', false],
|
||||||
|
['fr', true],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tags a version without edit as read only and keeps editable ones untagged', () => {
|
||||||
|
const list = rows(['read', 'edit'])
|
||||||
|
|
||||||
|
expect(list.find((row) => row.lang === 'ar')?.readOnly).toBe(true)
|
||||||
|
expect(list.find((row) => row.lang === 'en')?.readOnly).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers missing enabled languages only with create_translation', () => {
|
||||||
|
expect(rows(['read', 'edit']).some((row) => row.addTranslation)).toBe(false)
|
||||||
|
|
||||||
|
const withAdd = rows(['read', 'edit', 'create_translation'])
|
||||||
|
|
||||||
|
expect(withAdd.filter((row) => row.addTranslation).map((row) => row.lang)).toEqual(['de'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a disabled language listed and marked', () => {
|
||||||
|
const list = versionRows({
|
||||||
|
translations: [entry(10, 'en'), entry(12, 'fr', ['read', 'edit'], false)],
|
||||||
|
currentId: 10,
|
||||||
|
capabilities: ['read'],
|
||||||
|
enabledLanguages: ['en'],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(list.find((row) => row.lang === 'fr')?.languageDisabled).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('filterRows', () => {
|
||||||
|
it('narrows by language name, ignoring case', () => {
|
||||||
|
const list = rows(['read'])
|
||||||
|
|
||||||
|
expect(filterRows(list, 'FRE').map((row) => row.lang)).toEqual(['fr'])
|
||||||
|
expect(filterRows(list, '')).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import { clearStashedArticle, stashArticle, takeStashedArticle } from '@/lib/prefetchedArticle'
|
||||||
|
import type { Article } from '@/lib/articleTypes'
|
||||||
|
|
||||||
|
function article(id: number): Article {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title: 'T',
|
||||||
|
body: '',
|
||||||
|
is_online: false,
|
||||||
|
capabilities: ['read'],
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-01-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(clearStashedArticle)
|
||||||
|
|
||||||
|
describe('The prefetched article slot', () => {
|
||||||
|
it('hands the stashed article to the matching id once', () => {
|
||||||
|
stashArticle(article(12))
|
||||||
|
|
||||||
|
expect(takeStashedArticle(12)?.id).toBe(12)
|
||||||
|
expect(takeStashedArticle(12)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null and empties the slot for another id', () => {
|
||||||
|
stashArticle(article(12))
|
||||||
|
|
||||||
|
expect(takeStashedArticle(13)).toBeNull()
|
||||||
|
expect(takeStashedArticle(12)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps only the latest stashed article', () => {
|
||||||
|
stashArticle(article(12))
|
||||||
|
stashArticle(article(13))
|
||||||
|
|
||||||
|
expect(takeStashedArticle(12)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forgets the article once cleared', () => {
|
||||||
|
stashArticle(article(12))
|
||||||
|
clearStashedArticle()
|
||||||
|
|
||||||
|
expect(takeStashedArticle(12)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
readStoredDraft,
|
||||||
|
removeStoredDraft,
|
||||||
|
removeStoredDraftsOf,
|
||||||
|
writeStoredDraft,
|
||||||
|
type StoredDraft,
|
||||||
|
} from '@/lib/storedDraft'
|
||||||
|
import { EMPTY_SEO } from '@/lib/articleSeo'
|
||||||
|
|
||||||
|
const MIRRORED_AT = '2026-09-26T11:02:00.000Z'
|
||||||
|
|
||||||
|
const full: StoredDraft = {
|
||||||
|
title: 'Titre',
|
||||||
|
body: '<p>Corps</p>',
|
||||||
|
meta: { site_id: 1, authors: [{ id: 2, name: 'Ines', slug: 'ines' }] },
|
||||||
|
seo: { ...EMPTY_SEO, slug: 'titre', noindex: true, cover_image_id: 4 },
|
||||||
|
mirrored_at: MIRRORED_AT,
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => sessionStorage.clear())
|
||||||
|
|
||||||
|
describe('Stored drafts', () => {
|
||||||
|
it('round trips a full draft', () => {
|
||||||
|
writeStoredDraft(7, 12, full)
|
||||||
|
|
||||||
|
expect(readStoredDraft(7, 12)).toEqual(full)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round trips a draft that only holds the mirror time', () => {
|
||||||
|
writeStoredDraft(7, 12, { mirrored_at: MIRRORED_AT })
|
||||||
|
|
||||||
|
expect(readStoredDraft(7, 12)).toEqual({ mirrored_at: MIRRORED_AT })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads and writes nothing without a user', () => {
|
||||||
|
writeStoredDraft(null, 12, full)
|
||||||
|
|
||||||
|
expect(readStoredDraft(null, 12)).toBeNull()
|
||||||
|
expect(sessionStorage.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes one draft only', () => {
|
||||||
|
writeStoredDraft(7, 12, full)
|
||||||
|
writeStoredDraft(7, 13, full)
|
||||||
|
removeStoredDraft(7, 12)
|
||||||
|
|
||||||
|
expect(readStoredDraft(7, 12)).toBeNull()
|
||||||
|
expect(readStoredDraft(7, 13)).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps user 70 when removing the drafts of user 7', () => {
|
||||||
|
writeStoredDraft(7, 12, full)
|
||||||
|
writeStoredDraft(70, 12, full)
|
||||||
|
removeStoredDraftsOf(7)
|
||||||
|
|
||||||
|
expect(readStoredDraft(7, 12)).toBeNull()
|
||||||
|
expect(readStoredDraft(70, 12)).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes nothing without a user id', () => {
|
||||||
|
writeStoredDraft(7, 12, full)
|
||||||
|
removeStoredDraftsOf(undefined)
|
||||||
|
|
||||||
|
expect(readStoredDraft(7, 12)).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Malformed stored drafts read as no draft', () => {
|
||||||
|
const key = 'renewcms:article-draft:7:12'
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['not JSON', '{oops'],
|
||||||
|
['null', 'null'],
|
||||||
|
['an array', '[]'],
|
||||||
|
['a string', '"draft"'],
|
||||||
|
['no mirror time', JSON.stringify({ title: 'T' })],
|
||||||
|
['an unparseable mirror time', JSON.stringify({ mirrored_at: 'yesterday' })],
|
||||||
|
['a numeric title', JSON.stringify({ title: 3, mirrored_at: MIRRORED_AT })],
|
||||||
|
['a numeric body', JSON.stringify({ body: 3, mirrored_at: MIRRORED_AT })],
|
||||||
|
[
|
||||||
|
'meta without authors',
|
||||||
|
JSON.stringify({ meta: { site_id: 1 }, mirrored_at: MIRRORED_AT }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'meta authors that are not an array',
|
||||||
|
JSON.stringify({ meta: { site_id: 1, authors: {} }, mirrored_at: MIRRORED_AT }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'an author without a name',
|
||||||
|
JSON.stringify({
|
||||||
|
meta: { site_id: 1, authors: [{ id: 1 }] },
|
||||||
|
mirrored_at: MIRRORED_AT,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'a string site id',
|
||||||
|
JSON.stringify({ meta: { site_id: 'a', authors: [] }, mirrored_at: MIRRORED_AT }),
|
||||||
|
],
|
||||||
|
['seo that is a string', JSON.stringify({ seo: 'x', mirrored_at: MIRRORED_AT })],
|
||||||
|
[
|
||||||
|
'a seo slug that is a number',
|
||||||
|
JSON.stringify({ seo: { slug: 4 }, mirrored_at: MIRRORED_AT }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'a seo noindex that is a string',
|
||||||
|
JSON.stringify({ seo: { noindex: 'yes' }, mirrored_at: MIRRORED_AT }),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'a seo image id that is a string',
|
||||||
|
JSON.stringify({ seo: { cover_image_id: 'a' }, mirrored_at: MIRRORED_AT }),
|
||||||
|
],
|
||||||
|
])('returns null for %s', (_, raw) => {
|
||||||
|
sessionStorage.setItem(key, raw)
|
||||||
|
|
||||||
|
expect(readStoredDraft(7, 12)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,39 @@
|
|||||||
|
import type { ArticleSeo } from '@/lib/articleSeo'
|
||||||
|
import type { SuggestionCode } from '@/lib/seoTypes'
|
||||||
|
|
||||||
export interface Byline {
|
export interface Byline {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
slug: string
|
slug: string
|
||||||
avatar_id?: number | null
|
avatar_id?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ArticleCapability = 'read' | 'edit' | 'publish' | 'delete' | 'create_translation'
|
||||||
|
|
||||||
|
export interface CoverageEntry {
|
||||||
|
id: number
|
||||||
|
lang: string
|
||||||
|
title: string
|
||||||
|
is_online: boolean
|
||||||
|
language_enabled: boolean
|
||||||
|
capabilities: ArticleCapability[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Article extends Partial<ArticleSeo> {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
is_online: boolean
|
||||||
|
lang?: string
|
||||||
|
story_id?: number
|
||||||
|
capabilities: ArticleCapability[]
|
||||||
|
translations?: CoverageEntry[]
|
||||||
|
site_id?: number
|
||||||
|
created_by?: number
|
||||||
|
authors?: Byline[]
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
seo_status?: string
|
||||||
|
seo_dismissed?: SuggestionCode[]
|
||||||
|
seo_suggestions?: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { ArticleCapability } from '@/lib/articleTypes'
|
||||||
|
import { holds } from '@/lib/editorCapabilities'
|
||||||
|
|
||||||
|
export type SaveMood = 'clean' | 'dirty' | 'saving' | 'saved' | 'failed' | 'publishFailed'
|
||||||
|
|
||||||
|
export type PrimaryKind =
|
||||||
|
| 'saveAndPublish'
|
||||||
|
| 'save'
|
||||||
|
| 'trySaveAgain'
|
||||||
|
| 'publish'
|
||||||
|
| 'retryPublish'
|
||||||
|
| 'saving'
|
||||||
|
| 'publishing'
|
||||||
|
|
||||||
|
export interface PrimaryAction {
|
||||||
|
kind: PrimaryKind
|
||||||
|
disabled: boolean
|
||||||
|
busy: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Notice = 'readOnly' | 'publishOnly' | 'editOnly'
|
||||||
|
|
||||||
|
export interface ActionInput {
|
||||||
|
capabilities: ArticleCapability[]
|
||||||
|
online: boolean
|
||||||
|
mood: SaveMood
|
||||||
|
titleInvalid: boolean
|
||||||
|
languageDisabled: boolean
|
||||||
|
publishing: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionPlan {
|
||||||
|
primary: PrimaryAction | null
|
||||||
|
saveButton: { disabled: boolean } | null
|
||||||
|
unpublish: boolean
|
||||||
|
notice: Notice | null
|
||||||
|
onlineNotice: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function action(kind: PrimaryKind, disabled = false, busy = false): PrimaryAction {
|
||||||
|
return { kind, disabled, busy }
|
||||||
|
}
|
||||||
|
|
||||||
|
function primaryOf(input: ActionInput): PrimaryAction | null {
|
||||||
|
const canEdit = holds(input.capabilities, 'edit')
|
||||||
|
const canPublish = holds(input.capabilities, 'publish')
|
||||||
|
const unsaved = input.mood === 'dirty' || input.mood === 'failed'
|
||||||
|
const mayPublishNow = canPublish && !input.languageDisabled
|
||||||
|
if (input.mood === 'saving' && canEdit) {
|
||||||
|
return action('saving', true, true)
|
||||||
|
}
|
||||||
|
if (input.publishing && !input.online && canPublish) {
|
||||||
|
return action('publishing', true, true)
|
||||||
|
}
|
||||||
|
if (input.mood === 'publishFailed' && canPublish) {
|
||||||
|
return input.languageDisabled ? action('publish', true) : action('retryPublish')
|
||||||
|
}
|
||||||
|
if (canEdit && unsaved && mayPublishNow && !input.online && input.mood !== 'failed') {
|
||||||
|
return action('saveAndPublish', input.titleInvalid)
|
||||||
|
}
|
||||||
|
if (canEdit && unsaved) {
|
||||||
|
return action(input.mood === 'failed' ? 'trySaveAgain' : 'save', input.titleInvalid)
|
||||||
|
}
|
||||||
|
if (canPublish && !input.online) {
|
||||||
|
return action('publish', input.languageDisabled)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSavePrimary(primary: PrimaryAction | null) {
|
||||||
|
return (
|
||||||
|
primary !== null &&
|
||||||
|
(primary.kind === 'saving' ||
|
||||||
|
primary.kind === 'publishing' ||
|
||||||
|
primary.kind === 'save' ||
|
||||||
|
primary.kind === 'trySaveAgain')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function noticeOf(capabilities: ArticleCapability[], online: boolean): Notice | null {
|
||||||
|
const canEdit = holds(capabilities, 'edit')
|
||||||
|
const canPublish = holds(capabilities, 'publish')
|
||||||
|
if (!canEdit) {
|
||||||
|
return canPublish ? 'publishOnly' : 'readOnly'
|
||||||
|
}
|
||||||
|
return canPublish || online ? null : 'editOnly'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planActions(input: ActionInput): ActionPlan {
|
||||||
|
const canEdit = holds(input.capabilities, 'edit')
|
||||||
|
const canPublish = holds(input.capabilities, 'publish')
|
||||||
|
const unsaved = input.mood === 'dirty' || input.mood === 'failed'
|
||||||
|
const primary = primaryOf(input)
|
||||||
|
return {
|
||||||
|
primary,
|
||||||
|
saveButton: canEdit && !isSavePrimary(primary) ? { disabled: !unsaved } : null,
|
||||||
|
unpublish: canPublish && input.online && input.mood !== 'saving',
|
||||||
|
notice: noticeOf(input.capabilities, input.online),
|
||||||
|
onlineNotice: canEdit && input.online,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ArticleCapability } from '@/lib/articleTypes'
|
||||||
|
|
||||||
|
export function holds(
|
||||||
|
capabilities: ArticleCapability[] | null | undefined,
|
||||||
|
needed: ArticleCapability,
|
||||||
|
) {
|
||||||
|
return capabilities?.includes(needed) ?? false
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { t } from '@/i18n'
|
||||||
|
import type { PrimaryKind } from '@/lib/editorActions'
|
||||||
|
|
||||||
|
export function primaryLabel(kind: PrimaryKind, language: string | null) {
|
||||||
|
const labels: Record<PrimaryKind, () => string> = {
|
||||||
|
saveAndPublish: () =>
|
||||||
|
language ? t('Save and publish {0}', [language]) : t('Save and publish'),
|
||||||
|
save: () => t('Save'),
|
||||||
|
trySaveAgain: () => t('Try saving again'),
|
||||||
|
publish: () => (language ? t('Publish {0}', [language]) : t('Publish')),
|
||||||
|
retryPublish: () => t('Retry publish'),
|
||||||
|
saving: () => t('Saving…'),
|
||||||
|
publishing: () => t('Publishing…'),
|
||||||
|
}
|
||||||
|
return labels[kind]()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unpublishLabel(language: string | null) {
|
||||||
|
return language ? t('Unpublish {0}', [language]) : t('Unpublish')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function titleLabel(language: string | null) {
|
||||||
|
return language ? t('Title ({0})', [language]) : t('Title')
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { t } from '@/i18n'
|
||||||
|
import type { Notice } from '@/lib/editorActions'
|
||||||
|
|
||||||
|
export interface EditorNotice {
|
||||||
|
key: string
|
||||||
|
tone: 'info' | 'error' | 'warning' | 'neutral'
|
||||||
|
icon: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
action?: 'retryPublish'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoticeInput {
|
||||||
|
language: string | null
|
||||||
|
restoredAt: Date | null
|
||||||
|
saveFailed: boolean
|
||||||
|
publishFailed: boolean
|
||||||
|
languageDisabled: boolean
|
||||||
|
accessNotice: Notice | null
|
||||||
|
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',
|
||||||
|
tone: 'info',
|
||||||
|
icon: 'i-lucide-info',
|
||||||
|
title: t('Restored unsaved changes from {0}', [clockOf(restoredAt)]),
|
||||||
|
description: input.language
|
||||||
|
? t('You logged in again. Your {0} title and body were kept in this browser.', [
|
||||||
|
input.language,
|
||||||
|
])
|
||||||
|
: t('You logged in again. Your title and body were kept in this browser.'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFailedNotice(input: NoticeInput): EditorNotice {
|
||||||
|
return {
|
||||||
|
key: 'saveFailed',
|
||||||
|
tone: 'error',
|
||||||
|
icon: 'i-lucide-circle-x',
|
||||||
|
title: input.language ? t("Couldn't save {0}", [input.language]) : t("Couldn't save"),
|
||||||
|
description: t('Your changes are kept here. Nothing was published. Try saving again.'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishFailedNotice(): EditorNotice {
|
||||||
|
return {
|
||||||
|
key: 'publishFailed',
|
||||||
|
tone: 'error',
|
||||||
|
icon: 'i-lucide-circle-x',
|
||||||
|
title: t('Saved as a draft. Publishing failed. Try publishing again.'),
|
||||||
|
description: t('Your edits are saved. Only the publishing step failed.'),
|
||||||
|
action: 'retryPublish',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageDisabledNotice(language: string): EditorNotice {
|
||||||
|
return {
|
||||||
|
key: 'languageDisabled',
|
||||||
|
tone: 'warning',
|
||||||
|
icon: 'i-lucide-triangle-alert',
|
||||||
|
title: t('{0} is disabled on this site. Re-enable it to publish.', [language]),
|
||||||
|
description: t('You can still edit and save this version. Ask an owner to re-enable {0}.', [
|
||||||
|
language,
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function accessNoticeOf(notice: Notice): EditorNotice {
|
||||||
|
const notices: Record<Notice, () => EditorNotice> = {
|
||||||
|
readOnly: () => ({
|
||||||
|
key: 'readOnly',
|
||||||
|
tone: 'neutral',
|
||||||
|
icon: 'i-lucide-lock',
|
||||||
|
title: t("You can read this version. You can't edit it."),
|
||||||
|
description: t('The source reference and the versions you can read stay available.'),
|
||||||
|
}),
|
||||||
|
publishOnly: () => ({
|
||||||
|
key: 'publishOnly',
|
||||||
|
tone: 'neutral',
|
||||||
|
icon: 'i-lucide-lock',
|
||||||
|
title: t('You can publish this version but not edit it'),
|
||||||
|
description: t('Fields are read-only. Publish stays available.'),
|
||||||
|
}),
|
||||||
|
editOnly: () => ({
|
||||||
|
key: 'editOnly',
|
||||||
|
tone: 'neutral',
|
||||||
|
icon: 'i-lucide-info',
|
||||||
|
title: t('Publishing needs the publish permission'),
|
||||||
|
description: t('Saving keeps it as a draft.'),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return notices[notice]()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onlineNotice(): EditorNotice {
|
||||||
|
return {
|
||||||
|
key: 'online',
|
||||||
|
tone: 'neutral',
|
||||||
|
icon: 'i-lucide-info',
|
||||||
|
title: t('This version is online. Saving updates the live page right away.'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editorNotices(input: NoticeInput): EditorNotice[] {
|
||||||
|
const notices: EditorNotice[] = []
|
||||||
|
if (input.restoredAt) {
|
||||||
|
notices.push(restoredNotice(input, input.restoredAt))
|
||||||
|
}
|
||||||
|
if (input.saveFailed) {
|
||||||
|
notices.push(saveFailedNotice(input))
|
||||||
|
}
|
||||||
|
if (input.publishFailed) {
|
||||||
|
notices.push(publishFailedNotice())
|
||||||
|
}
|
||||||
|
if (input.languageDisabled && input.language) {
|
||||||
|
notices.push(languageDisabledNotice(input.language))
|
||||||
|
}
|
||||||
|
if (input.accessNotice) {
|
||||||
|
notices.push(accessNoticeOf(input.accessNotice))
|
||||||
|
}
|
||||||
|
if (input.onlineNotice) {
|
||||||
|
notices.push(onlineNotice())
|
||||||
|
}
|
||||||
|
return notices
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { t } from '@/i18n'
|
||||||
|
import type { ArticleCapability, CoverageEntry } from '@/lib/articleTypes'
|
||||||
|
import { holds } from '@/lib/editorCapabilities'
|
||||||
|
import { unpublishLabel } from '@/lib/editorLabels'
|
||||||
|
|
||||||
|
export interface OverflowItem {
|
||||||
|
key: 'unpublish' | 'editSlug' | 'move' | 'deleteVersion' | 'deleteArticle'
|
||||||
|
label: string
|
||||||
|
icon: string
|
||||||
|
danger: boolean
|
||||||
|
disabled: boolean
|
||||||
|
reason: string
|
||||||
|
onSelect: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverflowContext {
|
||||||
|
capabilities: ArticleCapability[]
|
||||||
|
siblings: CoverageEntry[]
|
||||||
|
language: string | null
|
||||||
|
unpublishInMenu: boolean
|
||||||
|
handlers: Partial<Record<OverflowItem['key'], () => void>>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ignore() {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailable(key: OverflowItem['key'], context: OverflowContext): Partial<OverflowItem> {
|
||||||
|
return context.handlers[key] ? {} : { disabled: true, reason: t('Not available yet.') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function item(
|
||||||
|
key: OverflowItem['key'],
|
||||||
|
label: string,
|
||||||
|
icon: string,
|
||||||
|
context: OverflowContext,
|
||||||
|
extra: Partial<OverflowItem> = {},
|
||||||
|
): OverflowItem {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
danger: false,
|
||||||
|
disabled: false,
|
||||||
|
reason: '',
|
||||||
|
onSelect: context.handlers[key] ?? ignore,
|
||||||
|
...unavailable(key, context),
|
||||||
|
...extra,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteVersionLabel(language: string | null) {
|
||||||
|
return language ? t('Delete {0} version', [language]) : t('Delete article')
|
||||||
|
}
|
||||||
|
|
||||||
|
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 editItems(context: OverflowContext): OverflowItem[] {
|
||||||
|
const blocked = blocksWholeArticle(context)
|
||||||
|
return [
|
||||||
|
item('editSlug', t('Edit slug'), 'i-lucide-link', context),
|
||||||
|
item(
|
||||||
|
'move',
|
||||||
|
t('Move article'),
|
||||||
|
'i-lucide-arrow-right-left',
|
||||||
|
context,
|
||||||
|
blockedExtra(blocked),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteItems(context: OverflowContext): OverflowItem[] {
|
||||||
|
const versionItem = item(
|
||||||
|
'deleteVersion',
|
||||||
|
deleteVersionLabel(context.language),
|
||||||
|
'i-lucide-trash-2',
|
||||||
|
context,
|
||||||
|
{ danger: true },
|
||||||
|
)
|
||||||
|
if (!context.language) {
|
||||||
|
return [versionItem]
|
||||||
|
}
|
||||||
|
const blocked = blocksWholeArticle(context)
|
||||||
|
return [
|
||||||
|
versionItem,
|
||||||
|
item('deleteArticle', t('Delete article'), 'i-lucide-trash-2', context, {
|
||||||
|
danger: !blocked,
|
||||||
|
...blockedExtra(blocked),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overflowItems(context: OverflowContext): OverflowItem[] {
|
||||||
|
const items: OverflowItem[] = []
|
||||||
|
if (context.unpublishInMenu) {
|
||||||
|
items.push(item('unpublish', unpublishLabel(context.language), 'i-lucide-eye-off', context))
|
||||||
|
}
|
||||||
|
if (holds(context.capabilities, 'edit')) {
|
||||||
|
items.push(...editItems(context))
|
||||||
|
}
|
||||||
|
if (holds(context.capabilities, 'delete')) {
|
||||||
|
items.push(...deleteItems(context))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export const TITLE_MIN = 3
|
||||||
|
export const TITLE_MAX = 200
|
||||||
|
export const TITLE_SERVER_MESSAGE = 'Article title must be between 3 and 200 characters.'
|
||||||
|
|
||||||
|
export type TitleVerdict = 'ok' | 'empty' | 'short' | 'long'
|
||||||
|
|
||||||
|
export function titleLength(title: string) {
|
||||||
|
return Array.from(title).length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function titleVerdict(title: string): TitleVerdict {
|
||||||
|
const length = titleLength(title)
|
||||||
|
if (length === 0) {
|
||||||
|
return 'empty'
|
||||||
|
}
|
||||||
|
if (length < TITLE_MIN) {
|
||||||
|
return 'short'
|
||||||
|
}
|
||||||
|
return length > TITLE_MAX ? 'long' : 'ok'
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { locale } from '@/i18n'
|
||||||
|
import type { ArticleCapability, CoverageEntry } from '@/lib/articleTypes'
|
||||||
|
import { holds } from '@/lib/editorCapabilities'
|
||||||
|
import { languageName } from '@/lib/siteLanguages'
|
||||||
|
|
||||||
|
export const MENU_FILTER_THRESHOLD = 8
|
||||||
|
|
||||||
|
export interface VersionRow {
|
||||||
|
key: string
|
||||||
|
lang: string
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
articleId: number | null
|
||||||
|
title: string
|
||||||
|
current: boolean
|
||||||
|
online: boolean
|
||||||
|
readOnly: boolean
|
||||||
|
languageDisabled: boolean
|
||||||
|
addTranslation: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionContext {
|
||||||
|
translations: CoverageEntry[]
|
||||||
|
currentId: number
|
||||||
|
capabilities: ArticleCapability[]
|
||||||
|
enabledLanguages: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionRow(entry: CoverageEntry, currentId: number): VersionRow {
|
||||||
|
const current = entry.id === currentId
|
||||||
|
return {
|
||||||
|
key: `version-${entry.id}`,
|
||||||
|
lang: entry.lang,
|
||||||
|
name: languageName(entry.lang, locale.value),
|
||||||
|
code: entry.lang.toUpperCase(),
|
||||||
|
articleId: entry.id,
|
||||||
|
title: entry.title,
|
||||||
|
current,
|
||||||
|
online: entry.is_online,
|
||||||
|
readOnly:
|
||||||
|
!current && !holds(entry.capabilities, 'edit') && !holds(entry.capabilities, 'publish'),
|
||||||
|
languageDisabled: !entry.language_enabled,
|
||||||
|
addTranslation: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function missingRow(lang: string): VersionRow {
|
||||||
|
return {
|
||||||
|
key: `missing-${lang}`,
|
||||||
|
lang,
|
||||||
|
name: languageName(lang, locale.value),
|
||||||
|
code: lang.toUpperCase(),
|
||||||
|
articleId: null,
|
||||||
|
title: '',
|
||||||
|
current: false,
|
||||||
|
online: false,
|
||||||
|
readOnly: false,
|
||||||
|
languageDisabled: false,
|
||||||
|
addTranslation: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function versionRows(context: VersionContext): VersionRow[] {
|
||||||
|
const rows = context.translations.map((entry) => versionRow(entry, context.currentId))
|
||||||
|
if (!holds(context.capabilities, 'create_translation')) {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
const present = new Set(context.translations.map((entry) => entry.lang))
|
||||||
|
const missing = context.enabledLanguages.filter((lang) => !present.has(lang))
|
||||||
|
return [...rows, ...missing.map(missingRow)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterRows(rows: VersionRow[], query: string) {
|
||||||
|
const needle = query.trim().toLocaleLowerCase()
|
||||||
|
if (!needle) {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
return rows.filter((row) => row.name.toLocaleLowerCase().includes(needle))
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
export function joinNames(names: string[]) {
|
||||||
|
const last = names[names.length - 1]
|
||||||
|
if (last === undefined || names.length === 1) {
|
||||||
|
return last ?? ''
|
||||||
|
}
|
||||||
|
return `${names.slice(0, -1).join(', ')}${t(' and ')}${last}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Article } from '@/lib/articleTypes'
|
||||||
|
|
||||||
|
let stashed: Article | null = null
|
||||||
|
|
||||||
|
export function stashArticle(article: Article) {
|
||||||
|
stashed = article
|
||||||
|
}
|
||||||
|
|
||||||
|
export function takeStashedArticle(id: number): Article | null {
|
||||||
|
const found = stashed?.id === id ? stashed : null
|
||||||
|
stashed = null
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStashedArticle() {
|
||||||
|
stashed = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import type { Byline } from '@/lib/articleTypes'
|
||||||
|
import { SEO_KEYS, type ArticleSeo, type SeoKey } from '@/lib/articleSeo'
|
||||||
|
|
||||||
|
export interface StoredDraft {
|
||||||
|
title?: string
|
||||||
|
body?: string
|
||||||
|
meta?: { site_id: number | null; authors: Byline[] }
|
||||||
|
seo?: ArticleSeo
|
||||||
|
mirrored_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAFT_PREFIX = 'renewcms:article-draft:'
|
||||||
|
|
||||||
|
const NULLABLE_NUMBER_KEYS: SeoKey[] = ['cover_image_id', 'social_image_id']
|
||||||
|
const BOOLEAN_KEYS: SeoKey[] = ['noindex']
|
||||||
|
|
||||||
|
type UnknownRecord = Record<string, unknown>
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is UnknownRecord {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOptionalString(value: unknown) {
|
||||||
|
return value === undefined || typeof value === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNullableNumber(value: unknown) {
|
||||||
|
return value === null || typeof value === 'number'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isByline(value: unknown) {
|
||||||
|
return (
|
||||||
|
isRecord(value) &&
|
||||||
|
typeof value.id === 'number' &&
|
||||||
|
typeof value.name === 'string' &&
|
||||||
|
typeof value.slug === 'string'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMeta(value: unknown) {
|
||||||
|
return (
|
||||||
|
value === undefined ||
|
||||||
|
(isRecord(value) &&
|
||||||
|
isNullableNumber(value.site_id) &&
|
||||||
|
Array.isArray(value.authors) &&
|
||||||
|
value.authors.every(isByline))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSeoValue(key: SeoKey, value: unknown) {
|
||||||
|
if (NULLABLE_NUMBER_KEYS.includes(key)) {
|
||||||
|
return isNullableNumber(value)
|
||||||
|
}
|
||||||
|
return BOOLEAN_KEYS.includes(key) ? typeof value === 'boolean' : typeof value === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSeo(value: unknown) {
|
||||||
|
return (
|
||||||
|
value === undefined ||
|
||||||
|
(isRecord(value) &&
|
||||||
|
SEO_KEYS.every((key) => value[key] === undefined || isSeoValue(key, value[key])))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMirrorTime(value: unknown) {
|
||||||
|
return typeof value === 'string' && !Number.isNaN(new Date(value).getTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStoredDraft(value: unknown): value is StoredDraft {
|
||||||
|
return (
|
||||||
|
isRecord(value) &&
|
||||||
|
isOptionalString(value.title) &&
|
||||||
|
isOptionalString(value.body) &&
|
||||||
|
isMeta(value.meta) &&
|
||||||
|
isSeo(value.seo) &&
|
||||||
|
isMirrorTime(value.mirrored_at)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyOf(userId: number, articleId: number) {
|
||||||
|
return `${DRAFT_PREFIX}${userId}:${articleId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredDraft(userId: number | null, articleId: number): StoredDraft | null {
|
||||||
|
if (userId === null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(keyOf(userId, articleId))
|
||||||
|
const parsed: unknown = raw === null ? null : JSON.parse(raw)
|
||||||
|
return isStoredDraft(parsed) ? parsed : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStoredDraft(userId: number | null, articleId: number, draft: StoredDraft) {
|
||||||
|
if (userId === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(keyOf(userId, articleId), JSON.stringify(draft))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeStoredDraft(userId: number | null, articleId: number) {
|
||||||
|
if (userId === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(keyOf(userId, articleId))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeStoredDraftsOf(userId: number | undefined) {
|
||||||
|
if (userId === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const prefix = `${DRAFT_PREFIX}${userId}:`
|
||||||
|
const owned = Object.keys(sessionStorage).filter((key) => key.startsWith(prefix))
|
||||||
|
owned.forEach((key) => sessionStorage.removeItem(key))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import { locale, t } from '@/i18n'
|
import { locale, t } from '@/i18n'
|
||||||
import type {
|
import type { StoryCounts, StoryItem } from '@/composables/useArticles'
|
||||||
ArticleCapability,
|
import type { ArticleCapability, CoverageEntry } from '@/lib/articleTypes'
|
||||||
CoverageEntry,
|
|
||||||
StoryCounts,
|
|
||||||
StoryItem,
|
|
||||||
} from '@/composables/useArticles'
|
|
||||||
import type { ArticleListQuery, ArticleStatus } from '@/lib/articleListQuery'
|
import type { ArticleListQuery, ArticleStatus } from '@/lib/articleListQuery'
|
||||||
import { authorsSummary } from '@/lib/articleMeta'
|
import { authorsSummary } from '@/lib/articleMeta'
|
||||||
import { formatAbsoluteDate, formatRelativeDate } from '@/lib/relativeDate'
|
import { formatAbsoluteDate, formatRelativeDate } from '@/lib/relativeDate'
|
||||||
|
|||||||
+278
-82
@@ -13,9 +13,16 @@ import MetaSection from '@/components/MetaSection.vue'
|
|||||||
import SearchSharingSection from '@/components/SearchSharingSection.vue'
|
import SearchSharingSection from '@/components/SearchSharingSection.vue'
|
||||||
import SeoPanel from '@/components/SeoPanel.vue'
|
import SeoPanel from '@/components/SeoPanel.vue'
|
||||||
import ApiPreviewDialog from '@/components/ApiPreviewDialog.vue'
|
import ApiPreviewDialog from '@/components/ApiPreviewDialog.vue'
|
||||||
|
import EditorActions from '@/components/EditorActions.vue'
|
||||||
import EditorHeader from '@/components/EditorHeader.vue'
|
import EditorHeader from '@/components/EditorHeader.vue'
|
||||||
|
import EditorLanguageMenu from '@/components/EditorLanguageMenu.vue'
|
||||||
import EditorMobileBar from '@/components/EditorMobileBar.vue'
|
import EditorMobileBar from '@/components/EditorMobileBar.vue'
|
||||||
|
import EditorNotices from '@/components/EditorNotices.vue'
|
||||||
import EditorSkeleton from '@/components/EditorSkeleton.vue'
|
import EditorSkeleton from '@/components/EditorSkeleton.vue'
|
||||||
|
import EditorTitleField from '@/components/EditorTitleField.vue'
|
||||||
|
import EditorVersionGone from '@/components/EditorVersionGone.vue'
|
||||||
|
import LocaleBadge from '@/components/localization/LocaleBadge.vue'
|
||||||
|
import SaveStatus from '@/components/SaveStatus.vue'
|
||||||
import { neutralizeUnsafeLinks } from '@/lib/articleLinkUrl'
|
import { neutralizeUnsafeLinks } from '@/lib/articleLinkUrl'
|
||||||
import EditorStatusBar from '@/components/EditorStatusBar.vue'
|
import EditorStatusBar from '@/components/EditorStatusBar.vue'
|
||||||
import ImageUploader from '@/components/ImageUploader.vue'
|
import ImageUploader from '@/components/ImageUploader.vue'
|
||||||
@@ -24,13 +31,14 @@ import { ApiError, useApi } from '@/composables/useApi'
|
|||||||
import type { Article } from '@/composables/useArticles'
|
import type { Article } from '@/composables/useArticles'
|
||||||
import { useArticleDraft, type SaveOutcome } from '@/composables/useArticleDraft'
|
import { useArticleDraft, type SaveOutcome } from '@/composables/useArticleDraft'
|
||||||
import { useArticleLifecycle } from '@/composables/useArticleLifecycle'
|
import { useArticleLifecycle } from '@/composables/useArticleLifecycle'
|
||||||
import { useContentAccess } from '@/composables/useContentAccess'
|
import { useEditorPublishing } from '@/composables/useEditorPublishing'
|
||||||
|
import { useEditorVersions } from '@/composables/useEditorVersions'
|
||||||
|
import { useVersionSwitch } from '@/composables/useVersionSwitch'
|
||||||
import { useEditorFocus } from '@/composables/useEditorFocus'
|
import { useEditorFocus } from '@/composables/useEditorFocus'
|
||||||
import { useEditorImages } from '@/composables/useEditorImages'
|
import { useEditorImages } from '@/composables/useEditorImages'
|
||||||
import { useEditorSeoPanel } from '@/composables/useEditorSeoPanel'
|
import { useEditorSeoPanel } from '@/composables/useEditorSeoPanel'
|
||||||
import { useSeoDismissals } from '@/composables/useSeoDismissals'
|
import { useSeoDismissals } from '@/composables/useSeoDismissals'
|
||||||
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
||||||
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
|
|
||||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||||
import { useSites, type Site } from '@/composables/useSites'
|
import { useSites, type Site } from '@/composables/useSites'
|
||||||
import { toBylines } from '@/lib/articleMeta'
|
import { toBylines } from '@/lib/articleMeta'
|
||||||
@@ -40,6 +48,15 @@ import { SEO_LIMITS, seoOf } from '@/lib/articleSeo'
|
|||||||
import { hostOf, slugify } from '@/lib/directory'
|
import { hostOf, slugify } from '@/lib/directory'
|
||||||
import { bodyCounts } from '@/lib/bodyCounts'
|
import { bodyCounts } from '@/lib/bodyCounts'
|
||||||
import { useCanManageVariants } from '@/composables/useCanManageVariants'
|
import { useCanManageVariants } from '@/composables/useCanManageVariants'
|
||||||
|
import { planActions, type SaveMood } from '@/lib/editorActions'
|
||||||
|
import { holds } from '@/lib/editorCapabilities'
|
||||||
|
import { titleLabel } from '@/lib/editorLabels'
|
||||||
|
import { editorNotices } from '@/lib/editorNotices'
|
||||||
|
import { overflowItems } from '@/lib/editorOverflow'
|
||||||
|
import { titleVerdict } from '@/lib/editorTitle'
|
||||||
|
import { takeStashedArticle } from '@/lib/prefetchedArticle'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import { useIsDesktop } from '@/composables/useIsDesktop'
|
||||||
import { bodyHasVariantImage } from '@/lib/variants/blockVariant'
|
import { bodyHasVariantImage } from '@/lib/variants/blockVariant'
|
||||||
import { useVariantCatalogStore } from '@/stores/variantCatalog'
|
import { useVariantCatalogStore } from '@/stores/variantCatalog'
|
||||||
|
|
||||||
@@ -68,9 +85,10 @@ const {
|
|||||||
} = useEditorImages(() => draft.body.value)
|
} = useEditorImages(() => draft.body.value)
|
||||||
|
|
||||||
const articleId = Number(route.params.id)
|
const articleId = Number(route.params.id)
|
||||||
const draft = useArticleDraft(articleId)
|
const draft = useArticleDraft(articleId, useSessionStore().session?.id ?? null)
|
||||||
const variantCatalog = useVariantCatalogStore()
|
const variantCatalog = useVariantCatalogStore()
|
||||||
const canManageVariants = useCanManageVariants()
|
const canManageVariants = useCanManageVariants()
|
||||||
|
const wideScreen = useIsDesktop('80rem')
|
||||||
|
|
||||||
usePageTitleOverride(() => article.value?.title)
|
usePageTitleOverride(() => article.value?.title)
|
||||||
|
|
||||||
@@ -85,9 +103,13 @@ const sites = ref<Site[]>([])
|
|||||||
const remembered = ref<boolean | null>(readMetaSectionOpen())
|
const remembered = ref<boolean | null>(readMetaSectionOpen())
|
||||||
const seoRemembered = ref<boolean | null>(readSeoSectionOpen())
|
const seoRemembered = ref<boolean | null>(readSeoSectionOpen())
|
||||||
const apiPreviewOpen = ref(false)
|
const apiPreviewOpen = ref(false)
|
||||||
|
const leaving = ref(false)
|
||||||
|
const titleField = ref<{ focus: () => void } | null>(null)
|
||||||
|
const titleSaveFailed = ref(false)
|
||||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
function markNotFound() {
|
function markNotFound() {
|
||||||
|
draft.discardStored()
|
||||||
loadState.value = 'notFound'
|
loadState.value = 'notFound'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,9 +190,16 @@ const address = computed(
|
|||||||
|
|
||||||
function editBody(newBody: string) {
|
function editBody(newBody: string) {
|
||||||
draft.edit(newBody)
|
draft.edit(newBody)
|
||||||
|
publishing.clearPublishFailure()
|
||||||
check.schedule()
|
check.schedule()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function editTitle(next: string) {
|
||||||
|
titleSaveFailed.value = false
|
||||||
|
draft.editTitle(next)
|
||||||
|
publishing.clearPublishFailure()
|
||||||
|
}
|
||||||
|
|
||||||
async function loadArticle() {
|
async function loadArticle() {
|
||||||
loadState.value = 'loading'
|
loadState.value = 'loading'
|
||||||
showSkeleton.value = false
|
showSkeleton.value = false
|
||||||
@@ -180,13 +209,17 @@ async function loadArticle() {
|
|||||||
}, SKELETON_DELAY_MS)
|
}, SKELETON_DELAY_MS)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const stashed = takeStashedArticle(articleId)
|
||||||
const [result, siteList] = await Promise.all([
|
const [result, siteList] = await Promise.all([
|
||||||
request<Article>(`/v1/article/${articleId}?format=html`),
|
stashed ?? request<Article>(`/v1/article/${articleId}?format=html`),
|
||||||
fetchSites(),
|
fetchSites(),
|
||||||
])
|
])
|
||||||
article.value = result
|
article.value = result
|
||||||
dismissedCodes.value = result.seo_dismissed ?? []
|
dismissedCodes.value = result.seo_dismissed ?? []
|
||||||
sites.value = siteList
|
sites.value = siteList
|
||||||
|
if (!holds(result.capabilities, 'edit')) {
|
||||||
|
draft.discardStored()
|
||||||
|
}
|
||||||
draft.load(
|
draft.load(
|
||||||
result.body,
|
result.body,
|
||||||
new Date(result.updated_at),
|
new Date(result.updated_at),
|
||||||
@@ -195,7 +228,9 @@ async function loadArticle() {
|
|||||||
authors: toBylines(result.authors ?? []),
|
authors: toBylines(result.authors ?? []),
|
||||||
},
|
},
|
||||||
seoOf(result),
|
seoOf(result),
|
||||||
|
result.title,
|
||||||
)
|
)
|
||||||
|
await versions.loadLanguages()
|
||||||
loadState.value = 'loaded'
|
loadState.value = 'loaded'
|
||||||
check.runNow()
|
check.runNow()
|
||||||
if (canManageVariants.value && bodyHasVariantImage(result.body)) {
|
if (canManageVariants.value && bodyHasVariantImage(result.body)) {
|
||||||
@@ -215,43 +250,106 @@ async function loadArticle() {
|
|||||||
|
|
||||||
const counts = computed(() => bodyCounts(draft.body.value))
|
const counts = computed(() => bodyCounts(draft.body.value))
|
||||||
const previewBody = computed(() => neutralizeUnsafeLinks(draft.body.value))
|
const previewBody = computed(() => neutralizeUnsafeLinks(draft.body.value))
|
||||||
const countText = computed(() =>
|
|
||||||
t('{0} words · {1} characters', [
|
const capabilities = computed(() => article.value?.capabilities ?? [])
|
||||||
counts.value.words.toLocaleString('en-US'),
|
const canEdit = computed(() => holds(capabilities.value, 'edit'))
|
||||||
counts.value.characters.toLocaleString('en-US'),
|
const versions = useEditorVersions(article, sites, capabilities)
|
||||||
]),
|
|
||||||
|
const offline = computed(() => draft.state.value === 'offline')
|
||||||
|
const unsavedChanges = computed(
|
||||||
|
() => draft.businessState.value === 'dirty' || draft.businessState.value === 'error',
|
||||||
)
|
)
|
||||||
const overflowItems = computed(() => [
|
const verdict = computed(() => titleVerdict(draft.title.value))
|
||||||
[
|
const titleInvalid = computed(() => verdict.value !== 'ok' || draft.titleServerError.value !== '')
|
||||||
{
|
|
||||||
icon: 'i-lucide-trash-2',
|
const mood = computed<SaveMood>(() => {
|
||||||
color: 'error' as const,
|
if (publishing.publishFailed.value && !unsavedChanges.value) {
|
||||||
label: t('Delete article'),
|
return 'publishFailed'
|
||||||
onSelect: () => {
|
}
|
||||||
handleDelete()
|
const moods: Record<string, SaveMood> = {
|
||||||
},
|
clean: 'clean',
|
||||||
|
dirty: 'dirty',
|
||||||
|
saving: 'saving',
|
||||||
|
saved: 'saved',
|
||||||
|
error: 'failed',
|
||||||
|
}
|
||||||
|
return moods[draft.businessState.value] ?? 'clean'
|
||||||
|
})
|
||||||
|
|
||||||
|
const plan = computed(() =>
|
||||||
|
planActions({
|
||||||
|
capabilities: capabilities.value,
|
||||||
|
online: article.value?.is_online ?? false,
|
||||||
|
mood: mood.value,
|
||||||
|
titleInvalid: titleInvalid.value,
|
||||||
|
languageDisabled: versions.languageDisabled.value || publishing.languageBlocked.value,
|
||||||
|
publishing: publishing.publishing.value || publishPending.value !== undefined,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const draftHint = computed(() =>
|
||||||
|
plan.value.notice === 'editOnly' && mood.value !== 'clean' && mood.value !== 'saved'
|
||||||
|
? t('Saving keeps it as a draft.')
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
|
||||||
|
const notices = computed(() =>
|
||||||
|
editorNotices({
|
||||||
|
language: versions.sentenceName.value,
|
||||||
|
restoredAt: draft.restoredAt.value,
|
||||||
|
saveFailed: mood.value === 'failed' && !titleSaveFailed.value,
|
||||||
|
publishFailed: mood.value === 'publishFailed',
|
||||||
|
languageDisabled: versions.languageDisabled.value || publishing.languageBlocked.value,
|
||||||
|
accessNotice: draftHint.value ? null : plan.value.notice,
|
||||||
|
onlineNotice: plan.value.onlineNotice,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const overflow = computed(() =>
|
||||||
|
overflowItems({
|
||||||
|
capabilities: capabilities.value,
|
||||||
|
siblings: versions.siblings.value,
|
||||||
|
language: versions.sentenceName.value,
|
||||||
|
unpublishInMenu: plan.value.unpublish && !wideScreen.value,
|
||||||
|
handlers: {
|
||||||
|
unpublish: handleUnpublish,
|
||||||
|
deleteVersion: handleDelete,
|
||||||
},
|
},
|
||||||
],
|
}),
|
||||||
])
|
|
||||||
|
|
||||||
const contentAccess = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
|
|
||||||
const articleAccess = computed(() => contentAccess.rowAccess(article.value?.created_by))
|
|
||||||
const canPublish = computed(() => contentAccess.canPublish(article.value?.created_by))
|
|
||||||
const saveEnabled = computed(
|
|
||||||
() =>
|
|
||||||
draft.state.value === 'dirty' &&
|
|
||||||
loadState.value === 'loaded' &&
|
|
||||||
articleAccess.value.canEdit,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const publishDisabled = computed(() => draft.state.value === 'offline')
|
function announceSaved() {
|
||||||
|
const names = versions.siblingNames.value
|
||||||
|
toast.add(() => ({
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-circle-check',
|
||||||
|
get title() {
|
||||||
|
return t('{0} saved', [versions.sentenceName.value ?? ''])
|
||||||
|
},
|
||||||
|
get description() {
|
||||||
|
if (names.length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return names.length === 1
|
||||||
|
? t("{0} wasn't changed.", [names[0]])
|
||||||
|
: t("Other versions weren't changed.")
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async function runSave(): Promise<SaveOutcome> {
|
async function runSave(options: { announce: boolean } = { announce: true }): Promise<SaveOutcome> {
|
||||||
|
const hadTitleEdit = draft.title.value !== article.value?.title
|
||||||
const outcome = await draft.save()
|
const outcome = await draft.save()
|
||||||
if (outcome === 'saved') {
|
if (outcome === 'saved') {
|
||||||
|
titleSaveFailed.value = false
|
||||||
|
syncSavedTitle()
|
||||||
check.invalidate()
|
check.invalidate()
|
||||||
check.runFresh()
|
check.runFresh()
|
||||||
|
if (versions.multilingual.value && options.announce) {
|
||||||
|
announceSaved()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
titleSaveFailed.value = outcome === 'error' && hadTitleEdit
|
||||||
revealSaveFailure(outcome)
|
revealSaveFailure(outcome)
|
||||||
if (outcome === 'site-gone') {
|
if (outcome === 'site-gone') {
|
||||||
directoryToasts.siteGone()
|
directoryToasts.siteGone()
|
||||||
@@ -272,24 +370,40 @@ async function runSave(): Promise<SaveOutcome> {
|
|||||||
return outcome
|
return outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncSavedTitle() {
|
||||||
|
const current = article.value
|
||||||
|
if (!current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
article.value = { ...current, title: draft.title.value }
|
||||||
|
versions.markTitle(current.id, draft.title.value)
|
||||||
|
}
|
||||||
|
|
||||||
async function performSave() {
|
async function performSave() {
|
||||||
if (!saveEnabled.value || !article.value) {
|
if (!article.value || !canEdit.value || !unsavedChanges.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (titleInvalid.value || offline.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await runSave()
|
await runSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
function retrySave() {
|
async function retryTitle() {
|
||||||
if (!article.value) {
|
await performSave()
|
||||||
return
|
titleField.value?.focus()
|
||||||
}
|
|
||||||
runSave()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.push('/article')
|
router.push('/article')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateArticle(updated: Article) {
|
||||||
|
const current = article.value
|
||||||
|
article.value = { ...current, ...updated, translations: current?.translations }
|
||||||
|
versions.markOnline(updated.id, updated.is_online)
|
||||||
|
}
|
||||||
|
|
||||||
function setOnline(makeOnline: boolean) {
|
function setOnline(makeOnline: boolean) {
|
||||||
const current = article.value
|
const current = article.value
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -312,12 +426,55 @@ function setOnline(makeOnline: boolean) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const publishing = useEditorPublishing({
|
||||||
|
articleId,
|
||||||
|
language: () => versions.sentenceName.value ?? '',
|
||||||
|
siblingNames: () => versions.siblingNames.value,
|
||||||
|
onlineSiblingNames: () => versions.onlineSiblingNames.value,
|
||||||
|
hasUnsavedChanges: () => unsavedChanges.value,
|
||||||
|
save: () => runSave({ announce: false }),
|
||||||
|
onUpdated: updateArticle,
|
||||||
|
onGone: markNotFound,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { choose: chooseVersion, loadingVisible: switching } = useVersionSwitch({
|
||||||
|
currentName: () => versions.displayName.value,
|
||||||
|
hasUnsavedChanges: () => unsavedChanges.value,
|
||||||
|
save: () => runSave({ announce: false }),
|
||||||
|
discard: () => draft.discardStored(),
|
||||||
|
allowLeave: () => {
|
||||||
|
leaving.value = true
|
||||||
|
},
|
||||||
|
cancelLeave: () => {
|
||||||
|
leaving.value = false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
function handlePublish() {
|
function handlePublish() {
|
||||||
setOnline(true)
|
return versions.multilingual.value ? publishing.publish() : setOnline(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUnpublish() {
|
function handleUnpublish() {
|
||||||
setOnline(false)
|
return versions.multilingual.value ? publishing.unpublish() : setOnline(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveAndPublish() {
|
||||||
|
return versions.multilingual.value ? publishing.saveAndPublish() : setOnline(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrimary() {
|
||||||
|
const kind = plan.value.primary?.kind
|
||||||
|
const actions = {
|
||||||
|
save: performSave,
|
||||||
|
trySaveAgain: performSave,
|
||||||
|
saveAndPublish: handleSaveAndPublish,
|
||||||
|
publish: handlePublish,
|
||||||
|
retryPublish: publishing.retryPublish,
|
||||||
|
}
|
||||||
|
if (kind && kind in actions) {
|
||||||
|
return actions[kind as keyof typeof actions]()
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
@@ -349,8 +506,9 @@ onBeforeUnmount(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useUnsavedChangesGuard({
|
useUnsavedChangesGuard({
|
||||||
isDirty: () => draft.state.value === 'dirty',
|
isDirty: () => unsavedChanges.value && !leaving.value,
|
||||||
title: () => article.value?.title ?? '',
|
title: () => article.value?.title ?? '',
|
||||||
|
onDiscard: draft.discardStored,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -361,18 +519,6 @@ useUnsavedChangesGuard({
|
|||||||
:back-label="t('Back to Articles')"
|
:back-label="t('Back to Articles')"
|
||||||
full-bleed
|
full-bleed
|
||||||
>
|
>
|
||||||
<template v-if="loadState === 'loaded'" #navbar-right>
|
|
||||||
<UDropdownMenu :items="overflowItems">
|
|
||||||
<UButton
|
|
||||||
icon="i-lucide-ellipsis"
|
|
||||||
color="neutral"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-11 justify-center lg:hidden"
|
|
||||||
:ui="{ leadingIcon: 'size-5' }"
|
|
||||||
:aria-label="t('More actions')"
|
|
||||||
/>
|
|
||||||
</UDropdownMenu>
|
|
||||||
</template>
|
|
||||||
<template v-if="loadState === 'loaded'" #breadcrumb>
|
<template v-if="loadState === 'loaded'" #breadcrumb>
|
||||||
<UBreadcrumb
|
<UBreadcrumb
|
||||||
:items="[
|
:items="[
|
||||||
@@ -390,7 +536,12 @@ useUnsavedChangesGuard({
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<DefineDetailsPanel>
|
<DefineDetailsPanel>
|
||||||
<ArticleDetails v-if="article" :article="article" v-model:open="detailsOpen">
|
<ArticleDetails
|
||||||
|
v-if="article"
|
||||||
|
:article="article"
|
||||||
|
:multilingual="versions.multilingual.value"
|
||||||
|
v-model:open="detailsOpen"
|
||||||
|
>
|
||||||
<template #meta="{ sheet }">
|
<template #meta="{ sheet }">
|
||||||
<MetaSection
|
<MetaSection
|
||||||
:sites="sites"
|
:sites="sites"
|
||||||
@@ -421,40 +572,83 @@ useUnsavedChangesGuard({
|
|||||||
</ArticleDetails>
|
</ArticleDetails>
|
||||||
</DefineDetailsPanel>
|
</DefineDetailsPanel>
|
||||||
|
|
||||||
<ErrorView v-if="loadState === 'notFound'" kind="article-not-found" />
|
<EditorVersionGone v-if="loadState === 'notFound'" @back="goBack" />
|
||||||
<ErrorView v-else-if="loadState === 'error'" kind="error" @retry="loadArticle" />
|
<ErrorView v-else-if="loadState === 'error'" kind="error" @retry="loadArticle" />
|
||||||
|
|
||||||
<EditorSkeleton v-else-if="loadState === 'loading' && showSkeleton" @back="goBack" />
|
<EditorSkeleton v-else-if="loadState === 'loading' && showSkeleton" @back="goBack" />
|
||||||
|
|
||||||
<div v-else-if="loadState === 'loaded' && article" class="flex min-h-0 flex-1 flex-col">
|
<div v-else-if="loadState === 'loaded' && article" class="flex min-h-0 flex-1 flex-col">
|
||||||
<EditorHeader
|
<EditorHeader @back="goBack">
|
||||||
v-model:preview="previewOn"
|
<template #title>
|
||||||
:article="article"
|
<EditorTitleField
|
||||||
:state="draft.state.value"
|
ref="titleField"
|
||||||
:saved-at="draft.savedAt.value"
|
:model-value="draft.title.value"
|
||||||
:count-text="countText"
|
:label="titleLabel(versions.sentenceName.value)"
|
||||||
:save-enabled="saveEnabled"
|
:lang="article.lang"
|
||||||
:publish-pending="publishPending"
|
:readonly="!canEdit"
|
||||||
:publish-disabled="publishDisabled"
|
:changed="draft.title.value !== article.title"
|
||||||
:can-publish="canPublish"
|
:saving="draft.savingTitle.value"
|
||||||
:can-delete="articleAccess.canDelete"
|
:failed="titleSaveFailed"
|
||||||
:delete-reason="articleAccess.deleteReason"
|
:server-error="draft.titleServerError.value"
|
||||||
@back="goBack"
|
@update:model-value="editTitle"
|
||||||
@retry="retrySave"
|
@retry="retryTitle"
|
||||||
@save="performSave"
|
/>
|
||||||
@publish="handlePublish"
|
</template>
|
||||||
@unpublish="handleUnpublish"
|
<template #meta>
|
||||||
@delete="handleDelete"
|
<EditorLanguageMenu
|
||||||
/>
|
v-if="versions.multilingual.value"
|
||||||
|
:rows="versions.rows.value"
|
||||||
|
:name="versions.displayName.value"
|
||||||
|
:code="versions.tag.value.toUpperCase()"
|
||||||
|
@choose="chooseVersion"
|
||||||
|
/>
|
||||||
|
<LocaleBadge
|
||||||
|
:tone="article.is_online ? 'success' : 'neutral'"
|
||||||
|
:icon="article.is_online ? 'i-lucide-circle-check' : 'i-lucide-pencil'"
|
||||||
|
>
|
||||||
|
{{ article.is_online ? t('Published') : t('Draft') }}
|
||||||
|
</LocaleBadge>
|
||||||
|
<SaveStatus
|
||||||
|
v-if="canEdit"
|
||||||
|
:state="draft.state.value"
|
||||||
|
:saved-at="draft.savedAt.value"
|
||||||
|
:retryable="false"
|
||||||
|
/>
|
||||||
|
<span v-if="draftHint" class="text-xs text-muted max-lg:hidden">{{
|
||||||
|
draftHint
|
||||||
|
}}</span>
|
||||||
|
<LocaleBadge v-if="!canEdit" tone="neutral" icon="i-lucide-lock">
|
||||||
|
{{ t('Read only') }}
|
||||||
|
</LocaleBadge>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<EditorActions
|
||||||
|
v-model:preview="previewOn"
|
||||||
|
:plan="plan"
|
||||||
|
:language="versions.sentenceName.value"
|
||||||
|
:offline="offline"
|
||||||
|
:unpublish-visible="wideScreen"
|
||||||
|
:overflow="overflow"
|
||||||
|
:previewable="true"
|
||||||
|
@save="performSave"
|
||||||
|
@primary="handlePrimary"
|
||||||
|
@unpublish="handleUnpublish"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</EditorHeader>
|
||||||
|
|
||||||
<div v-if="previewOn" class="flex min-h-0 flex-1">
|
<EditorNotices :notices="notices" @retry-publish="publishing.retryPublish" />
|
||||||
|
|
||||||
|
<EditorSkeleton v-if="switching" headerless />
|
||||||
|
|
||||||
|
<div v-else-if="previewOn" class="flex min-h-0 flex-1">
|
||||||
<div
|
<div
|
||||||
class="min-h-0 flex-1 overflow-y-auto bg-muted px-12 py-10"
|
class="min-h-0 flex-1 overflow-y-auto bg-muted px-12 py-10"
|
||||||
data-testid="article-preview"
|
data-testid="article-preview"
|
||||||
>
|
>
|
||||||
<div class="article-content mx-auto">
|
<div class="article-content mx-auto">
|
||||||
<h2 class="text-2xl font-semibold text-highlighted">
|
<h2 class="text-2xl font-semibold text-highlighted">
|
||||||
{{ article.title }}
|
{{ draft.title.value }}
|
||||||
</h2>
|
</h2>
|
||||||
<div v-html="previewBody" />
|
<div v-html="previewBody" />
|
||||||
</div>
|
</div>
|
||||||
@@ -471,12 +665,15 @@ useUnsavedChangesGuard({
|
|||||||
class="h-full"
|
class="h-full"
|
||||||
:model-value="draft.body.value"
|
:model-value="draft.body.value"
|
||||||
:article-id="articleId"
|
:article-id="articleId"
|
||||||
|
:readonly="!canEdit"
|
||||||
|
:label="t('Body')"
|
||||||
@update:model-value="editBody"
|
@update:model-value="editBody"
|
||||||
@request-image="onRequestImage"
|
@request-image="onRequestImage"
|
||||||
@request-remove-image="onRequestRemoveImage"
|
@request-remove-image="onRequestRemoveImage"
|
||||||
>
|
>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<EditorStatusBar
|
<EditorStatusBar
|
||||||
|
v-if="canEdit"
|
||||||
class="max-lg:hidden"
|
class="max-lg:hidden"
|
||||||
:words="counts.words"
|
:words="counts.words"
|
||||||
:characters="counts.characters"
|
:characters="counts.characters"
|
||||||
@@ -534,15 +731,14 @@ useUnsavedChangesGuard({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<EditorMobileBar
|
<EditorMobileBar
|
||||||
:online="article.is_online"
|
:hint="draftHint"
|
||||||
:state="draft.state.value"
|
:plan="plan"
|
||||||
:save-enabled="saveEnabled"
|
:language="versions.sentenceName.value"
|
||||||
:publish-pending="publishPending"
|
:offline="offline"
|
||||||
:publish-disabled="publishDisabled"
|
:overflow="overflow"
|
||||||
:can-publish="canPublish"
|
|
||||||
@details="detailsOpen = true"
|
@details="detailsOpen = true"
|
||||||
@save="performSave"
|
@save="performSave"
|
||||||
@publish="handlePublish"
|
@primary="handlePrimary"
|
||||||
@unpublish="handleUnpublish"
|
@unpublish="handleUnpublish"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { initializeLocale } from '@/i18n'
|
||||||
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import {
|
||||||
|
WRITE_FAILS,
|
||||||
|
failWith,
|
||||||
|
kit,
|
||||||
|
press,
|
||||||
|
settle,
|
||||||
|
text,
|
||||||
|
typeInto,
|
||||||
|
unmountRoute,
|
||||||
|
} from './localizationKit'
|
||||||
|
import {
|
||||||
|
EDIT_ONLY,
|
||||||
|
FULL,
|
||||||
|
PUBLISH_ONLY,
|
||||||
|
READ,
|
||||||
|
articleOf,
|
||||||
|
buttonNamed,
|
||||||
|
editBody,
|
||||||
|
mountEditor,
|
||||||
|
openMoreActions,
|
||||||
|
titleInput,
|
||||||
|
type EditorFixture,
|
||||||
|
} from './editorKit'
|
||||||
|
import type { ArticleCapability } from '@/composables/useArticles'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
const TABLET_WIDTH = 1024
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
initializeLocale()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(unmountRoute)
|
||||||
|
|
||||||
|
function primary() {
|
||||||
|
return document.body
|
||||||
|
.querySelector<HTMLElement>('[data-testid="primary-action"]')
|
||||||
|
?.textContent?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveButton() {
|
||||||
|
return document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
function menuItems() {
|
||||||
|
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function menuItem(label: string) {
|
||||||
|
return menuItems().find((item) => item.textContent?.includes(label))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dirty() {
|
||||||
|
await typeInto(titleInput(), 'Couts reels')
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
name: string
|
||||||
|
fixture: EditorFixture
|
||||||
|
edit?: boolean
|
||||||
|
primary: string | undefined
|
||||||
|
save: 'disabled' | 'enabled' | 'absent'
|
||||||
|
unpublish?: boolean
|
||||||
|
text?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ONLINE_ALERT = 'This version is online. Saving updates the live page right away.'
|
||||||
|
|
||||||
|
const MATRIX: Row[] = [
|
||||||
|
{
|
||||||
|
name: 'clean draft with edit and publish',
|
||||||
|
fixture: {},
|
||||||
|
primary: 'Publish French',
|
||||||
|
save: 'disabled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'clean draft with edit only',
|
||||||
|
fixture: { caps: EDIT_ONLY },
|
||||||
|
primary: undefined,
|
||||||
|
save: 'disabled',
|
||||||
|
text: ['Publishing needs the publish permission'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dirty draft with edit only',
|
||||||
|
fixture: { caps: EDIT_ONLY },
|
||||||
|
edit: true,
|
||||||
|
primary: 'Save',
|
||||||
|
save: 'absent',
|
||||||
|
text: ['Saving keeps it as a draft.'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dirty draft with edit and publish',
|
||||||
|
fixture: {},
|
||||||
|
edit: true,
|
||||||
|
primary: 'Save and publish French',
|
||||||
|
save: 'enabled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'clean online with edit and publish',
|
||||||
|
fixture: { online: { fr: true } },
|
||||||
|
primary: undefined,
|
||||||
|
save: 'disabled',
|
||||||
|
unpublish: true,
|
||||||
|
text: [ONLINE_ALERT],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dirty online with edit and publish',
|
||||||
|
fixture: { online: { fr: true } },
|
||||||
|
edit: true,
|
||||||
|
primary: 'Save',
|
||||||
|
save: 'absent',
|
||||||
|
unpublish: true,
|
||||||
|
text: [ONLINE_ALERT],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'publish without edit',
|
||||||
|
fixture: { caps: PUBLISH_ONLY },
|
||||||
|
primary: 'Publish French',
|
||||||
|
save: 'absent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'read only',
|
||||||
|
fixture: { caps: READ },
|
||||||
|
primary: undefined,
|
||||||
|
save: 'absent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'saving an online version without publish',
|
||||||
|
fixture: { caps: EDIT_ONLY, online: { fr: true } },
|
||||||
|
edit: true,
|
||||||
|
primary: 'Save',
|
||||||
|
save: 'absent',
|
||||||
|
unpublish: false,
|
||||||
|
text: [ONLINE_ALERT],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('loc.l5.*: Header priority and action matrix', () => {
|
||||||
|
it.each(MATRIX)('$name', async (row) => {
|
||||||
|
await mountEditor(row.fixture)
|
||||||
|
if (row.edit) {
|
||||||
|
await dirty()
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(primary()).toBe(row.primary)
|
||||||
|
expect(
|
||||||
|
saveButton() === null ? 'absent' : saveButton()!.disabled ? 'disabled' : 'enabled',
|
||||||
|
).toBe(row.save)
|
||||||
|
expect(buttonNamed('Unpublish French') !== undefined).toBe(row.unpublish ?? false)
|
||||||
|
row.text?.forEach((fragment) => expect(text()).toContain(fragment))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders at most one filled button for every combination', async () => {
|
||||||
|
const capabilitySets: ArticleCapability[][] = [READ, EDIT_ONLY, PUBLISH_ONLY, FULL]
|
||||||
|
for (const caps of capabilitySets) {
|
||||||
|
await mountEditor({ caps })
|
||||||
|
expect(
|
||||||
|
document.body.querySelectorAll('[data-testid="primary-action"]').length,
|
||||||
|
).toBeLessThanOrEqual(1)
|
||||||
|
await unmountRoute()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l5.saving and saveFail: Saving and save failed', () => {
|
||||||
|
it('marks the primary busy while saving', async () => {
|
||||||
|
await mountEditor({ extra: { 'PATCH /v1/article/12': () => new Promise(() => undefined) } })
|
||||||
|
await dirty()
|
||||||
|
await press('Save')
|
||||||
|
|
||||||
|
const busy = document.body.querySelector('[data-testid="primary-action"]')
|
||||||
|
expect(busy?.textContent).toContain('Saving…')
|
||||||
|
expect(busy?.getAttribute('aria-busy')).toBe('true')
|
||||||
|
expect(saveButton()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the banner, "Try saving again" and publishes nothing when a save fails', async () => {
|
||||||
|
await mountEditor({ extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
|
||||||
|
await editBody()
|
||||||
|
await press('Save')
|
||||||
|
|
||||||
|
expect(text()).toContain("Couldn't save French")
|
||||||
|
expect(text()).toContain(
|
||||||
|
'Your changes are kept here. Nothing was published. Try saving again.',
|
||||||
|
)
|
||||||
|
expect(primary()).toBe('Try saving again')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads Saved and offers Publish French after a good save', async () => {
|
||||||
|
await mountEditor({
|
||||||
|
extra: { 'PATCH /v1/article/12': { ...articleOf({}), title: 'Couts reels' } },
|
||||||
|
})
|
||||||
|
await dirty()
|
||||||
|
await press('Save')
|
||||||
|
|
||||||
|
expect(text()).toContain('Saved')
|
||||||
|
expect(primary()).toBe('Publish French')
|
||||||
|
expect(saveButton()!.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l5.ovf: Collapse order by width', () => {
|
||||||
|
const online = { online: { fr: true } }
|
||||||
|
|
||||||
|
it('keeps Unpublish French and More actions in the header at 1440', async () => {
|
||||||
|
await mountEditor(online)
|
||||||
|
|
||||||
|
expect(buttonNamed('Unpublish French')).toBeDefined()
|
||||||
|
expect(buttonNamed('More actions')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves Unpublish into More actions at 1024', async () => {
|
||||||
|
await mountEditor({ ...online, width: TABLET_WIDTH })
|
||||||
|
|
||||||
|
expect(buttonNamed('Unpublish French')).toBeUndefined()
|
||||||
|
await openMoreActions()
|
||||||
|
expect(menuItem('Unpublish French')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('puts Save, the primary and an overflow button in the bottom bar at 390', async () => {
|
||||||
|
await mountEditor({ ...online, width: MOBILE_WIDTH })
|
||||||
|
await dirty()
|
||||||
|
|
||||||
|
const bar = document.body.querySelector<HTMLElement>('.border-t.lg\\:hidden')!
|
||||||
|
expect(bar.textContent).toContain('Save')
|
||||||
|
expect(bar.querySelector('[aria-label="More actions"]')).not.toBeNull()
|
||||||
|
const targets = Array.from(bar.querySelectorAll('button'))
|
||||||
|
expect(targets.every((target) => /max-md:h-11|size-11/.test(target.className))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the overflow as a sheet on a phone with Unpublish, Edit slug, Move and Delete', async () => {
|
||||||
|
await mountEditor({ ...online, width: MOBILE_WIDTH })
|
||||||
|
await openMoreActions()
|
||||||
|
|
||||||
|
expect(menuItems().map((item) => item.textContent?.trim())).toEqual([
|
||||||
|
'Unpublish French',
|
||||||
|
`Edit slug${NOT_AVAILABLE}`,
|
||||||
|
`Move article${NOT_AVAILABLE}`,
|
||||||
|
'Delete French version',
|
||||||
|
`Delete article${NOT_AVAILABLE}`,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const NOT_AVAILABLE = 'Not available yet.'
|
||||||
|
|
||||||
|
describe('loc.perm.createNotAll: The overflow menu follows capabilities', () => {
|
||||||
|
it('lists Edit slug, Move article and both delete items for the owner', async () => {
|
||||||
|
await mountEditor()
|
||||||
|
await openMoreActions()
|
||||||
|
|
||||||
|
expect(menuItems().map((item) => item.textContent?.trim())).toEqual([
|
||||||
|
`Edit slug${NOT_AVAILABLE}`,
|
||||||
|
`Move article${NOT_AVAILABLE}`,
|
||||||
|
'Delete French version',
|
||||||
|
`Delete article${NOT_AVAILABLE}`,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Edit slug, Move article and Delete article disabled with a reason until they 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)
|
||||||
|
}
|
||||||
|
expect(menuItem('Delete French version')!.getAttribute('aria-disabled')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables Delete article and Move with the reason when a sibling lacks delete', async () => {
|
||||||
|
await mountEditor({ siblingCaps: { en: EDIT_ONLY } })
|
||||||
|
await openMoreActions()
|
||||||
|
|
||||||
|
for (const label of ['Move article', 'Delete article']) {
|
||||||
|
const item = menuItem(label)!
|
||||||
|
expect(item.getAttribute('aria-disabled')).toBe('true')
|
||||||
|
expect(item.textContent).toContain("You can't delete this article.")
|
||||||
|
}
|
||||||
|
expect(menuItem('Delete French version')!.getAttribute('aria-disabled')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no overflow button on a read-only version', async () => {
|
||||||
|
await mountEditor({ caps: READ })
|
||||||
|
|
||||||
|
expect(buttonNamed('More actions')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.perm.*: Server checks still win', () => {
|
||||||
|
it('keeps the edit and reads Save failed when PATCH answers 403', async () => {
|
||||||
|
await mountEditor({
|
||||||
|
caps: EDIT_ONLY,
|
||||||
|
extra: { 'PATCH /v1/article/12': failWith(403, 'missing_permission') },
|
||||||
|
})
|
||||||
|
await dirty()
|
||||||
|
await press('Save')
|
||||||
|
|
||||||
|
expect(text()).toContain('Save failed')
|
||||||
|
expect(titleInput().value).toBe('Couts reels')
|
||||||
|
expect(kit.calls.some((call) => call.method === 'PATCH')).toBe(true)
|
||||||
|
await settle()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { initializeLocale } from '@/i18n'
|
||||||
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import { settle, text, unmountRoute } from './localizationKit'
|
||||||
|
import {
|
||||||
|
EDIT_ONLY,
|
||||||
|
FULL,
|
||||||
|
READ,
|
||||||
|
buttonNamed,
|
||||||
|
languageTrigger,
|
||||||
|
menuRows,
|
||||||
|
mountEditor,
|
||||||
|
openLanguageMenu,
|
||||||
|
rowFor,
|
||||||
|
titleInput,
|
||||||
|
} from './editorKit'
|
||||||
|
import type { LanguageFixture } from './localizationData'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => initializeLocale())
|
||||||
|
afterEach(unmountRoute)
|
||||||
|
|
||||||
|
const LANGUAGE_NAMES = ['English', 'Arabic', 'French', 'German']
|
||||||
|
|
||||||
|
function rowNames() {
|
||||||
|
return menuRows().map((row) => row.textContent?.trim() ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageOf(row: string) {
|
||||||
|
return LANGUAGE_NAMES.find((name) => row.startsWith(name)) ?? row
|
||||||
|
}
|
||||||
|
|
||||||
|
function languageRows(tags: string[]): LanguageFixture[] {
|
||||||
|
return tags.map((lang) => ({ lang, versions: 1, published: 0 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('loc.l5.menu: The menu lists readable versions in API order', () => {
|
||||||
|
it('opens a menu, not a listbox, with English, Arabic and French and no German', async () => {
|
||||||
|
await mountEditor({ caps: EDIT_ONLY })
|
||||||
|
await openLanguageMenu()
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[role="menu"]')).not.toBeNull()
|
||||||
|
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
|
||||||
|
expect(rowNames().map(languageOf)).toEqual(['English', 'Arabic', 'French'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names the control "Language: French. Switch version"', async () => {
|
||||||
|
await mountEditor()
|
||||||
|
|
||||||
|
expect(languageTrigger()?.getAttribute('aria-label')).toBe(
|
||||||
|
'Language: French. Switch version',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows each language name, code and publication badge, with the French row current', async () => {
|
||||||
|
await mountEditor({ caps: EDIT_ONLY })
|
||||||
|
await openLanguageMenu()
|
||||||
|
|
||||||
|
expect(rowNames()).toEqual([
|
||||||
|
expect.stringMatching(/^EnglishEN.*Published/),
|
||||||
|
expect.stringMatching(/^ArabicAR.*Published/),
|
||||||
|
expect.stringMatching(/^FrenchFR.*Draft/),
|
||||||
|
])
|
||||||
|
expect(rowFor('French')?.querySelector('[aria-current="true"]')).not.toBeNull()
|
||||||
|
expect(rowFor('English')?.querySelector('[aria-current="true"]')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l5.two and loc.l5.many: Menu size', () => {
|
||||||
|
it('shows two rows without a filter on a two-language site', async () => {
|
||||||
|
await mountEditor({
|
||||||
|
caps: EDIT_ONLY,
|
||||||
|
translations: ['en', 'fr'],
|
||||||
|
languages: languageRows(['en', 'fr']),
|
||||||
|
})
|
||||||
|
await openLanguageMenu()
|
||||||
|
|
||||||
|
expect(menuRows()).toHaveLength(2)
|
||||||
|
expect(document.body.querySelector('input[placeholder="Find a language"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows all four rows without a filter', async () => {
|
||||||
|
await mountEditor({ caps: EDIT_ONLY, translations: ['en', 'fr', 'de', 'ar'] })
|
||||||
|
await openLanguageMenu()
|
||||||
|
|
||||||
|
expect(menuRows()).toHaveLength(4)
|
||||||
|
expect(document.body.querySelector('input[placeholder="Find a language"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds a filter field that narrows the rows by language name on a many-language site', async () => {
|
||||||
|
const tags = ['en', 'fr', 'de', 'ar', 'es', 'it', 'ja', 'nl', 'pl', 'pt-BR']
|
||||||
|
await mountEditor({
|
||||||
|
caps: FULL,
|
||||||
|
translations: ['en', 'fr', 'de', 'ar'],
|
||||||
|
languages: languageRows(tags),
|
||||||
|
})
|
||||||
|
await openLanguageMenu()
|
||||||
|
|
||||||
|
const filter = document.body.querySelector<HTMLInputElement>(
|
||||||
|
'input[placeholder="Find a language"]',
|
||||||
|
)!
|
||||||
|
expect(menuRows().length).toBeGreaterThan(8)
|
||||||
|
filter.value = 'port'
|
||||||
|
filter.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(rowNames().map((name) => name.slice(0, 'Portuguese'.length))).toEqual(['Portuguese'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.perm.*: Row tags and absent rows follow each sibling capabilities', () => {
|
||||||
|
it('tags a read-only sibling as Read only and still opens it', async () => {
|
||||||
|
await mountEditor({ siblingCaps: { ar: READ } })
|
||||||
|
await openLanguageMenu()
|
||||||
|
|
||||||
|
expect(rowFor('Arabic')?.textContent).toContain('Read only')
|
||||||
|
expect(rowFor('English')?.textContent).not.toContain('Read only')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no German row, count or placeholder when German is not in translations', async () => {
|
||||||
|
await mountEditor({ caps: EDIT_ONLY })
|
||||||
|
await openLanguageMenu()
|
||||||
|
|
||||||
|
expect(rowFor('German')).toBeUndefined()
|
||||||
|
expect(text()).not.toContain('German')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a disabled language listed and marks it as disabled', async () => {
|
||||||
|
await mountEditor({ languageEnabled: { ar: false } })
|
||||||
|
await openLanguageMenu()
|
||||||
|
|
||||||
|
expect(rowFor('Arabic')?.textContent).toContain('Disabled')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l5b.withAdd, noAdd and roMenu: Add translation rows', () => {
|
||||||
|
it('lists missing languages as Add translation rows when the caller can create', async () => {
|
||||||
|
await mountEditor({ caps: FULL })
|
||||||
|
await openLanguageMenu()
|
||||||
|
|
||||||
|
expect(rowFor('German')?.textContent).toContain('Add translation')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers nothing for missing languages without create_translation', async () => {
|
||||||
|
await mountEditor({ caps: EDIT_ONLY })
|
||||||
|
await openLanguageMenu()
|
||||||
|
|
||||||
|
expect(text()).not.toContain('Add translation')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers no Add translation row on a read-only version', async () => {
|
||||||
|
await mountEditor({ caps: READ })
|
||||||
|
await openLanguageMenu()
|
||||||
|
|
||||||
|
expect(text()).not.toContain('Add translation')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the Add translation row without doing anything when chosen', async () => {
|
||||||
|
await mountEditor({ caps: FULL })
|
||||||
|
await openLanguageMenu()
|
||||||
|
rowFor('German')!.click()
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(titleInput().value).toBe('Couts de renovation')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('The menu is keyboard operable', () => {
|
||||||
|
it('opens on ArrowDown with focus on the current row', async () => {
|
||||||
|
await mountEditor()
|
||||||
|
languageTrigger()!.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
|
||||||
|
)
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[role="menu"]')).not.toBeNull()
|
||||||
|
expect(document.activeElement?.textContent).toContain('French')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focuses the current row when the sheet opens on a phone', async () => {
|
||||||
|
await mountEditor({ width: MOBILE_WIDTH })
|
||||||
|
await openLanguageMenu()
|
||||||
|
|
||||||
|
expect(document.activeElement?.textContent).toContain('French')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes on Escape', async () => {
|
||||||
|
await mountEditor()
|
||||||
|
await openLanguageMenu()
|
||||||
|
document.activeElement?.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||||
|
)
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[role="menu"]')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('A single-language site shows no language control', () => {
|
||||||
|
it('hides the control, names no language and reads Publish', async () => {
|
||||||
|
await mountEditor({
|
||||||
|
lang: 'en',
|
||||||
|
translations: ['en'],
|
||||||
|
languages: languageRows(['en']),
|
||||||
|
online: { en: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(languageTrigger()).toBeNull()
|
||||||
|
expect(buttonNamed('Publish')).toBeDefined()
|
||||||
|
expect(text()).not.toContain('English')
|
||||||
|
expect(titleInput().getAttribute('id')).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('On a phone the menu opens as a sheet', () => {
|
||||||
|
it('lists the same rows in a bottom sheet', async () => {
|
||||||
|
await mountEditor({ width: MOBILE_WIDTH, caps: EDIT_ONLY })
|
||||||
|
languageTrigger()!.click()
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(rowNames().map(languageOf)).toEqual(['English', 'Arabic', 'French'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -109,7 +109,7 @@ describe('Editor section: loading and not found', () => {
|
|||||||
backend.responders['GET /v1/article/12'] = failWith(404)
|
backend.responders['GET /v1/article/12'] = failWith(404)
|
||||||
await mountMeta('editor', EDITOR, { backend })
|
await mountMeta('editor', EDITOR, { backend })
|
||||||
|
|
||||||
expect(textOf(document.body)).toContain("This article doesn't exist")
|
expect(textOf(document.body)).toContain("This version isn't available")
|
||||||
expect(metaSection()).toBeNull()
|
expect(metaSection()).toBeNull()
|
||||||
expect(document.body.textContent).not.toContain('Site and authors')
|
expect(document.body.textContent).not.toContain('Site and authors')
|
||||||
})
|
})
|
||||||
@@ -454,12 +454,9 @@ describe('Editor section: the existing save behaviour treats the change like any
|
|||||||
saveButtonEl().click()
|
saveButtonEl().click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(JSON.parse(sessionStorage.getItem('renewcms:article-draft-meta:12')!)).toMatchObject(
|
const stored = JSON.parse(sessionStorage.getItem('renewcms:article-draft:1:12')!)
|
||||||
{
|
expect(stored.meta).toMatchObject({ site_id: 2 })
|
||||||
site_id: 2,
|
expect(stored.body).toBeUndefined()
|
||||||
},
|
|
||||||
)
|
|
||||||
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBeNull()
|
|
||||||
await unmountMeta()
|
await unmountMeta()
|
||||||
|
|
||||||
await mountMeta('editor', EDITOR, {
|
await mountMeta('editor', EDITOR, {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { initializeLocale } from '@/i18n'
|
||||||
|
import { callsTo, kit, settle, text, unmountRoute } from './localizationKit'
|
||||||
|
import {
|
||||||
|
EDIT_ONLY,
|
||||||
|
PUBLISH_ONLY,
|
||||||
|
READ,
|
||||||
|
buttonNamed,
|
||||||
|
mountEditor,
|
||||||
|
titleInput,
|
||||||
|
toolbar,
|
||||||
|
} from './editorKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
initializeLocale()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(unmountRoute)
|
||||||
|
|
||||||
|
function pressControlS() {
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyEditor() {
|
||||||
|
return document.body.querySelector('[role="textbox"][aria-multiline="true"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('loc.perm.readonly: A version without edit renders read-only', () => {
|
||||||
|
it('reads the banner, locks the title and body, hides the toolbar and every action', async () => {
|
||||||
|
await mountEditor({ caps: READ })
|
||||||
|
|
||||||
|
expect(text()).toContain("You can read this version. You can't edit it.")
|
||||||
|
expect(text()).toContain(
|
||||||
|
'The source reference and the versions you can read stay available.',
|
||||||
|
)
|
||||||
|
expect(titleInput().getAttribute('aria-readonly')).toBe('true')
|
||||||
|
expect(titleInput().readOnly).toBe(true)
|
||||||
|
expect(bodyEditor()?.getAttribute('aria-readonly')).toBe('true')
|
||||||
|
expect(toolbar()).toBeNull()
|
||||||
|
for (const label of [
|
||||||
|
'Save',
|
||||||
|
'Publish French',
|
||||||
|
'Unpublish French',
|
||||||
|
'Delete French version',
|
||||||
|
]) {
|
||||||
|
expect(buttonNamed(label)).toBeUndefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing on Ctrl+S', async () => {
|
||||||
|
await mountEditor({ caps: READ })
|
||||||
|
pressControlS()
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(0)
|
||||||
|
expect(kit.calls.some((call) => call.method === 'PATCH')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.perm.publishNoEdit: Publish without edit', () => {
|
||||||
|
it('reads the notice, keeps the fields read-only and offers Publish French', async () => {
|
||||||
|
await mountEditor({ caps: PUBLISH_ONLY })
|
||||||
|
|
||||||
|
expect(text()).toContain('You can publish this version but not edit it')
|
||||||
|
expect(text()).toContain('Fields are read-only. Publish stays available.')
|
||||||
|
expect(titleInput().readOnly).toBe(true)
|
||||||
|
expect(toolbar()).toBeNull()
|
||||||
|
expect(buttonNamed('Publish French')).toBeDefined()
|
||||||
|
expect(buttonNamed('Save')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.perm.editNoPublish: Edit without publish', () => {
|
||||||
|
it('reads the hint, offers Save and no Publish control', async () => {
|
||||||
|
await mountEditor({ caps: EDIT_ONLY })
|
||||||
|
|
||||||
|
expect(text()).toContain('Publishing needs the publish permission')
|
||||||
|
expect(text()).toContain('Saving keeps it as a draft.')
|
||||||
|
expect(buttonNamed('Save')).toBeDefined()
|
||||||
|
expect(buttonNamed('Publish French')).toBeUndefined()
|
||||||
|
expect(toolbar()).not.toBeNull()
|
||||||
|
expect(titleInput().readOnly).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { initializeLocale } from '@/i18n'
|
||||||
|
import {
|
||||||
|
WRITE_FAILS,
|
||||||
|
callsTo,
|
||||||
|
dialogBox,
|
||||||
|
dialogText,
|
||||||
|
failWith,
|
||||||
|
kit,
|
||||||
|
press,
|
||||||
|
settle,
|
||||||
|
text,
|
||||||
|
toastText,
|
||||||
|
typeInto,
|
||||||
|
unmountRoute,
|
||||||
|
} from './localizationKit'
|
||||||
|
import { articleOf, buttonNamed, editBody, mountEditor, titleInput } from './editorKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
const THREE = ['en', 'fr', 'de'] as const
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
initializeLocale()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(unmountRoute)
|
||||||
|
|
||||||
|
const saved = () => articleOf({ translations: [...THREE] })
|
||||||
|
const published = () => articleOf({ translations: [...THREE], online: { fr: true } })
|
||||||
|
|
||||||
|
function requests() {
|
||||||
|
return kit.calls
|
||||||
|
.filter((call) => call.method === 'PATCH' || call.method === 'DELETE')
|
||||||
|
.map((call) => `${call.method} ${call.path}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDialogFor(label: string) {
|
||||||
|
await press(label)
|
||||||
|
expect(dialogBox()).not.toBeNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('loc.l8.saveAndPublish: Save and publish saves first, then publishes only that version', () => {
|
||||||
|
function setup(extra: Record<string, unknown> = {}) {
|
||||||
|
return mountEditor({
|
||||||
|
translations: [...THREE],
|
||||||
|
extra: {
|
||||||
|
'PATCH /v1/article/12': saved(),
|
||||||
|
'PATCH /v1/article/12/publish': published(),
|
||||||
|
...extra,
|
||||||
|
} as never,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('explains the order in the confirmation dialog', async () => {
|
||||||
|
await setup()
|
||||||
|
await editBody()
|
||||||
|
await openDialogFor('Save and publish French')
|
||||||
|
|
||||||
|
expect(dialogText()).toContain('Save and publish French?')
|
||||||
|
expect(dialogText()).toContain(
|
||||||
|
"Your unsaved changes to the French version are saved first, then only the French version goes online. English and German aren't affected.",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends the save, then the publish, touches no other article and toasts', async () => {
|
||||||
|
await setup()
|
||||||
|
await editBody()
|
||||||
|
await openDialogFor('Save and publish French')
|
||||||
|
await press('Save and publish French', dialogBox()!)
|
||||||
|
|
||||||
|
expect(requests()).toEqual(['PATCH /v1/article/12', 'PATCH /v1/article/12/publish'])
|
||||||
|
await vi.waitFor(() => expect(toastText()).toContain('French published'))
|
||||||
|
expect(toastText()).toContain("Other versions weren't changed.")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l8.saveFailPublish: A failed save publishes nothing', () => {
|
||||||
|
it('toasts, shows the banner and the retry primary, and never calls publish', async () => {
|
||||||
|
await mountEditor({
|
||||||
|
translations: [...THREE],
|
||||||
|
extra: { 'PATCH /v1/article/12': WRITE_FAILS },
|
||||||
|
})
|
||||||
|
await editBody()
|
||||||
|
await openDialogFor('Save and publish French')
|
||||||
|
await press('Save and publish French', dialogBox()!)
|
||||||
|
|
||||||
|
expect(requests()).toEqual(['PATCH /v1/article/12'])
|
||||||
|
await vi.waitFor(() => expect(toastText()).toContain("Couldn't save French"))
|
||||||
|
expect(toastText()).toContain('Nothing was published. Your changes are kept. Try again.')
|
||||||
|
expect(text()).toContain(
|
||||||
|
'Your changes are kept here. Nothing was published. Try saving again.',
|
||||||
|
)
|
||||||
|
expect(buttonNamed('Try saving again')).toBeDefined()
|
||||||
|
expect(text()).toContain('Save failed')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l5.pubFail: A failed publish after a good save is not a failed save', () => {
|
||||||
|
async function failAfterSave() {
|
||||||
|
await mountEditor({
|
||||||
|
translations: [...THREE],
|
||||||
|
extra: {
|
||||||
|
'PATCH /v1/article/12': saved(),
|
||||||
|
'PATCH /v1/article/12/publish': failWith(500, 'Internal server error'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await editBody()
|
||||||
|
await openDialogFor('Save and publish French')
|
||||||
|
await press('Save and publish French', dialogBox()!)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('says the draft is saved and offers Retry publish', async () => {
|
||||||
|
await failAfterSave()
|
||||||
|
|
||||||
|
expect(text()).toContain('Saved as a draft. Publishing failed. Try publishing again.')
|
||||||
|
expect(text()).toContain('Your edits are saved. Only the publishing step failed.')
|
||||||
|
expect(text()).toContain('Saved')
|
||||||
|
expect(text()).not.toContain('Save failed')
|
||||||
|
expect(buttonNamed('Retry publish')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends only the publish request on Retry publish', async () => {
|
||||||
|
await failAfterSave()
|
||||||
|
await press('Retry publish')
|
||||||
|
|
||||||
|
expect(requests()).toEqual([
|
||||||
|
'PATCH /v1/article/12',
|
||||||
|
'PATCH /v1/article/12/publish',
|
||||||
|
'PATCH /v1/article/12/publish',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.lang.publishDisabled: Publishing is blocked in a disabled language', () => {
|
||||||
|
it('warns after a 422 language_disabled and keeps Save working', async () => {
|
||||||
|
await mountEditor({
|
||||||
|
translations: [...THREE],
|
||||||
|
extra: {
|
||||||
|
'PATCH /v1/article/12': saved(),
|
||||||
|
'PATCH /v1/article/12/publish': failWith(422, 'language_disabled'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await openDialogFor('Publish French')
|
||||||
|
await press('Publish French', dialogBox()!)
|
||||||
|
|
||||||
|
expect(text()).toContain('French is disabled on this site. Re-enable it to publish.')
|
||||||
|
expect(text()).toContain(
|
||||||
|
'You can still edit and save this version. Ask an owner to re-enable French.',
|
||||||
|
)
|
||||||
|
await typeInto(titleInput(), 'Couts reels')
|
||||||
|
await press('Save')
|
||||||
|
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the warning up front when the version language is disabled', async () => {
|
||||||
|
await mountEditor({ translations: [...THREE], languageEnabled: { fr: false } })
|
||||||
|
|
||||||
|
expect(text()).toContain('French is disabled on this site. Re-enable it to publish.')
|
||||||
|
expect((buttonNamed('Publish French') as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l8.publish, unpublish and fail: Publishing a clean version', () => {
|
||||||
|
it('publishes French only after confirming the dialog', async () => {
|
||||||
|
await mountEditor({
|
||||||
|
translations: [...THREE],
|
||||||
|
extra: { 'PATCH /v1/article/12/publish': published() },
|
||||||
|
})
|
||||||
|
await openDialogFor('Publish French')
|
||||||
|
|
||||||
|
expect(dialogText()).toContain('Publish French?')
|
||||||
|
expect(dialogText()).toContain(
|
||||||
|
"Only the French version goes online. English and German aren't affected.",
|
||||||
|
)
|
||||||
|
await press('Publish French', dialogBox()!)
|
||||||
|
expect(requests()).toEqual(['PATCH /v1/article/12/publish'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unpublishes French only after confirming the dialog', async () => {
|
||||||
|
await mountEditor({
|
||||||
|
translations: [...THREE],
|
||||||
|
online: { fr: true },
|
||||||
|
extra: { 'PATCH /v1/article/12/unpublish': saved() },
|
||||||
|
})
|
||||||
|
await openDialogFor('Unpublish French')
|
||||||
|
|
||||||
|
expect(dialogText()).toContain('Unpublish French?')
|
||||||
|
expect(dialogText()).toContain(
|
||||||
|
'The French version goes back to draft and stops being public. English stays online.',
|
||||||
|
)
|
||||||
|
await press('Unpublish French', dialogBox()!)
|
||||||
|
expect(requests()).toEqual(['PATCH /v1/article/12/unpublish'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toasts "Couldn\'t publish French" when a clean publish fails', async () => {
|
||||||
|
await mountEditor({
|
||||||
|
translations: [...THREE],
|
||||||
|
extra: { 'PATCH /v1/article/12/publish': WRITE_FAILS },
|
||||||
|
})
|
||||||
|
await openDialogFor('Publish French')
|
||||||
|
await press('Publish French', dialogBox()!)
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(toastText()).toContain("Couldn't publish French"))
|
||||||
|
expect(toastText()).toContain('Nothing changed. Try again.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l8.unpublishDirty: Unpublish never saves local edits', () => {
|
||||||
|
it('explains that edits stay, sends only /unpublish and keeps the state unsaved', async () => {
|
||||||
|
await mountEditor({
|
||||||
|
translations: [...THREE],
|
||||||
|
online: { fr: true },
|
||||||
|
extra: { 'PATCH /v1/article/12/unpublish': saved() },
|
||||||
|
})
|
||||||
|
await typeInto(titleInput(), 'Couts reels')
|
||||||
|
await openDialogFor('Unpublish French')
|
||||||
|
|
||||||
|
expect(dialogText()).toContain(
|
||||||
|
'The saved French version goes offline and becomes a draft. English stays online.',
|
||||||
|
)
|
||||||
|
expect(dialogText()).toContain('Your edits stay in the editor')
|
||||||
|
expect(dialogText()).toContain(
|
||||||
|
'Your unsaved changes are kept here. Nothing is saved when you unpublish. Save them yourself when you are ready.',
|
||||||
|
)
|
||||||
|
await press('Unpublish French', dialogBox()!)
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(requests()).toEqual(['PATCH /v1/article/12/unpublish'])
|
||||||
|
expect(titleInput().value).toBe('Couts reels')
|
||||||
|
expect(text()).toContain('Unsaved changes')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { initializeLocale } from '@/i18n'
|
||||||
|
import {
|
||||||
|
WRITE_FAILS,
|
||||||
|
failWith,
|
||||||
|
kit,
|
||||||
|
press,
|
||||||
|
settle,
|
||||||
|
text,
|
||||||
|
typeInto,
|
||||||
|
unmountRoute,
|
||||||
|
} from './localizationKit'
|
||||||
|
import { EDIT_ONLY, READ, articleOf, editBody, mountEditor, titleInput } from './editorKit'
|
||||||
|
import { removeStoredDraftsOf } from '@/lib/storedDraft'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
const KEY_FOR_7 = 'renewcms:article-draft:7:12'
|
||||||
|
const STORED = {
|
||||||
|
title: 'Stored title',
|
||||||
|
body: '<p>Stored body</p>',
|
||||||
|
mirrored_at: '2026-10-14T14:18:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
initializeLocale()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(unmountRoute)
|
||||||
|
|
||||||
|
function store(key = KEY_FOR_7) {
|
||||||
|
sessionStorage.setItem(key, JSON.stringify(STORED))
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorBody() {
|
||||||
|
return kit.wrapper!.findComponent({ name: 'ArticleEditor' }).props('modelValue')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('A 401 on save mirrors the draft for this user and article', () => {
|
||||||
|
it('stores the title and body under user 7 and article 12 with the mirror time', async () => {
|
||||||
|
await mountEditor({ extra: { 'PATCH /v1/article/12': failWith(401) } })
|
||||||
|
await typeInto(titleInput(), 'Couts reels')
|
||||||
|
await editBody('<p>Edited body</p>')
|
||||||
|
await press('Save')
|
||||||
|
|
||||||
|
const stored = JSON.parse(sessionStorage.getItem(KEY_FOR_7)!)
|
||||||
|
expect(stored).toMatchObject({ title: 'Couts reels', body: '<p>Edited body</p>' })
|
||||||
|
expect(new Date(stored.mirrored_at).getTime()).not.toBeNaN()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l11.restored: Restoring after login', () => {
|
||||||
|
it('shows the stored title and body, not the loaded ones, with the info alert and unsaved state', async () => {
|
||||||
|
store()
|
||||||
|
await mountEditor()
|
||||||
|
|
||||||
|
expect(titleInput().value).toBe('Stored title')
|
||||||
|
expect(editorBody()).toBe('<p>Stored body</p>')
|
||||||
|
expect(text()).toContain('Restored unsaved changes from 14:18')
|
||||||
|
expect(text()).toContain(
|
||||||
|
'You logged in again. Your French title and body were kept in this browser.',
|
||||||
|
)
|
||||||
|
expect(text()).toContain('Unsaved changes')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('A draft never leaks across accounts or articles', () => {
|
||||||
|
it('shows the loaded article unchanged for another account', async () => {
|
||||||
|
store()
|
||||||
|
await mountEditor({ userId: 8 })
|
||||||
|
|
||||||
|
expect(titleInput().value).toBe('Couts de renovation')
|
||||||
|
expect(text()).not.toContain('Restored unsaved changes')
|
||||||
|
expect(sessionStorage.getItem(KEY_FOR_7)).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores nothing on another article of the same account', async () => {
|
||||||
|
store()
|
||||||
|
await mountEditor({ lang: 'en' })
|
||||||
|
|
||||||
|
expect(titleInput().value).toBe('Renovation costs')
|
||||||
|
expect(text()).not.toContain('Restored unsaved changes')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('When the local draft is deleted', () => {
|
||||||
|
it('removes it after a save answers 200', async () => {
|
||||||
|
store()
|
||||||
|
await mountEditor({ extra: { 'PATCH /v1/article/12': articleOf({}) } })
|
||||||
|
await press('Save')
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem(KEY_FOR_7)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes it when GET answers 404', async () => {
|
||||||
|
store()
|
||||||
|
await mountEditor({ extra: { 'GET /v1/article/12': failWith(404, 'Article not found') } })
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem(KEY_FOR_7)).toBeNull()
|
||||||
|
expect(text()).toContain("This version isn't available")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes it and never renders it when the response has no edit capability', async () => {
|
||||||
|
store()
|
||||||
|
await mountEditor({ caps: READ })
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem(KEY_FOR_7)).toBeNull()
|
||||||
|
expect(titleInput().value).toBe('Couts de renovation')
|
||||||
|
expect(text()).not.toContain('Restored unsaved changes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes every draft of the user on an explicit logout', () => {
|
||||||
|
sessionStorage.setItem(KEY_FOR_7, '{}')
|
||||||
|
sessionStorage.setItem('renewcms:article-draft:7:30', '{}')
|
||||||
|
sessionStorage.setItem('renewcms:article-draft:8:12', '{}')
|
||||||
|
removeStoredDraftsOf(7)
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem(KEY_FOR_7)).toBeNull()
|
||||||
|
expect(sessionStorage.getItem('renewcms:article-draft:7:30')).toBeNull()
|
||||||
|
expect(sessionStorage.getItem('renewcms:article-draft:8:12')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps it when a save fails with an ordinary error', async () => {
|
||||||
|
store()
|
||||||
|
await mountEditor({ caps: EDIT_ONLY, extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
|
||||||
|
await press('Save')
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem(KEY_FOR_7)).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -152,8 +152,10 @@ describe('Saving state', () => {
|
|||||||
expect(fieldByLabel(panel, 'Meta title')!.className).toContain('disabled:opacity-100')
|
expect(fieldByLabel(panel, 'Meta title')!.className).toContain('disabled:opacity-100')
|
||||||
expect(fieldByLabel(panel, 'Meta title')!.className).not.toContain('opacity-40')
|
expect(fieldByLabel(panel, 'Meta title')!.className).not.toContain('opacity-40')
|
||||||
expect(
|
expect(
|
||||||
textOf(document.body.querySelector('[data-testid="save-button"]')!.parentElement),
|
document.body
|
||||||
).toContain('Save')
|
.querySelector('[data-testid="primary-action"]')!
|
||||||
|
.getAttribute('aria-busy'),
|
||||||
|
).toBe('true')
|
||||||
expect(document.body.textContent).toContain('Saving…')
|
expect(document.body.textContent).toContain('Saving…')
|
||||||
gate.resolve({})
|
gate.resolve({})
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -212,7 +214,7 @@ describe('Save failed', () => {
|
|||||||
expect(fieldByLabel(panel, 'Meta title')!.disabled).toBe(false)
|
expect(fieldByLabel(panel, 'Meta title')!.disabled).toBe(false)
|
||||||
expect(saveChangesButton(panel).disabled).toBe(false)
|
expect(saveChangesButton(panel).disabled).toBe(false)
|
||||||
expect(document.body.textContent).toContain('Save failed')
|
expect(document.body.textContent).toContain('Save failed')
|
||||||
expect(document.body.textContent).toContain('Retry')
|
expect(document.body.textContent).toContain('Try saving again')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { typeInto } from './directoryTestKit'
|
|||||||
import { oneSite } from './editorMetaKit'
|
import { oneSite } from './editorMetaKit'
|
||||||
import { fieldByLabel, openPanel, panelRoot, saveChangesButton } from './seoTestKit'
|
import { fieldByLabel, openPanel, panelRoot, saveChangesButton } from './seoTestKit'
|
||||||
import { ownerAccess } from '@/test/access'
|
import { ownerAccess } from '@/test/access'
|
||||||
|
import type { ArticleCapability } from '@/lib/articleTypes'
|
||||||
|
|
||||||
function reply(status: number, body: unknown = null, text = '') {
|
function reply(status: number, body: unknown = null, text = '') {
|
||||||
return {
|
return {
|
||||||
@@ -26,6 +27,7 @@ const article = {
|
|||||||
title: 'How we price a bathroom renovation',
|
title: 'How we price a bathroom renovation',
|
||||||
body: '<p>Intro</p>',
|
body: '<p>Intro</p>',
|
||||||
is_online: false,
|
is_online: false,
|
||||||
|
capabilities: ['read', 'edit', 'publish', 'delete'] as ArticleCapability[],
|
||||||
site_id: 1,
|
site_id: 1,
|
||||||
authors: [{ id: 2, name: 'Inès Duarte', slug: 'ines-duarte' }],
|
authors: [{ id: 2, name: 'Inès Duarte', slug: 'ines-duarte' }],
|
||||||
slug: 'how-we-price-a-bathroom-renovation',
|
slug: 'how-we-price-a-bathroom-renovation',
|
||||||
@@ -71,7 +73,7 @@ async function mountEditor() {
|
|||||||
setViewportWidth(DESKTOP_WIDTH)
|
setViewportWidth(DESKTOP_WIDTH)
|
||||||
Element.prototype.scrollIntoView = vi.fn()
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
session.session = { id: 1, verified: true, access: ownerAccess(), username: 'marta' }
|
||||||
session.status = 'ready'
|
session.status = 'ready'
|
||||||
await router.push('/article/12/edit')
|
await router.push('/article/12/edit')
|
||||||
wrapper = mount(RootComponent, {
|
wrapper = mount(RootComponent, {
|
||||||
@@ -112,7 +114,7 @@ describe('A session problem during the check follows the existing rules', () =>
|
|||||||
|
|
||||||
await mountEditor()
|
await mountEditor()
|
||||||
|
|
||||||
expect(document.body.textContent).toContain("This article doesn't exist.")
|
expect(document.body.textContent).toContain("This version isn't available")
|
||||||
expect(document.body.querySelector('[data-testid="seo-section"]')).toBeNull()
|
expect(document.body.querySelector('[data-testid="seo-section"]')).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -129,7 +131,7 @@ describe('Session expiry keeps the SEO draft', () => {
|
|||||||
|
|
||||||
expect(router.currentRoute.value.path).toBe('/login')
|
expect(router.currentRoute.value.path).toBe('/login')
|
||||||
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
|
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
|
||||||
const mirrored = JSON.parse(sessionStorage.getItem('renewcms:article-draft-seo:12')!)
|
const mirrored = JSON.parse(sessionStorage.getItem('renewcms:article-draft:1:12')!).seo
|
||||||
expect(mirrored.seo_title).toBe('A title kept across the login')
|
expect(mirrored.seo_title).toBe('A title kept across the login')
|
||||||
|
|
||||||
wrapper?.unmount()
|
wrapper?.unmount()
|
||||||
|
|||||||
@@ -332,6 +332,6 @@ describe('The check could not run', () => {
|
|||||||
backend.responders['POST /v1/article/12/seo/check'] = failWith(404)
|
backend.responders['POST /v1/article/12/seo/check'] = failWith(404)
|
||||||
await mountSeo(backend)
|
await mountSeo(backend)
|
||||||
|
|
||||||
expect(document.body.textContent).toContain("This article doesn't exist.")
|
expect(document.body.textContent).toContain("This version isn't available")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { initializeLocale } from '@/i18n'
|
||||||
|
import {
|
||||||
|
WRITE_FAILS,
|
||||||
|
callsTo,
|
||||||
|
deferred,
|
||||||
|
failWith,
|
||||||
|
press,
|
||||||
|
settle,
|
||||||
|
text,
|
||||||
|
typeInto,
|
||||||
|
unmountRoute,
|
||||||
|
} from './localizationKit'
|
||||||
|
import {
|
||||||
|
TITLES,
|
||||||
|
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 HELPER = "Changing the title doesn't change the address."
|
||||||
|
const TITLE_REJECTED = 'Article title must be between 3 and 200 characters.'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
initializeLocale()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(unmountRoute)
|
||||||
|
|
||||||
|
function helper() {
|
||||||
|
return document.getElementById(titleInput().getAttribute('aria-describedby')!)
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEnabled() {
|
||||||
|
return !(buttonNamed('Save') as HTMLButtonElement | undefined)?.disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
function primaryDisabled() {
|
||||||
|
return (document.body.querySelector('[data-testid="primary-action"]') as HTMLButtonElement)
|
||||||
|
.disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusField() {
|
||||||
|
titleInput().focus()
|
||||||
|
titleInput().dispatchEvent(new FocusEvent('focus'))
|
||||||
|
await settle()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('loc.l5.title: The title replaces the heading and the old tooltip', () => {
|
||||||
|
it('is a text field labelled "Title (French)" holding the title', async () => {
|
||||||
|
await mountEditor()
|
||||||
|
|
||||||
|
expect(titleInput().tagName).toBe('TEXTAREA')
|
||||||
|
expect(titleInput().value).toBe(TITLES.fr)
|
||||||
|
expect(document.body.querySelector('label')?.textContent).toBe('Title (French)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no tooltip about the title being fixed anywhere', async () => {
|
||||||
|
await mountEditor()
|
||||||
|
|
||||||
|
expect(document.body.innerHTML).not.toContain("The title can't be changed after creation.")
|
||||||
|
expect(document.body.querySelector('[title]')?.getAttribute('title') ?? '').not.toContain(
|
||||||
|
'title',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads "Title" on a single-language site', async () => {
|
||||||
|
await mountEditor({
|
||||||
|
lang: 'en',
|
||||||
|
translations: ['en'],
|
||||||
|
languages: [{ lang: 'en', versions: 1, published: 1 }],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(document.body.querySelector('label')?.textContent).toBe('Title')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Title field states', () => {
|
||||||
|
it('loc.l5.default: is neutral with no helper and Save disabled', async () => {
|
||||||
|
await mountEditor()
|
||||||
|
|
||||||
|
expect(helper()?.textContent?.trim()).toBe('')
|
||||||
|
expect(titleInput().getAttribute('aria-invalid')).toBeNull()
|
||||||
|
expect(saveEnabled()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loc.l5.focus: shows the address note with Save still disabled', async () => {
|
||||||
|
await mountEditor()
|
||||||
|
await focusField()
|
||||||
|
|
||||||
|
expect(helper()?.textContent).toContain(HELPER)
|
||||||
|
expect(saveEnabled()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loc.l5.titleEdit: shows the counter "11 / 200" and enables Save while typing', async () => {
|
||||||
|
await mountEditor()
|
||||||
|
await focusField()
|
||||||
|
await typeInto(titleInput(), 'Couts reels')
|
||||||
|
|
||||||
|
expect(text()).toContain('11 / 200')
|
||||||
|
expect(helper()?.textContent).toContain(HELPER)
|
||||||
|
expect(saveEnabled()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loc.l5.titleUnsaved: reads Unsaved changes once focus leaves, Save stays enabled', async () => {
|
||||||
|
await mountEditor()
|
||||||
|
await focusField()
|
||||||
|
await typeInto(titleInput(), 'Couts reels')
|
||||||
|
titleInput().dispatchEvent(new FocusEvent('blur'))
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(text()).toContain('Unsaved changes')
|
||||||
|
expect(helper()?.textContent).toContain(HELPER)
|
||||||
|
expect(saveEnabled()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loc.l5.invalid: flags an empty title as an alert and blocks Save', async () => {
|
||||||
|
await mountEditor()
|
||||||
|
await typeInto(titleInput(), '')
|
||||||
|
|
||||||
|
expect(titleInput().getAttribute('aria-invalid')).toBe('true')
|
||||||
|
expect(helper()?.getAttribute('role')).toBe('alert')
|
||||||
|
expect(helper()?.textContent).toContain('Enter a title')
|
||||||
|
expect(primaryDisabled()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loc.l5.titleShort: flags two characters with the counter "2 / 200"', async () => {
|
||||||
|
await mountEditor()
|
||||||
|
await typeInto(titleInput(), 'ab')
|
||||||
|
|
||||||
|
expect(helper()?.textContent).toContain('Use at least 3 characters')
|
||||||
|
expect(text()).toContain('2 / 200')
|
||||||
|
expect(primaryDisabled()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loc.l5.titleLong: flags 203 characters with the counter "203 / 200"', async () => {
|
||||||
|
await mountEditor()
|
||||||
|
await typeInto(titleInput(), 'a'.repeat(203))
|
||||||
|
|
||||||
|
expect(helper()?.textContent).toContain('Use 200 characters or fewer')
|
||||||
|
expect(text()).toContain('203 / 200')
|
||||||
|
expect(primaryDisabled()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts code points: 200 four-byte emoji are valid', async () => {
|
||||||
|
await mountEditor()
|
||||||
|
await focusField()
|
||||||
|
await typeInto(titleInput(), '😀'.repeat(200))
|
||||||
|
|
||||||
|
expect(text()).toContain('200 / 200')
|
||||||
|
expect(titleInput().getAttribute('aria-invalid')).toBeNull()
|
||||||
|
expect(primaryDisabled()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l5.titleSaving: Saving the title sends it with the other edits', () => {
|
||||||
|
it('sends the title and the body in one PATCH, with no slug', async () => {
|
||||||
|
await mountEditor({
|
||||||
|
extra: { 'PATCH /v1/article/12': { ...articleOf({}), title: 'Couts reels' } },
|
||||||
|
})
|
||||||
|
await typeInto(titleInput(), 'Couts reels')
|
||||||
|
await editBody('<p>New body</p>')
|
||||||
|
await press('Save')
|
||||||
|
|
||||||
|
const calls = callsTo('PATCH', '/v1/article/12')
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
expect(calls[0]!.body).toEqual({ title: 'Couts reels', body: '<p>New body</p>' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a spinner and "Saving title…" while in flight', async () => {
|
||||||
|
const gate = deferred<unknown>()
|
||||||
|
await mountEditor({ extra: { 'PATCH /v1/article/12': () => gate.promise } })
|
||||||
|
await typeInto(titleInput(), 'Couts reels')
|
||||||
|
await press('Save')
|
||||||
|
|
||||||
|
expect(helper()?.textContent).toContain('Saving title…')
|
||||||
|
gate.resolve({ ...articleOf({}), title: 'Couts reels' })
|
||||||
|
await settle()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates the breadcrumb and the menu row on 200', async () => {
|
||||||
|
await mountEditor({
|
||||||
|
extra: { 'PATCH /v1/article/12': { ...articleOf({}), title: 'Couts reels' } },
|
||||||
|
})
|
||||||
|
await typeInto(titleInput(), 'Couts reels')
|
||||||
|
await press('Save')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('nav[aria-label="breadcrumb" i]')?.textContent,
|
||||||
|
).toContain('Couts reels')
|
||||||
|
await openLanguageMenu()
|
||||||
|
expect(rowFor('French')?.textContent).toContain('Couts reels')
|
||||||
|
expect(titleInput().value).toBe('Couts reels')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l5.failed: A failed title save keeps the text', () => {
|
||||||
|
it('keeps the text, shows the helper and a Try again action, and still reads unsaved', async () => {
|
||||||
|
await mountEditor({ extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
|
||||||
|
await typeInto(titleInput(), 'Couts reels')
|
||||||
|
await press('Save')
|
||||||
|
|
||||||
|
expect(titleInput().value).toBe('Couts reels')
|
||||||
|
expect(titleInput().getAttribute('aria-invalid')).toBe('true')
|
||||||
|
expect(helper()?.textContent).toContain("Couldn't save the title. Your text is kept.")
|
||||||
|
expect(buttonNamed('Try again')).toBeDefined()
|
||||||
|
expect(text()).toContain('Save failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resends the same PATCH on Try again and returns focus to the field', async () => {
|
||||||
|
await mountEditor({ extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
|
||||||
|
await typeInto(titleInput(), 'Couts reels')
|
||||||
|
await press('Save')
|
||||||
|
await press('Try again')
|
||||||
|
|
||||||
|
const calls = callsTo('PATCH', '/v1/article/12')
|
||||||
|
expect(calls).toHaveLength(2)
|
||||||
|
expect(calls[1]!.body).toEqual(calls[0]!.body)
|
||||||
|
expect(document.activeElement).toBe(titleInput())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l5.invalid: The server rejects a title the client allowed', () => {
|
||||||
|
it('shows the plain-text message in the invalid state and keeps the edit', async () => {
|
||||||
|
await mountEditor({ extra: { 'PATCH /v1/article/12': failWith(400, TITLE_REJECTED) } })
|
||||||
|
await typeInto(titleInput(), 'Couts reels')
|
||||||
|
await press('Save')
|
||||||
|
|
||||||
|
expect(helper()?.textContent).toContain(TITLE_REJECTED)
|
||||||
|
expect(titleInput().getAttribute('aria-invalid')).toBe('true')
|
||||||
|
expect(titleInput().value).toBe('Couts reels')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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,
|
||||||
|
deferred,
|
||||||
|
dialogBox,
|
||||||
|
dialogText,
|
||||||
|
failWith,
|
||||||
|
press,
|
||||||
|
settle,
|
||||||
|
text,
|
||||||
|
toastText,
|
||||||
|
typeInto,
|
||||||
|
unmountRoute,
|
||||||
|
} from './localizationKit'
|
||||||
|
import {
|
||||||
|
TITLES,
|
||||||
|
articleOf,
|
||||||
|
buttonNamed,
|
||||||
|
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 DRAFT_KEY = 'renewcms:article-draft:7:12'
|
||||||
|
const SKELETON_DELAY_MS = 150
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
initializeLocale()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
await unmountRoute()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function chooseEnglish() {
|
||||||
|
await openLanguageMenu()
|
||||||
|
rowFor('English')!.click()
|
||||||
|
await settle()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editFrenchTitle() {
|
||||||
|
await typeInto(titleInput(), 'Couts reels')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('loc.l5.menu: Switching from a clean editor', () => {
|
||||||
|
it('requests the other version before the route changes, then shows its title', async () => {
|
||||||
|
let routeWhenRequested = ''
|
||||||
|
await mountEditor({
|
||||||
|
extra: {
|
||||||
|
'GET /v1/article/10': () => {
|
||||||
|
routeWhenRequested = router.currentRoute.value.path
|
||||||
|
return articleOf({ lang: 'fr' }, 'en')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await chooseEnglish()
|
||||||
|
|
||||||
|
expect(routeWhenRequested).toBe('/article/12/edit')
|
||||||
|
expect(router.currentRoute.value.path).toBe('/article/10/edit')
|
||||||
|
expect(titleInput().value).toBe(TITLES.en)
|
||||||
|
expect(callsTo('GET', '/v1/article/10')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('announces "Editing English version" politely', async () => {
|
||||||
|
await mountEditor()
|
||||||
|
await chooseEnglish()
|
||||||
|
|
||||||
|
const live = document.body.querySelector('[data-testid="live-announcement"]')
|
||||||
|
expect(live?.textContent).toBe('Editing English version')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l5.loading: A slow switch shows a loading state', () => {
|
||||||
|
it('shows the loading version state after 150 ms and keeps the current version on screen', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||||
|
const gate = deferred<unknown>()
|
||||||
|
await mountEditor({ extra: { 'GET /v1/article/10': () => gate.promise } })
|
||||||
|
await chooseEnglish()
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[data-testid="skeleton-content"]')).toBeNull()
|
||||||
|
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
|
||||||
|
await settle()
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[data-testid="skeleton-content"]')).not.toBeNull()
|
||||||
|
expect(titleInput().value).toBe(TITLES.fr)
|
||||||
|
expect(router.currentRoute.value.path).toBe('/article/12/edit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never shows a loading state when the response is faster than 150 ms', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||||
|
await mountEditor()
|
||||||
|
await chooseEnglish()
|
||||||
|
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS * 2)
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[data-testid="skeleton-content"]')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l5.navFail: A failed switch keeps the current version', () => {
|
||||||
|
it.each([
|
||||||
|
['a 500', failWith(500, 'Internal server error')],
|
||||||
|
[
|
||||||
|
'a network failure',
|
||||||
|
() => {
|
||||||
|
throw new NetworkError()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])('toasts and stays on French after %s', async (_name, failure) => {
|
||||||
|
await mountEditor({ extra: { 'GET /v1/article/10': failure } })
|
||||||
|
await chooseEnglish()
|
||||||
|
await vi.waitFor(() => expect(toastText()).toContain("Couldn't open the English version"))
|
||||||
|
|
||||||
|
expect(toastText()).toContain("You're still editing French. Try again.")
|
||||||
|
expect(router.currentRoute.value.path).toBe('/article/12/edit')
|
||||||
|
expect(titleInput().value).toBe(TITLES.fr)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l5.gone: The editor opened directly on an unavailable version', () => {
|
||||||
|
it('shows only the Version unavailable state with one action to Articles', async () => {
|
||||||
|
await mountEditor({ extra: { 'GET /v1/article/12': failWith(404, 'Article not found') } })
|
||||||
|
|
||||||
|
expect(text()).toContain("This version isn't available")
|
||||||
|
expect(text()).toContain('It may have been deleted, or you may not have access.')
|
||||||
|
expect(document.body.querySelector('label')).toBeNull()
|
||||||
|
expect(buttonNamed('Save')).toBeUndefined()
|
||||||
|
buttonNamed('Back to Articles')!.click()
|
||||||
|
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loc.l5.unsaved: Choosing another version with unsaved changes asks first', () => {
|
||||||
|
async function openDialog() {
|
||||||
|
await mountEditor()
|
||||||
|
await editFrenchTitle()
|
||||||
|
await chooseEnglish()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('asks with the exact copy and the three buttons in order', async () => {
|
||||||
|
await openDialog()
|
||||||
|
|
||||||
|
expect(dialogText()).toContain('Leave without saving?')
|
||||||
|
expect(dialogText()).toContain("Your changes to the French version aren't saved.")
|
||||||
|
const labels = Array.from(dialogBox()!.querySelectorAll('button'))
|
||||||
|
.map((button) => button.textContent?.trim())
|
||||||
|
.filter((label) => label)
|
||||||
|
expect(labels).toEqual([
|
||||||
|
'Keep editing',
|
||||||
|
'Discard and open English',
|
||||||
|
'Save and open English',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Keep editing closes the dialog, keeps the title and sends nothing', async () => {
|
||||||
|
await openDialog()
|
||||||
|
await press('Keep editing', dialogBox()!)
|
||||||
|
|
||||||
|
expect(dialogBox()).toBeNull()
|
||||||
|
expect(router.currentRoute.value.path).toBe('/article/12/edit')
|
||||||
|
expect(titleInput().value).toBe('Couts reels')
|
||||||
|
expect(callsTo('GET', '/v1/article/10')).toHaveLength(0)
|
||||||
|
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Discard and open English removes the local draft and saves nothing', async () => {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
DRAFT_KEY,
|
||||||
|
JSON.stringify({ title: 'Kept', mirrored_at: '2026-10-14T14:18:00Z' }),
|
||||||
|
)
|
||||||
|
await mountEditor()
|
||||||
|
await chooseEnglish()
|
||||||
|
await press('Discard and open English', dialogBox()!)
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem(DRAFT_KEY)).toBeNull()
|
||||||
|
expect(router.currentRoute.value.path).toBe('/article/10/edit')
|
||||||
|
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Save and open English saves first, then opens the version', async () => {
|
||||||
|
await mountEditor({
|
||||||
|
extra: { 'PATCH /v1/article/12': { ...articleOf({}), title: 'Couts reels' } },
|
||||||
|
})
|
||||||
|
await editFrenchTitle()
|
||||||
|
await chooseEnglish()
|
||||||
|
await press('Save and open English', dialogBox()!)
|
||||||
|
|
||||||
|
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(1)
|
||||||
|
expect(router.currentRoute.value.path).toBe('/article/10/edit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops when the save fails: no request for article 10 and Save failed shows', async () => {
|
||||||
|
await mountEditor({ extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
|
||||||
|
await editFrenchTitle()
|
||||||
|
await chooseEnglish()
|
||||||
|
await press('Save and open English', dialogBox()!)
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.path).toBe('/article/12/edit')
|
||||||
|
expect(callsTo('GET', '/v1/article/10')).toHaveLength(0)
|
||||||
|
expect(text()).toContain('Save failed')
|
||||||
|
expect(titleInput().value).toBe('Couts reels')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,6 +10,7 @@ import ArticleEditor from '@/components/ArticleEditor.vue'
|
|||||||
import RootComponent from '@/App.vue'
|
import RootComponent from '@/App.vue'
|
||||||
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
|
||||||
import { ownerAccess } from '@/test/access'
|
import { ownerAccess } from '@/test/access'
|
||||||
|
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||||
|
|
||||||
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
const FIXED_NOW = '2026-09-26T11:02:00Z'
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ async function mountEditor(
|
|||||||
overlayMock = createOverlayMock()
|
overlayMock = createOverlayMock()
|
||||||
toastAdd = vi.fn()
|
toastAdd = vi.fn()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
session.session = { id: 7, verified: true, access: ownerAccess(), username: 'marta' }
|
||||||
session.status = 'ready'
|
session.status = 'ready'
|
||||||
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
|
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
|
||||||
await router.push(path)
|
await router.push(path)
|
||||||
@@ -135,6 +136,7 @@ function paragraphOfLength(totalLength: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
setViewportWidth(DESKTOP_WIDTH)
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
vi.setSystemTime(new Date(FIXED_NOW))
|
vi.setSystemTime(new Date(FIXED_NOW))
|
||||||
})
|
})
|
||||||
@@ -194,7 +196,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
|
|||||||
describe('Saved — a freshly loaded article, draft or online', () => {
|
describe('Saved — a freshly loaded article, draft or online', () => {
|
||||||
it.each([
|
it.each([
|
||||||
{ is_online: false, badge: 'Draft', publishLabel: 'Publish' },
|
{ is_online: false, badge: 'Draft', publishLabel: 'Publish' },
|
||||||
{ is_online: true, badge: 'Online', publishLabel: 'Unpublish' },
|
{ is_online: true, badge: 'Published', publishLabel: 'Unpublish' },
|
||||||
])(
|
])(
|
||||||
'reads the Saved state for is_online $is_online',
|
'reads the Saved state for is_online $is_online',
|
||||||
async ({ is_online, badge, publishLabel }) => {
|
async ({ is_online, badge, publishLabel }) => {
|
||||||
@@ -251,7 +253,8 @@ describe('Saving — Save sends the PATCH and disables itself while in flight',
|
|||||||
body: paragraphOfLength(4320),
|
body: paragraphOfLength(4320),
|
||||||
})
|
})
|
||||||
expect(wrapper.text()).toContain('Saving…')
|
expect(wrapper.text()).toContain('Saving…')
|
||||||
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
expect(saveButton(wrapper).exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-testid="primary-action"]').attributes('aria-busy')).toBe('true')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -328,14 +331,14 @@ describe('SaveStatus switches to a relative time after an hour', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Save failed — a 500 or a network failure keeps the changes and offers Retry', () => {
|
describe('Save failed — a 500 or a network failure keeps the changes and offers Try saving again', () => {
|
||||||
it.each([
|
it.each([
|
||||||
{ name: 'a 500 response', handler: () => mockResponse(500) },
|
{ name: 'a 500 response', handler: () => mockResponse(500) },
|
||||||
{
|
{
|
||||||
name: 'a network failure',
|
name: 'a network failure',
|
||||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||||
},
|
},
|
||||||
])('shows Save failed with Retry for $name', async ({ handler }) => {
|
])('shows Save failed with Try saving again for $name', async ({ handler }) => {
|
||||||
const wrapper = await mountEditor({
|
const wrapper = await mountEditor({
|
||||||
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
|
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
|
||||||
'PATCH /v1/article/12?format=html': handler,
|
'PATCH /v1/article/12?format=html': handler,
|
||||||
@@ -345,12 +348,14 @@ describe('Save failed — a 500 or a network failure keeps the changes and offer
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Save failed')
|
expect(wrapper.text()).toContain('Save failed')
|
||||||
expect(wrapper.findAll('button').some((button) => button.text() === 'Retry')).toBe(true)
|
expect(
|
||||||
|
wrapper.findAll('button').some((button) => button.text() === 'Try saving again'),
|
||||||
|
).toBe(true)
|
||||||
expect(articleEditorBody(wrapper)).toBe(paragraph('Revised opening paragraph.'))
|
expect(articleEditorBody(wrapper)).toBe(paragraph('Revised opening paragraph.'))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Retry resends the same body and can recover', () => {
|
describe('Try saving again resends the same body and can recover', () => {
|
||||||
it('resends the same PATCH and shows Saved at the current time', async () => {
|
it('resends the same PATCH and shows Saved at the current time', async () => {
|
||||||
const patchCall = vi
|
const patchCall = vi
|
||||||
.fn()
|
.fn()
|
||||||
@@ -371,7 +376,9 @@ describe('Retry resends the same body and can recover', () => {
|
|||||||
await saveButton(wrapper).trigger('click')
|
await saveButton(wrapper).trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const retryButton = wrapper.findAll('button').find((button) => button.text() === 'Retry')
|
const retryButton = wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find((button) => button.text() === 'Try saving again')
|
||||||
await retryButton?.trigger('click')
|
await retryButton?.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -448,7 +455,7 @@ describe('Very long article — no length limit', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Saving 120,000 characters. This can take a few seconds.')
|
expect(wrapper.text()).toContain('Saving 120,000 characters. This can take a few seconds.')
|
||||||
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
expect(saveButton(wrapper).exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reads Saved once the PATCH resolves', async () => {
|
it('reads Saved once the PATCH resolves', async () => {
|
||||||
@@ -601,6 +608,29 @@ describe('Esc or the overlay on the leave guard behaves like "Keep editing"', ()
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Leaving after a failed save still prompts', () => {
|
||||||
|
it('opens the leave dialog while Save failed is showing', async () => {
|
||||||
|
const wrapper = await mountEditor({
|
||||||
|
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
|
||||||
|
'PATCH /v1/article/12?format=html': () => mockResponse(500),
|
||||||
|
})
|
||||||
|
await typeBody(wrapper, paragraph('Revised opening paragraph.'))
|
||||||
|
await saveButton(wrapper).trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('Save failed')
|
||||||
|
|
||||||
|
const navigation = router.push('/article')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect((overlayMock.getProps() as Record<string, unknown>).title).toBe(
|
||||||
|
'Leave without saving?',
|
||||||
|
)
|
||||||
|
overlayMock.resolveOpen(true)
|
||||||
|
await navigation
|
||||||
|
expect(router.currentRoute.value.path).toBe('/article/12/edit')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('Leaving a clean article never prompts', () => {
|
describe('Leaving a clean article never prompts', () => {
|
||||||
it('navigates directly with no ConfirmDialog', async () => {
|
it('navigates directly with no ConfirmDialog', async () => {
|
||||||
const wrapper = await mountEditor({
|
const wrapper = await mountEditor({
|
||||||
@@ -627,13 +657,15 @@ describe('Session expired mid-save keeps the draft and restores it after logging
|
|||||||
await saveButton(wrapper).trigger('click')
|
await saveButton(wrapper).trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBe(body)
|
expect(JSON.parse(sessionStorage.getItem('renewcms:article-draft:7:12') ?? '{}').body).toBe(
|
||||||
|
body,
|
||||||
|
)
|
||||||
expect(router.currentRoute.value.path).toBe('/login')
|
expect(router.currentRoute.value.path).toBe('/login')
|
||||||
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
|
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
|
||||||
expect(router.currentRoute.value.query.reason).toBe('expired')
|
expect(router.currentRoute.value.query.reason).toBe('expired')
|
||||||
|
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
session.session = { id: 7, verified: true, access: ownerAccess(), username: 'marta' }
|
||||||
session.status = 'ready'
|
session.status = 'ready'
|
||||||
global.fetch = fetchRouter({
|
global.fetch = fetchRouter({
|
||||||
'GET /v1/article/12?format=html': () =>
|
'GET /v1/article/12?format=html': () =>
|
||||||
@@ -797,13 +829,13 @@ describe('An unknown or deleted article id replaces the editor with an error sta
|
|||||||
'/article/9999/edit',
|
'/article/9999/edit',
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(wrapper.text()).toContain("This article doesn't exist.")
|
expect(wrapper.text()).toContain("This version isn't available")
|
||||||
expect(wrapper.find('[data-testid="save-button"]').exists()).toBe(false)
|
expect(wrapper.find('[data-testid="save-button"]').exists()).toBe(false)
|
||||||
expect(wrapper.text()).not.toContain('Publish')
|
expect(wrapper.text()).not.toContain('Publish')
|
||||||
|
|
||||||
const allArticlesButton = wrapper
|
const allArticlesButton = wrapper
|
||||||
.findAll('button')
|
.findAll('button')
|
||||||
.find((button) => button.text() === 'All articles')
|
.find((button) => button.text() === 'Back to Articles')
|
||||||
await allArticlesButton?.trigger('click')
|
await allArticlesButton?.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ describe('Dismiss x: behaviour', () => {
|
|||||||
await openPanel()
|
await openPanel()
|
||||||
await dismiss(0)
|
await dismiss(0)
|
||||||
|
|
||||||
expect(textOf(document.body)).toContain("This article doesn't exist.")
|
expect(textOf(document.body)).toContain("This version isn't available")
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows nothing and sends no check on a 401', async () => {
|
it('shows nothing and sends no check on a 401', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import RootComponent from '@/App.vue'
|
||||||
|
import { DESKTOP_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import type { ArticleCapability } from '@/composables/useArticles'
|
||||||
|
import { storyEnvelope } from './articleEnvelope'
|
||||||
|
import { languagesBody, type LanguageFixture } from './localizationData'
|
||||||
|
import { kit, mountRoute, settle, text, type Route } from './localizationKit'
|
||||||
|
|
||||||
|
export const FULL: ArticleCapability[] = ['read', 'edit', 'publish', 'delete', 'create_translation']
|
||||||
|
export const READ: ArticleCapability[] = ['read']
|
||||||
|
export const EDIT_ONLY: ArticleCapability[] = ['read', 'edit']
|
||||||
|
export const PUBLISH_ONLY: ArticleCapability[] = ['read', 'publish']
|
||||||
|
|
||||||
|
export type Lang = 'en' | 'fr' | 'ar' | 'de'
|
||||||
|
|
||||||
|
export const IDS: Record<Lang, number> = { en: 10, fr: 12, ar: 13, de: 11 }
|
||||||
|
export const TITLES: Record<Lang, string> = {
|
||||||
|
en: 'Renovation costs',
|
||||||
|
fr: 'Couts de renovation',
|
||||||
|
ar: 'Takalif',
|
||||||
|
de: 'Renovierungskosten',
|
||||||
|
}
|
||||||
|
const ONLINE: Record<Lang, boolean> = { en: true, fr: false, ar: true, de: false }
|
||||||
|
const UPDATED_AT = '2026-09-26T11:02:00Z'
|
||||||
|
const TRANSLATION_ORDER: Lang[] = ['en', 'ar', 'fr']
|
||||||
|
|
||||||
|
export interface EditorFixture {
|
||||||
|
lang?: Lang
|
||||||
|
caps?: ArticleCapability[]
|
||||||
|
siblingCaps?: Partial<Record<Lang, ArticleCapability[]>>
|
||||||
|
online?: Partial<Record<Lang, boolean>>
|
||||||
|
languageEnabled?: Partial<Record<Lang, boolean>>
|
||||||
|
translations?: Lang[]
|
||||||
|
body?: string
|
||||||
|
languages?: LanguageFixture[]
|
||||||
|
extra?: Record<string, Route>
|
||||||
|
userId?: number
|
||||||
|
width?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const JOURNAL_LANGUAGES: LanguageFixture[] = [
|
||||||
|
{ lang: 'en', versions: 7, published: 6 },
|
||||||
|
{ lang: 'fr', versions: 4, published: 2 },
|
||||||
|
{ lang: 'de', versions: 4, published: 3 },
|
||||||
|
{ lang: 'ar', versions: 1, published: 0 },
|
||||||
|
{ lang: 'es', enabled: false, versions: 1, published: 1 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SITE = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Journal',
|
||||||
|
key: 'journal',
|
||||||
|
site_name: 'Journal',
|
||||||
|
title_template: '%title% | %site%',
|
||||||
|
default_description: '',
|
||||||
|
base_url: 'https://journal.example.com',
|
||||||
|
default_social_image_id: null,
|
||||||
|
default_robots: 'index',
|
||||||
|
locale: 'en',
|
||||||
|
is_default: true,
|
||||||
|
article_count: 4,
|
||||||
|
public_url_pattern: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function capsOf(fixture: EditorFixture, lang: Lang) {
|
||||||
|
const current = fixture.lang ?? 'fr'
|
||||||
|
return lang === current ? (fixture.caps ?? FULL) : (fixture.siblingCaps?.[lang] ?? FULL)
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryOf(fixture: EditorFixture, lang: Lang) {
|
||||||
|
return {
|
||||||
|
id: IDS[lang],
|
||||||
|
lang,
|
||||||
|
title: TITLES[lang],
|
||||||
|
is_online: fixture.online?.[lang] ?? ONLINE[lang],
|
||||||
|
language_enabled: fixture.languageEnabled?.[lang] ?? true,
|
||||||
|
capabilities: capsOf(fixture, lang),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function articleOf(fixture: EditorFixture, lang: Lang = fixture.lang ?? 'fr') {
|
||||||
|
const order = fixture.translations ?? TRANSLATION_ORDER
|
||||||
|
return {
|
||||||
|
id: IDS[lang],
|
||||||
|
title: TITLES[lang],
|
||||||
|
body: fixture.body ?? `<p>Body of ${TITLES[lang]}</p>`,
|
||||||
|
images: [],
|
||||||
|
is_online: fixture.online?.[lang] ?? ONLINE[lang],
|
||||||
|
lang,
|
||||||
|
story_id: 5,
|
||||||
|
capabilities: capsOf(fixture, lang),
|
||||||
|
translations: order.map((tag) => entryOf(fixture, tag)),
|
||||||
|
site_id: 1,
|
||||||
|
authors: [],
|
||||||
|
created_at: '2026-03-12T09:14:00Z',
|
||||||
|
updated_at: UPDATED_AT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function editorRoutes(fixture: EditorFixture = {}): Record<string, Route> {
|
||||||
|
const current = fixture.lang ?? 'fr'
|
||||||
|
const ready = { status: 'ready', suggestions: [], slug: { available: true, suggestion: null } }
|
||||||
|
const routes: Record<string, Route> = {
|
||||||
|
'GET /v1/site/': [SITE],
|
||||||
|
'GET /v1/site/journal/languages': languagesBody(
|
||||||
|
'journal-1',
|
||||||
|
'en',
|
||||||
|
fixture.languages ?? JOURNAL_LANGUAGES,
|
||||||
|
),
|
||||||
|
'GET /v1/author/': { items: [], total: 0, page: 1, size: 100 },
|
||||||
|
'GET /v1/article/': (call) =>
|
||||||
|
storyEnvelope([], { size: Number(call.query.get('size')) || 25 }),
|
||||||
|
'GET /v1/media/': { items: [], next_cursor: null, counts: { all: 0 } },
|
||||||
|
}
|
||||||
|
for (const lang of Object.keys(IDS) as Lang[]) {
|
||||||
|
routes[`GET /v1/article/${IDS[lang]}`] = articleOf(fixture, lang)
|
||||||
|
routes[`POST /v1/article/${IDS[lang]}/seo/check`] = ready
|
||||||
|
}
|
||||||
|
routes[`GET /v1/article/${IDS[current]}`] = articleOf(fixture, current)
|
||||||
|
return { ...routes, ...fixture.extra }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mountEditor(fixture: EditorFixture = {}, path?: string) {
|
||||||
|
const lang = fixture.lang ?? 'fr'
|
||||||
|
await mountRoute(path ?? `/article/${IDS[lang]}/edit`, {
|
||||||
|
routes: editorRoutes(fixture),
|
||||||
|
width: fixture.width ?? DESKTOP_WIDTH,
|
||||||
|
userId: fixture.userId ?? 7,
|
||||||
|
root: RootComponent,
|
||||||
|
})
|
||||||
|
await settle()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function titleInput() {
|
||||||
|
const label = Array.from(document.body.querySelectorAll('label')).find((element) =>
|
||||||
|
/^Title/.test(element.textContent ?? ''),
|
||||||
|
)
|
||||||
|
return document.getElementById(label?.getAttribute('for') ?? '') as HTMLTextAreaElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export function languageTrigger() {
|
||||||
|
return document.body.querySelector<HTMLElement>('[aria-label$="Switch version"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointerDown(target: Element | null) {
|
||||||
|
target?.dispatchEvent(
|
||||||
|
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
|
||||||
|
pointerType: 'mouse',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openLanguageMenu() {
|
||||||
|
pointerDown(languageTrigger())
|
||||||
|
languageTrigger()!.click()
|
||||||
|
await settle()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openMoreActions() {
|
||||||
|
const trigger = document.body.querySelector<HTMLElement>('[aria-label="More actions"]')
|
||||||
|
pointerDown(trigger)
|
||||||
|
trigger!.click()
|
||||||
|
await settle()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function menuRows() {
|
||||||
|
return Array.from(
|
||||||
|
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowFor(name: string) {
|
||||||
|
return menuRows().find((row) => row.textContent?.trim().startsWith(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageText() {
|
||||||
|
return text()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buttonNamed(label: string | RegExp) {
|
||||||
|
return Array.from(document.body.querySelectorAll<HTMLElement>('button')).find((element) => {
|
||||||
|
const name = (element.getAttribute('aria-label') || element.textContent || '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
return typeof label === 'string' ? name === label : label.test(name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function editBody(html = '<p>New body</p>') {
|
||||||
|
kit.wrapper!.findComponent({ name: 'ArticleEditor' }).vm.$emit('update:modelValue', html)
|
||||||
|
await settle()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toolbar() {
|
||||||
|
return document.body.querySelector('.article-toolbar')
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Byline } from '@/composables/useArticles'
|
import type { ArticleCapability, Byline } from '@/composables/useArticles'
|
||||||
import type { Site } from '@/composables/useSites'
|
import type { Site } from '@/composables/useSites'
|
||||||
|
|
||||||
export const defaultSite: Site = {
|
export const defaultSite: Site = {
|
||||||
@@ -59,7 +59,19 @@ export const camille: Byline = {
|
|||||||
}
|
}
|
||||||
export const paul: Byline = { id: 8, name: 'Paul Huber', slug: 'paul-huber' }
|
export const paul: Byline = { id: 8, name: 'Paul Huber', slug: 'paul-huber' }
|
||||||
|
|
||||||
export const articleMeta = { site_id: defaultSite.id, authors: [ines] }
|
const FULL_CAPABILITIES: ArticleCapability[] = [
|
||||||
|
'read',
|
||||||
|
'edit',
|
||||||
|
'publish',
|
||||||
|
'delete',
|
||||||
|
'create_translation',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const articleMeta = {
|
||||||
|
site_id: defaultSite.id,
|
||||||
|
authors: [ines],
|
||||||
|
capabilities: FULL_CAPABILITIES,
|
||||||
|
}
|
||||||
|
|
||||||
export const readyCheck = {
|
export const readyCheck = {
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { vi } from 'vitest'
|
import { vi } from 'vitest'
|
||||||
import { defineComponent, h } from 'vue'
|
import { defineComponent, h, type Component } from 'vue'
|
||||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
@@ -46,6 +46,8 @@ interface MountOptions {
|
|||||||
role?: string
|
role?: string
|
||||||
routes: Record<string, Route>
|
routes: Record<string, Route>
|
||||||
width?: number
|
width?: number
|
||||||
|
userId?: number
|
||||||
|
root?: Component
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mountRoute(url: string, options: MountOptions) {
|
export async function mountRoute(url: string, options: MountOptions) {
|
||||||
@@ -57,7 +59,7 @@ export async function mountRoute(url: string, options: MountOptions) {
|
|||||||
session.session = {
|
session.session = {
|
||||||
verified: true,
|
verified: true,
|
||||||
username: 'marta',
|
username: 'marta',
|
||||||
id: 1,
|
id: options.userId ?? 1,
|
||||||
email: 'marta@renew.example',
|
email: 'marta@renew.example',
|
||||||
displayName: 'Marta Alves',
|
displayName: 'Marta Alves',
|
||||||
access: accessFor(options.role ?? 'owner'),
|
access: accessFor(options.role ?? 'owner'),
|
||||||
@@ -85,10 +87,10 @@ export async function mountRoute(url: string, options: MountOptions) {
|
|||||||
requestPaged: vi.fn(),
|
requestPaged: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof useApi>)
|
} as unknown as ReturnType<typeof useApi>)
|
||||||
await router.push(url)
|
await router.push(url)
|
||||||
kit.wrapper = mount(
|
const root =
|
||||||
defineComponent({ render: () => h(UApp, null, { default: () => h(RouterView) }) }),
|
options.root ??
|
||||||
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
|
defineComponent({ render: () => h(UApp, null, { default: () => h(RouterView) }) })
|
||||||
)
|
kit.wrapper = mount(root, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
|
||||||
await settle()
|
await settle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export function articleOf(id: number, title: string, overrides: Partial<Article>
|
|||||||
title,
|
title,
|
||||||
body: '<p>Intro</p>',
|
body: '<p>Intro</p>',
|
||||||
is_online: false,
|
is_online: false,
|
||||||
|
capabilities: ['read', 'edit', 'publish', 'delete', 'create_translation'],
|
||||||
site_id: 1,
|
site_id: 1,
|
||||||
authors: bylines('Inès Duarte'),
|
authors: bylines('Inès Duarte'),
|
||||||
created_at: '2026-03-12T09:14:00Z',
|
created_at: '2026-03-12T09:14:00Z',
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ describe('Every state has a setup', () => {
|
|||||||
describe('Existing screens are unaffected', () => {
|
describe('Existing screens are unaffected', () => {
|
||||||
it('reports the parser as the only discovery for localization screens', () => {
|
it('reports the parser as the only discovery for localization screens', () => {
|
||||||
const names = Object.keys(visualStates).filter((name) => name.startsWith('localization-'))
|
const names = Object.keys(visualStates).filter((name) => name.startsWith('localization-'))
|
||||||
expect(names).toEqual(['localization-site-languages', 'localization-list'])
|
expect(names).toEqual([
|
||||||
|
'localization-site-languages',
|
||||||
|
'localization-list',
|
||||||
|
'localization-editor',
|
||||||
|
'localization-conflicts-moves',
|
||||||
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
import type { Page } from 'playwright'
|
||||||
|
import { accessFor } from '../src/test/access'
|
||||||
|
import { siteLanguagesBody } from '../src/test/storyLists'
|
||||||
|
import type { ApiMock, DiffException, VisualState } from './states'
|
||||||
|
|
||||||
|
type Lang = 'en' | 'fr' | 'de' | 'ar'
|
||||||
|
|
||||||
|
interface Version {
|
||||||
|
id: number
|
||||||
|
lang: string
|
||||||
|
title: string
|
||||||
|
paragraphs: string[]
|
||||||
|
online: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const FULL = ['read', 'edit', 'publish', 'delete', 'create_translation']
|
||||||
|
const READ_ONLY = ['read']
|
||||||
|
const EDIT_NO_PUBLISH = ['read', 'edit', 'delete', 'create_translation']
|
||||||
|
const PUBLISH_NO_EDIT = ['read', 'publish']
|
||||||
|
const FULL_WITHOUT_CREATE = ['read', 'edit', 'publish', 'delete']
|
||||||
|
|
||||||
|
const FRENCH_TITLE = 'Comment nous chiffrons une rénovation de salle de bain'
|
||||||
|
const CHANGED_TITLE = `${FRENCH_TITLE.slice(0, -1)}x`
|
||||||
|
const LONG_TITLE_LENGTH = 203
|
||||||
|
const LONG_TITLE =
|
||||||
|
'Comment nous chiffrons une rénovation de salle de bain, poste par poste, avec les coûts cachés derrière les carreaux, sous la baignoire et autour des canalisations anciennes, expliqués avant la commande du premier carreau et de la première vanne'
|
||||||
|
const MINUTE_MS = 60 * 1000
|
||||||
|
const SAVED_MINUTES_AGO = 3
|
||||||
|
const LOAD_SETTLE_MS = 400
|
||||||
|
|
||||||
|
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.',
|
||||||
|
'Next, separate what is fixed from what is chosen. Plumbing and waterproofing are fixed. Tiles and taps are chosen.',
|
||||||
|
'Price the fixed items first and ask for a written range. A range with a stated assumption is more useful than a single figure.',
|
||||||
|
],
|
||||||
|
fr: [
|
||||||
|
"La plupart des devis échouent parce qu'ils cachent les trois mêmes coûts. Voici comment nous les détaillons avant de commander le premier carreau.",
|
||||||
|
'Commencez par la pièce, pas par le catalogue. Mesurez les murs, repérez le passage des canalisations et photographiez les équipements existants.',
|
||||||
|
"Séparez ensuite ce qui est fixe de ce qui est choisi. La plomberie et l'étanchéité sont fixes. Les carreaux et la robinetterie sont choisis.",
|
||||||
|
"Chiffrez d'abord les postes fixes et demandez une fourchette écrite.",
|
||||||
|
],
|
||||||
|
de: [
|
||||||
|
'Die meisten Angebote scheitern, weil sie dieselben drei Kosten verschweigen. So listen wir sie auf, bevor die erste Fliese bestellt wird.',
|
||||||
|
'Beginnen Sie mit dem Raum, nicht mit dem Katalog. Messen Sie die Wände und halten Sie fest, wo die Rohre verlaufen.',
|
||||||
|
],
|
||||||
|
ar: [
|
||||||
|
'تفشل معظم عروض الأسعار لأنها تُخفي التكاليف الثلاثة نفسها. إليك كيف نفصّلها قبل طلب أول بلاطة.',
|
||||||
|
'ابدأ بالغرفة لا بالكتالوج. قِس الجدران، وحدّد مسار الأنابيب، وصوّر التركيبات الحالية.',
|
||||||
|
'افصل بعد ذلك ما هو ثابت عمّا هو مختار. السباكة والعزل ثابتان، أما البلاط والصنابير فمختارة.',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const versions: Record<Lang, Version> = {
|
||||||
|
en: {
|
||||||
|
id: 10,
|
||||||
|
lang: 'en',
|
||||||
|
title: 'How we price a bathroom renovation',
|
||||||
|
paragraphs: paragraphs.en,
|
||||||
|
online: true,
|
||||||
|
},
|
||||||
|
fr: { id: 12, lang: 'fr', title: FRENCH_TITLE, paragraphs: paragraphs.fr, online: false },
|
||||||
|
de: {
|
||||||
|
id: 11,
|
||||||
|
lang: 'de',
|
||||||
|
title: 'So kalkulieren wir eine Badsanierung',
|
||||||
|
paragraphs: paragraphs.de,
|
||||||
|
online: true,
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
id: 13,
|
||||||
|
lang: 'ar',
|
||||||
|
title: 'كيف نسعّر تجديد الحمّام',
|
||||||
|
paragraphs: paragraphs.ar,
|
||||||
|
online: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = new Date(Date.now() - SAVED_MINUTES_AGO * MINUTE_MS).toISOString()
|
||||||
|
const seoReady = { status: 'ready', suggestions: [], slug: { available: true, suggestion: null } }
|
||||||
|
const seoOneSuggestion = {
|
||||||
|
status: 'suggestions',
|
||||||
|
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 }
|
||||||
|
|
||||||
|
const site = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Journal',
|
||||||
|
key: 'journal',
|
||||||
|
site_name: 'Journal',
|
||||||
|
title_template: '%title% | %site%',
|
||||||
|
default_description: '',
|
||||||
|
base_url: 'https://journal.example.com',
|
||||||
|
default_social_image_id: null,
|
||||||
|
default_robots: 'index',
|
||||||
|
locale: 'en',
|
||||||
|
is_default: true,
|
||||||
|
article_count: 8,
|
||||||
|
public_url_pattern: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Setup {
|
||||||
|
current?: Lang
|
||||||
|
capabilities?: string[]
|
||||||
|
siblings?: Lang[]
|
||||||
|
siblingCapabilities?: Partial<Record<Lang, string[]>>
|
||||||
|
online?: Partial<Record<Lang, boolean>>
|
||||||
|
languageEnabled?: Partial<Record<Lang, boolean>>
|
||||||
|
languages?: unknown
|
||||||
|
versions?: Record<string, Version>
|
||||||
|
title?: string
|
||||||
|
paragraphs?: string[]
|
||||||
|
suggestion?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function entry(setup: Setup, lang: Lang) {
|
||||||
|
const version = versions[lang]
|
||||||
|
const current = lang === (setup.current ?? 'fr')
|
||||||
|
return {
|
||||||
|
id: version.id,
|
||||||
|
lang: version.lang,
|
||||||
|
title: version.title,
|
||||||
|
is_online: setup.online?.[lang] ?? version.online,
|
||||||
|
language_enabled: setup.languageEnabled?.[lang] ?? true,
|
||||||
|
capabilities: current
|
||||||
|
? (setup.capabilities ?? FULL)
|
||||||
|
: (setup.siblingCapabilities?.[lang] ?? FULL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function articleBody(setup: Setup, lang: Lang) {
|
||||||
|
const version = versions[lang]
|
||||||
|
const current = lang === (setup.current ?? 'fr')
|
||||||
|
const siblings = setup.siblings ?? ['en', 'fr', 'de']
|
||||||
|
const list = (siblings.includes(lang) ? siblings : [...siblings, lang]) as Lang[]
|
||||||
|
return {
|
||||||
|
id: version.id,
|
||||||
|
title: setup.title ?? version.title,
|
||||||
|
body: (setup.paragraphs ?? version.paragraphs).map((text) => `<p>${text}</p>`).join(''),
|
||||||
|
images: [1, 2, 3].map((id) => ({ id, url: `/images/${id}.png` })),
|
||||||
|
is_online: setup.online?.[lang] ?? version.online,
|
||||||
|
lang: version.lang,
|
||||||
|
story_id: 5,
|
||||||
|
capabilities: current
|
||||||
|
? (setup.capabilities ?? FULL)
|
||||||
|
: (setup.siblingCapabilities?.[lang] ?? FULL),
|
||||||
|
translations: list.map((tag) => entry(setup, tag)),
|
||||||
|
site_id: 1,
|
||||||
|
authors: [{ id: 1, name: 'Inès Duarte', slug: 'ines-duarte' }],
|
||||||
|
created_at: '2026-03-12T09:14:00Z',
|
||||||
|
updated_at: updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(): ApiMock {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
id: 7,
|
||||||
|
is_verified: true,
|
||||||
|
username: 'marta',
|
||||||
|
email: 'marta@renew.example',
|
||||||
|
display_name: 'Marta Alves',
|
||||||
|
access: accessFor('owner'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function articleApi(setup: Setup, lang: Lang): Record<string, ApiMock> {
|
||||||
|
const version = versions[lang]
|
||||||
|
return {
|
||||||
|
[`GET /v1/article/${version.id}`]: ok(articleBody(setup, lang)),
|
||||||
|
[`POST /v1/article/${version.id}/seo/check`]: ok(
|
||||||
|
setup.suggestion ? seoOneSuggestion : seoReady,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorApi(setup: Setup = {}, overrides: Record<string, ApiMock> = {}) {
|
||||||
|
const current = setup.current ?? 'fr'
|
||||||
|
const others = (['en', 'fr', 'de', 'ar'] as Lang[]).filter((lang) => lang !== current)
|
||||||
|
return {
|
||||||
|
'GET /v1/auth/me': session(),
|
||||||
|
'GET /v1/site/': ok([site]),
|
||||||
|
'GET /v1/site/journal/languages': ok(setup.languages ?? siteLanguagesBody('journal')),
|
||||||
|
'GET /v1/author/': ok({ items: [], total: 0 }),
|
||||||
|
...Object.assign({}, ...others.map((lang) => articleApi(setup, lang))),
|
||||||
|
...articleApi(setup, current),
|
||||||
|
...overrides,
|
||||||
|
} as Record<string, ApiMock>
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathOf(setup: Setup) {
|
||||||
|
return `/article/${versions[setup.current ?? 'fr'].id}/edit`
|
||||||
|
}
|
||||||
|
|
||||||
|
function state(
|
||||||
|
setup: Setup,
|
||||||
|
steps?: (page: Page) => Promise<void>,
|
||||||
|
overrides: Record<string, ApiMock> = {},
|
||||||
|
exception?: DiffException,
|
||||||
|
): VisualState {
|
||||||
|
return {
|
||||||
|
path: pathOf(setup),
|
||||||
|
timezone: 'UTC',
|
||||||
|
api: editorApi(setup, overrides),
|
||||||
|
steps,
|
||||||
|
exception,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = (path: string): VisualState => ({ path, skip: 'pending' })
|
||||||
|
|
||||||
|
function narrow(page: Page) {
|
||||||
|
return (page.viewportSize()?.width ?? 0) < 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle(page: Page) {
|
||||||
|
await page.waitForTimeout(LOAD_SETTLE_MS)
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollEverythingToTop(page: Page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.querySelectorAll<HTMLElement>('*').forEach((element) => {
|
||||||
|
element.scrollTop = 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
await page.keyboard.press('Control+Home')
|
||||||
|
await scrollEverythingToTop(page)
|
||||||
|
await settle(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function titleInput(page: Page) {
|
||||||
|
const input = page.getByRole('textbox', { name: /^Title/ }).first()
|
||||||
|
await input.waitFor()
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
async function typeTitle(page: Page, value: string) {
|
||||||
|
const input = await titleInput(page)
|
||||||
|
await input.click()
|
||||||
|
await input.fill(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function blurTitle(page: Page) {
|
||||||
|
await page.keyboard.press('Tab')
|
||||||
|
await settle(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLanguageMenu(page: Page, name = 'French') {
|
||||||
|
await page.getByRole('button', { name: `Language: ${name}. Switch version` }).click()
|
||||||
|
await page.getByRole('menuitem').first().waitFor()
|
||||||
|
await settle(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseLanguage(page: Page, name: string) {
|
||||||
|
await openLanguageMenu(page, name === 'English' ? 'French' : 'English')
|
||||||
|
await page.getByRole('menuitem', { name: new RegExp(`^${name}`) }).click()
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
await page.getByRole('button', { name: 'More actions' }).first().click()
|
||||||
|
await page.getByRole('menuitem').first().waitFor()
|
||||||
|
await settle(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pressUnpublish(page: Page) {
|
||||||
|
if (narrow(page)) {
|
||||||
|
await openOverflow(page)
|
||||||
|
await page.getByRole('menuitem', { name: /^Unpublish/ }).click()
|
||||||
|
} else {
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /^Unpublish/ })
|
||||||
|
.first()
|
||||||
|
.click()
|
||||||
|
}
|
||||||
|
await page.getByRole('alertdialog').or(page.getByRole('dialog')).first().waitFor()
|
||||||
|
await settle(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDialog(page: Page, label: string) {
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click()
|
||||||
|
await settle(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dirtyThenPress(page: Page, label: string | RegExp) {
|
||||||
|
await editBody(page)
|
||||||
|
await press(page, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveFails: Record<string, ApiMock> = { 'PATCH /v1/article/12': fails() }
|
||||||
|
const savedFrench = (online = false) =>
|
||||||
|
ok({ ...articleBody({ current: 'fr', online: { fr: online } }, 'fr') })
|
||||||
|
|
||||||
|
const ed = (
|
||||||
|
setup: Setup,
|
||||||
|
steps?: (page: Page) => Promise<void>,
|
||||||
|
overrides: Record<string, ApiMock> = {},
|
||||||
|
) => state({ suggestion: true, ...setup }, steps, overrides)
|
||||||
|
|
||||||
|
const l5: Record<string, VisualState> = {
|
||||||
|
'loc.l5.default': ed({}),
|
||||||
|
'loc.l5.dirty': ed({ capabilities: EDIT_NO_PUBLISH }, editBody),
|
||||||
|
'loc.l5.dirtyPublish': ed({}, editBody),
|
||||||
|
'loc.l5.cleanOnline': ed({ current: 'en' }),
|
||||||
|
'loc.l5.onlineDirty': ed({ current: 'en' }, editBody),
|
||||||
|
'loc.l5.saveFail': ed({}, (page) => dirtyThenPress(page, 'Save'), saveFails),
|
||||||
|
'loc.l5.pubFail': ed(
|
||||||
|
{},
|
||||||
|
async (page) => {
|
||||||
|
await editBody(page)
|
||||||
|
await press(page, 'Save and publish French')
|
||||||
|
await confirmDialog(page, 'Save and publish French')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'PATCH /v1/article/12': savedFrench(),
|
||||||
|
'PATCH /v1/article/12/publish': fails(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'loc.l5.saving': ed({}, (page) => dirtyThenPress(page, 'Save'), {
|
||||||
|
'PATCH /v1/article/12': hangs,
|
||||||
|
}),
|
||||||
|
'loc.l5.saved': ed({ siblings: ['en', 'fr'] }, (page) => dirtyThenPress(page, 'Save'), {
|
||||||
|
'PATCH /v1/article/12': savedFrench(),
|
||||||
|
}),
|
||||||
|
'loc.l5.ovf': ed({}, async (page) => {
|
||||||
|
await editBody(page)
|
||||||
|
await openOverflow(page)
|
||||||
|
}),
|
||||||
|
'loc.l5.menu': ed({ siblings: ['en', 'fr', 'de', 'ar'] }, openLanguageMenu),
|
||||||
|
'loc.l5.two': ed(
|
||||||
|
{
|
||||||
|
siblings: ['en', 'fr'],
|
||||||
|
languages: languagesOf(['en', 'fr']),
|
||||||
|
},
|
||||||
|
openLanguageMenu,
|
||||||
|
),
|
||||||
|
'loc.l5.many': manyLanguages(),
|
||||||
|
'loc.l5.unsaved': ed({}, async (page) => {
|
||||||
|
await editBody(page)
|
||||||
|
await chooseLanguage(page, 'English')
|
||||||
|
await page.getByRole('dialog').waitFor()
|
||||||
|
await settle(page)
|
||||||
|
}),
|
||||||
|
'loc.l5.loading': ed(
|
||||||
|
{ siblings: ['en', 'fr', 'de'] },
|
||||||
|
async (page) => {
|
||||||
|
await chooseLanguage(page, 'English')
|
||||||
|
await page.waitForTimeout(LOAD_SETTLE_MS)
|
||||||
|
},
|
||||||
|
{ 'GET /v1/article/10': hangs },
|
||||||
|
),
|
||||||
|
'loc.l5.navFail': ed(
|
||||||
|
{ current: 'en', online: { en: false } },
|
||||||
|
async (page) => {
|
||||||
|
await openLanguageMenu(page, 'English')
|
||||||
|
await page.getByRole('menuitem', { name: /^French/ }).click()
|
||||||
|
await page.getByText("Couldn't open the French version", { exact: true }).waitFor()
|
||||||
|
await settle(page)
|
||||||
|
},
|
||||||
|
{ 'GET /v1/article/12': fails() },
|
||||||
|
),
|
||||||
|
'loc.l5.gone': ed({}, undefined, {
|
||||||
|
'GET /v1/article/12': { status: 404, body: 'Article not found' },
|
||||||
|
}),
|
||||||
|
'loc.l5.focus': ed({}, async (page) => {
|
||||||
|
await (await titleInput(page)).focus()
|
||||||
|
await settle(page)
|
||||||
|
}),
|
||||||
|
'loc.l5.titleEdit': ed({}, async (page) => {
|
||||||
|
const input = await titleInput(page)
|
||||||
|
await input.click()
|
||||||
|
await input.press('End')
|
||||||
|
await input.pressSequentially(' en 2026')
|
||||||
|
await settle(page)
|
||||||
|
}),
|
||||||
|
'loc.l5.titleUnsaved': ed({}, async (page) => {
|
||||||
|
const input = await titleInput(page)
|
||||||
|
await input.click()
|
||||||
|
await input.press('End')
|
||||||
|
await input.pressSequentially(' en 2026')
|
||||||
|
await blurTitle(page)
|
||||||
|
}),
|
||||||
|
'loc.l5.invalid': ed({}, async (page) => {
|
||||||
|
await typeTitle(page, '')
|
||||||
|
await settle(page)
|
||||||
|
}),
|
||||||
|
'loc.l5.titleShort': ed({}, async (page) => {
|
||||||
|
await typeTitle(page, 'Sa')
|
||||||
|
await settle(page)
|
||||||
|
}),
|
||||||
|
'loc.l5.titleLong': ed({}, async (page) => {
|
||||||
|
await typeTitle(page, LONG_TITLE.slice(0, LONG_TITLE_LENGTH))
|
||||||
|
await settle(page)
|
||||||
|
}),
|
||||||
|
'loc.l5.titleSaving': ed(
|
||||||
|
{},
|
||||||
|
async (page) => {
|
||||||
|
await typeTitle(page, CHANGED_TITLE)
|
||||||
|
await blurTitle(page)
|
||||||
|
await press(page, 'Save')
|
||||||
|
},
|
||||||
|
{ 'PATCH /v1/article/12': hangs },
|
||||||
|
),
|
||||||
|
'loc.l5.failed': ed(
|
||||||
|
{},
|
||||||
|
async (page) => {
|
||||||
|
await typeTitle(page, CHANGED_TITLE)
|
||||||
|
await blurTitle(page)
|
||||||
|
await press(page, 'Save')
|
||||||
|
},
|
||||||
|
saveFails,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
function languagesOf(tags: string[]) {
|
||||||
|
const base = siteLanguagesBody('journal')
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
languages: base.languages.filter((language) => tags.includes(language.lang)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function manyLanguages(): VisualState {
|
||||||
|
const tags = ['en', 'en-GB', 'fr', 'de', 'it', 'ja', 'pt-BR', 'es', 'zh-Hans', 'ar', 'nl', 'pl']
|
||||||
|
const base = siteLanguagesBody('journal')
|
||||||
|
const template = base.languages[0]!
|
||||||
|
const many = {
|
||||||
|
...base,
|
||||||
|
languages: tags.map((lang) => ({ ...template, lang, is_default: lang === 'en' })),
|
||||||
|
}
|
||||||
|
const readable = ['en', 'en-GB', 'fr', 'de', 'it', 'ja']
|
||||||
|
const article = articleBody({ current: 'fr' }, 'fr')
|
||||||
|
const translations = readable.map((lang, index) => ({
|
||||||
|
...article.translations[0]!,
|
||||||
|
id: index === 2 ? 12 : 100 + index,
|
||||||
|
lang,
|
||||||
|
is_online: ['en', 'de', 'ja'].includes(lang),
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
path: '/article/12/edit',
|
||||||
|
timezone: 'UTC',
|
||||||
|
api: editorApi(
|
||||||
|
{ suggestion: true },
|
||||||
|
{
|
||||||
|
'GET /v1/site/journal/languages': ok(many),
|
||||||
|
'GET /v1/article/12': ok({ ...article, translations }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
steps: async (page) => {
|
||||||
|
await openLanguageMenu(page)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const l5b: Record<string, VisualState> = {
|
||||||
|
'loc.perm.readonly': state({ capabilities: READ_ONLY }),
|
||||||
|
'loc.l5b.roMenu': state({ capabilities: READ_ONLY }, openLanguageMenu),
|
||||||
|
'loc.perm.editNoPublish': state({ capabilities: EDIT_NO_PUBLISH }),
|
||||||
|
'loc.perm.publishNoEdit': state({ capabilities: PUBLISH_NO_EDIT }),
|
||||||
|
'loc.perm.siblingReadOnly': state(
|
||||||
|
{ siblings: ['en', 'fr', 'de', 'ar'], siblingCapabilities: { ar: READ_ONLY } },
|
||||||
|
openLanguageMenu,
|
||||||
|
),
|
||||||
|
'loc.perm.siblingHidden': state(
|
||||||
|
{ languages: languagesOf(['en', 'fr', 'de']) },
|
||||||
|
openLanguageMenu,
|
||||||
|
),
|
||||||
|
'loc.perm.createNotAll': state({ siblingCapabilities: { en: ['read', 'edit'] } }, openOverflow),
|
||||||
|
'loc.l5b.noAdd': state(
|
||||||
|
{
|
||||||
|
capabilities: FULL_WITHOUT_CREATE,
|
||||||
|
title: "Guide de l'enduit à la chaux",
|
||||||
|
},
|
||||||
|
openLanguageMenu,
|
||||||
|
),
|
||||||
|
'loc.l5b.withAdd': state(
|
||||||
|
{
|
||||||
|
siblings: ['en', 'fr', 'de'],
|
||||||
|
title: "Guide de l'enduit à la chaux",
|
||||||
|
},
|
||||||
|
openLanguageMenu,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
const l8: Record<string, VisualState> = {
|
||||||
|
'loc.l8.publish': state({}, async (page) => {
|
||||||
|
await press(page, 'Publish French')
|
||||||
|
await page.getByRole('dialog').waitFor()
|
||||||
|
await settle(page)
|
||||||
|
}),
|
||||||
|
'loc.lang.publishDisabled': state({ languageEnabled: { fr: false } }),
|
||||||
|
'loc.l8.saveAndPublish': state({}, async (page) => {
|
||||||
|
await editBody(page)
|
||||||
|
await press(page, 'Save and publish French')
|
||||||
|
await page.getByRole('dialog').waitFor()
|
||||||
|
await settle(page)
|
||||||
|
}),
|
||||||
|
'loc.l8.saveFailPublish': state(
|
||||||
|
{},
|
||||||
|
async (page) => {
|
||||||
|
await editBody(page)
|
||||||
|
await press(page, 'Save and publish French')
|
||||||
|
await confirmDialog(page, 'Save and publish French')
|
||||||
|
await page.getByText("Couldn't save French", { exact: true }).first().waitFor()
|
||||||
|
await settle(page)
|
||||||
|
},
|
||||||
|
saveFails,
|
||||||
|
{
|
||||||
|
maxPercent: { desktop: 13, mobile: 21.5 },
|
||||||
|
reason: 'The board omits the banner / online alert that features/localization_editor_navigation.feature requires, shifting the page by 70-90px; the board is to be updated.',
|
||||||
|
approvedBy: 'Florian Sylvain',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'loc.l8.unpublish': state({ online: { fr: true } }, pressUnpublish),
|
||||||
|
'loc.l8.unpublishDirty': state({ online: { fr: true } }, async (page) => {
|
||||||
|
await editBody(page)
|
||||||
|
await pressUnpublish(page)
|
||||||
|
}),
|
||||||
|
'loc.l8.delete': pending('/article/12/edit'),
|
||||||
|
'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.done': state(
|
||||||
|
{},
|
||||||
|
async (page) => {
|
||||||
|
await press(page, 'Publish French')
|
||||||
|
await confirmDialog(page, 'Publish French')
|
||||||
|
await page.getByText('French published', { exact: true }).waitFor()
|
||||||
|
await settle(page)
|
||||||
|
},
|
||||||
|
{ 'PATCH /v1/article/12/publish': savedFrench(true) },
|
||||||
|
{
|
||||||
|
maxPercent: { desktop: 11.1, mobile: 20.2 },
|
||||||
|
reason: 'The board omits the banner / online alert that features/localization_editor_navigation.feature requires, shifting the page by 70-90px; the board is to be updated.',
|
||||||
|
approvedBy: 'Florian Sylvain',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'loc.l8.fail': state(
|
||||||
|
{},
|
||||||
|
async (page) => {
|
||||||
|
await press(page, 'Publish French')
|
||||||
|
await confirmDialog(page, 'Publish French')
|
||||||
|
await page.getByText("Couldn't publish French", { exact: true }).waitFor()
|
||||||
|
await settle(page)
|
||||||
|
},
|
||||||
|
{ 'PATCH /v1/article/12/publish': fails() },
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editorLocalizationStates: Record<string, VisualState> = { ...l5, ...l5b, ...l8 }
|
||||||
|
|
||||||
|
const RESTORED_AT = '2026-10-14T14:18:00Z'
|
||||||
|
const STORED_DRAFT_KEY = 'renewcms:article-draft:7:12'
|
||||||
|
const olderFrench = paragraphs.fr.slice(0, 2)
|
||||||
|
|
||||||
|
export const conflictsMovesStates: Record<string, VisualState> = {
|
||||||
|
'loc.l11.session': pending('/article/12/edit'),
|
||||||
|
'loc.l11.restored': state({ paragraphs: olderFrench }, async (page) => {
|
||||||
|
await page.evaluate(
|
||||||
|
({ key, body, mirroredAt }) =>
|
||||||
|
sessionStorage.setItem(key, JSON.stringify({ body, 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 }).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.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'),
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { Page } from 'playwright'
|
|||||||
import { accessFor, customAccess, roleRefFor } from '../src/test/access'
|
import { accessFor, customAccess, roleRefFor } from '../src/test/access'
|
||||||
import { approvedExceptions } from './approvedExceptions'
|
import { approvedExceptions } from './approvedExceptions'
|
||||||
import { articleListStates } from './articleListStates'
|
import { articleListStates } from './articleListStates'
|
||||||
|
import { conflictsMovesStates, editorLocalizationStates } from './editorLocalizationStates'
|
||||||
import { localizationStates } from './localizationStates'
|
import { localizationStates } from './localizationStates'
|
||||||
import { rolesStates } from './rolesStates'
|
import { rolesStates } from './rolesStates'
|
||||||
|
|
||||||
@@ -10347,4 +10348,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
'media-picker': pickerStates,
|
'media-picker': pickerStates,
|
||||||
'localization-site-languages': localizationStates,
|
'localization-site-languages': localizationStates,
|
||||||
'localization-list': articleListStates,
|
'localization-list': articleListStates,
|
||||||
|
'localization-editor': editorLocalizationStates,
|
||||||
|
'localization-conflicts-moves': conflictsMovesStates,
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user