feat: SEO SPA editor integration
This commit is contained in:
1 parent
eb9876fde3
commit
470584400f
62 files changed
+7471
-58
No files matched your search
@@ -0,0 +1,532 @@
|
||||
Feature: Editor: search and sharing, suggestions and "What the frontend gets"
|
||||
|
||||
As a RenewCMS user who can edit articles
|
||||
I want to fill in an article's search and sharing data, see suggestions and previews, and see what the frontend receives
|
||||
So that the API returns good SEO data for every article without blocking me from saving or publishing
|
||||
|
||||
Background:
|
||||
Given the screen is EditorView (/article/:id/edit) inside DashboardShell, per docs/design/routes.md
|
||||
And the visual spec of record is docs/design/screens/editor.md (groups "Editor: search and sharing" with 24 states and "Editor: what the frontend gets" with 6 states), docs/design/seo-slices.md, docs/design/components.md (SearchSharingSection, SeoPanel, SoftCounter, SearchPreview, SocialCardPreview, SuggestionList, ApiPreviewDialog, SlugField, MediaField) and docs/design/states.md ("SEO round"). Exact copy is read from those files and is not repeated here unless a scenario needs it
|
||||
And the board frames of docs/design/source/RenewCMS Screens Back-office.dc.html, AppFrame.dc.html and DirectoryFrame.dc.html are the layout tiebreaker
|
||||
And this slice is SPA only and reuses the API of features/seo_data_backend.feature and features/seo_output_backend.feature without change
|
||||
And the SPA calls, with relative URLs through useApi only: GET /v1/article/{id} and PATCH /v1/article/{id} (SEO fields slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex), POST /v1/article/{id}/seo/check, GET /v1/article/{id}/seo, GET /v1/site/ and the media routes of the shared picker
|
||||
And POST /v1/article/{id}/seo/check takes the optional SEO fields (and body) of PATCH as overrides and answers {status, suggestions: [{code, field, params}], slug: {available, suggestion}} with status "ready", "suggestions" or "hidden"
|
||||
And the SPA never re-implements a suggestion rule. It renders the suggestions, the order and the status the check returns. The copy of each code lives in the SPA and has a French entry
|
||||
And the suggestion codes are title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt and short
|
||||
And a suggestion's field maps to a link label: seo_title "meta title", seo_description "meta description", cover_image_alt and cover_image_id "cover image", slug "URL slug", author_ids "authors", body "body"
|
||||
And the soft ranges of the counters are meta title 30 to 60 and description 70 to 160 (docs/design). They colour a counter and never block
|
||||
And suggestions never disable Save, "Save and publish" or Publish
|
||||
And the sample article is 12, "How we price a bathroom renovation", online on the site Renew (renew.example, id 1), slug "how-we-price-a-bathroom-renovation", with no SEO field set, and the author "Inès Duarte"
|
||||
And the sample cover media is "north-wall.jpg" (the site social image) and the sample panel values are those of the board frames "Panel: filled in" and "Panel: outside the soft ranges"
|
||||
And every visible string goes through t(...) with the English text as the key and a French entry in web/src/i18n/messages.ts
|
||||
And the screen uses Nuxt UI components and design tokens only, with no raw colors, and no comment is added anywhere
|
||||
And a scenario tagged @not-in-design uses copy or behaviour that docs/design does not define: it takes the proposal of this feature, has a French entry, and is listed in the .qa.md to be fed back to the design
|
||||
|
||||
Rule: The "Search and sharing" section sits under "Site and authors", collapsed by default
|
||||
|
||||
Scenario: Position, in the sidebar and in the Details sheet
|
||||
Given article 12 is loaded and nothing is remembered for this browser
|
||||
When the user opens /article/12/edit on desktop
|
||||
Then the details sidebar shows a collapsed "Search and sharing" section directly under "Site and authors"
|
||||
And on mobile (390 px) the section is not visible until the Details sheet is opened, and inside the sheet it sits under "Site and authors"
|
||||
|
||||
Scenario Outline: The one-line summary follows the check
|
||||
Given the section is collapsed
|
||||
And POST /v1/article/12/seo/check answers <check>
|
||||
Then the summary line reads "<summary>" in <colour>
|
||||
|
||||
Examples:
|
||||
| check | summary | colour |
|
||||
| pending | Checking… | the muted colour |
|
||||
| status "ready", no suggestions | Ready | the muted colour |
|
||||
| status "suggestions", 1 suggestion | 1 suggestion | the warning colour |
|
||||
| status "suggestions", 2 suggestions | 2 suggestions | the warning colour |
|
||||
| status "hidden", 2 suggestions | Hidden from search | the muted colour |
|
||||
|
||||
Scenario: The existing Editor states show the section
|
||||
Then every editor state that has the details sidebar shows the collapsed section with "Ready"
|
||||
And the Loading state shows the section header with a skeleton summary, as for "Site and authors"
|
||||
And the Not found state shows no section
|
||||
|
||||
Scenario: The choice is remembered per browser, with its own key
|
||||
Given the section is collapsed by default
|
||||
When the user opens it and later opens another article in the same browser
|
||||
Then it is open there too
|
||||
When the user collapses it and reloads
|
||||
Then it is collapsed
|
||||
And the choice is kept in localStorage under its own key, independent of the "Site and authors" key, and nothing is sent to the API
|
||||
And the sidebar and the Details sheet share that key
|
||||
|
||||
Scenario Outline: A storage that throws never breaks the editor
|
||||
Given localStorage.<call> throws a SecurityError
|
||||
When the user opens the editor and toggles the section
|
||||
Then the editor renders, the section opens and closes for this page view, and no error reaches the user
|
||||
And reading falls back to "collapsed" and writing is silently skipped
|
||||
|
||||
Examples:
|
||||
| call |
|
||||
| getItem |
|
||||
| setItem |
|
||||
|
||||
Scenario: The section opens by itself when asked from elsewhere
|
||||
When the user activates "Edit search and sharing", "What the frontend gets" or a suggestion link
|
||||
Then the panel or the dialog opens whatever the stored choice is, and the stored choice is not changed
|
||||
|
||||
Rule: The open section: address, suggestions and actions
|
||||
|
||||
Scenario: Open and ready
|
||||
Given the section is open and the check answers status "ready"
|
||||
Then it shows "Address" with "renew.example/how-we-price-a-bathroom-renovation" (the host of the site's base_url, without the scheme, then the slug)
|
||||
And "All good" with "Nothing to suggest for this article."
|
||||
And the actions "Edit search and sharing" and "What the frontend gets"
|
||||
|
||||
Scenario: Open with suggestions, at most three
|
||||
Given the check answers title_long (74 characters), cover_alt, no_h and short, in that order
|
||||
Then exactly three suggestions are shown, in the order of the response, each with its message and a "Go to <field>" link: "Go to meta title", "Go to cover image", "Go to body"
|
||||
And a "+1 more in Search and sharing" action follows
|
||||
When the user activates "+1 more in Search and sharing"
|
||||
Then the panel opens with the suggestion list in view
|
||||
And with three suggestions or fewer no "+N more" action is shown
|
||||
|
||||
Scenario Outline: A suggestion link goes to its field
|
||||
Given the check answers a suggestion whose field is "<field>"
|
||||
When the user activates its "Go to <label>" link
|
||||
Then <result>
|
||||
|
||||
Examples:
|
||||
| field | label | result |
|
||||
| seo_title | meta title | the panel opens with focus on the meta title input |
|
||||
| seo_description | meta description | the panel opens with focus on the meta description |
|
||||
| cover_image_alt | cover image | the panel opens with focus on the alt text input |
|
||||
| cover_image_id | cover image | the panel opens with focus on the cover "Choose image" button |
|
||||
| slug | URL slug | the panel opens with focus on the slug field |
|
||||
| body | body | focus moves to the article body in the editor |
|
||||
| author_ids | authors | the "Site and authors" section opens and focus moves to its list |
|
||||
|
||||
@not-in-design
|
||||
Scenario Outline: Copy of the codes the design does not draw
|
||||
Given the check answers the code "<code>" with params <params>
|
||||
Then the message reads "<message>" and has a French entry
|
||||
|
||||
Examples:
|
||||
| code | params | message |
|
||||
| title_empty | {} | The meta title is empty. |
|
||||
| desc_long | {"length": 170, "max": 160} | The description is 170 characters. About 70 to 160 reads best in results. |
|
||||
| cover_none | {} | This article has no cover image. |
|
||||
| slug_long | {"length": 80, "max": 75} | The URL slug is 80 characters. Shorter addresses are easier to share. |
|
||||
| no_au | {} | This article has no author. |
|
||||
| body_alt | {"count": 2} | 2 images in the body have no alt text. |
|
||||
| short | {"length": 40, "min": 300} | The body is 40 characters. Longer articles tend to do better in search. |
|
||||
|
||||
Scenario Outline: The messages the design does draw use the params of the response
|
||||
Given the check answers the code "<code>" with params <params>
|
||||
Then the message reads "<message>"
|
||||
|
||||
Examples:
|
||||
| code | params | message |
|
||||
| title_long | {"length": 74, "max": 60} | The meta title is 74 characters. Search engines may cut it off after about 60. |
|
||||
| desc_short | {"length": 41, "min": 70} | The description is 41 characters. About 70 to 160 reads best in results. |
|
||||
| cover_alt | {} | The cover image has no alt text. Add a short description of it. |
|
||||
| no_h | {} | The body has no headings. H2 headings help readers scan the article. |
|
||||
|
||||
Scenario: Checking, with the rest of the section usable
|
||||
Given POST /v1/article/12/seo/check is pending
|
||||
Then the section shows "Checking this article…"
|
||||
And the address, "Edit search and sharing" and "What the frontend gets" stay usable
|
||||
|
||||
Scenario: Hidden from search
|
||||
Given the article has noindex true
|
||||
When the section is open
|
||||
Then it shows "Hidden from search engines. This only sets a flag in the API."
|
||||
|
||||
Scenario: Another site
|
||||
Given the article is on the site Journal (journal.renew.example)
|
||||
Then the address reads "journal.renew.example/how-we-price-a-bathroom-renovation"
|
||||
|
||||
@not-in-design
|
||||
Scenario Outline: The check could not run
|
||||
Given POST /v1/article/12/seo/check fails with <failure>
|
||||
Then the summary line is not shown as Ready and the section shows "Couldn't check this article." with "Try again"
|
||||
And no toast is shown and the article is unchanged
|
||||
When the user activates "Try again"
|
||||
Then the check is requested again
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| status 500 |
|
||||
| a network error |
|
||||
|
||||
Scenario Outline: A session problem during the check follows the existing rules
|
||||
When POST /v1/article/12/seo/check answers <status>
|
||||
Then <result>
|
||||
|
||||
Examples:
|
||||
| status | result |
|
||||
| 401 | the user lands on /login?redirect=/article/12/edit&reason=expired, the draft is kept |
|
||||
| 404 | the "Not found" editor state is shown |
|
||||
|
||||
Rule: Suggestions and the slug check come from the server, after a pause in typing
|
||||
|
||||
Scenario: The first check runs when the editor loads
|
||||
When GET /v1/article/12 answers 200
|
||||
Then exactly one POST /v1/article/12/seo/check is sent with the body {}
|
||||
And no check is sent while GET /v1/article/12 is pending or fails
|
||||
|
||||
Scenario: Only changed fields are sent to the check
|
||||
Given the user changed the meta title to a 74-character text and nothing else
|
||||
When the check runs
|
||||
Then the POST body is {"seo_title": "<the 74 characters>"} and holds no other field
|
||||
|
||||
Scenario: An unsaved body edit is part of the check
|
||||
Given the user edited the body in the editor
|
||||
When the check runs
|
||||
Then the POST body holds "body" with the edited html, so no_h, body_alt and short reflect what is on screen
|
||||
|
||||
Scenario Outline: The check waits for a pause in typing
|
||||
Given the user types in <field>
|
||||
When the user keeps typing with less than the debounce delay between keystrokes
|
||||
Then no check is sent
|
||||
When the user pauses for the debounce delay
|
||||
Then exactly one POST /v1/article/12/seo/check is sent with the latest value
|
||||
And the suggestions refresh and the summary line follows
|
||||
|
||||
Examples:
|
||||
| field |
|
||||
| meta title |
|
||||
| meta description |
|
||||
| excerpt |
|
||||
| alt text |
|
||||
| social title |
|
||||
| canonical URL |
|
||||
| slug |
|
||||
|
||||
Scenario: A late answer never overwrites a newer one
|
||||
Given check A was sent, then check B after a further edit
|
||||
When B answers first and A answers later
|
||||
Then the suggestions and the status are those of B
|
||||
And when only A is still pending, the previous request is aborted
|
||||
|
||||
Scenario: Discrete changes check immediately
|
||||
When the user toggles "Hide from search engines", picks or removes a cover or a social image, or chooses "Use <slug>-2"
|
||||
Then one check is sent without waiting for the debounce delay
|
||||
|
||||
Scenario: Nothing is saved by a check
|
||||
Then no PATCH is sent by any check and the status stays unchanged
|
||||
|
||||
Scenario: The panel check state
|
||||
Given the panel is open and a check is pending
|
||||
Then the suggestions area shows "Checking this article…" and the fields stay usable
|
||||
|
||||
Rule: The panel (desktop slide-over, mobile 92% sheet)
|
||||
|
||||
Scenario: Opening
|
||||
When the user activates "Edit search and sharing" on desktop
|
||||
Then a slide-over 960 px wide opens with the title "Search and sharing", the fields, the previews, "Cancel" and "Save changes"
|
||||
And on mobile a sheet covering 92% of the height opens over the Details sheet, with the suggestions first, then the fields, then the two previews
|
||||
And a check is sent on open
|
||||
|
||||
Scenario: Defaults, every value is a fallback and says so
|
||||
Given the article has no SEO field set
|
||||
Then the panel shows "URL slug" with the Auto badge and "Generated from the title until you edit it."
|
||||
And "Meta title" with the chip "Using article title" and "Recommended 30 to 60 characters. This is a guide, not a limit."
|
||||
And "Meta description" with the chip "Using first sentence of the body" and "Recommended 70 to 160 characters."
|
||||
And "Excerpt" with "A short summary. It is also sent as the article teaser."
|
||||
And "Cover image" with "Using the site social image (north-wall.jpg)."
|
||||
And "Social sharing" collapsed with "Optional" and "Uses the meta values"
|
||||
And "Advanced" collapsed with "Canonical URL: automatic"
|
||||
And "Hide from search engines" off with "Sets noindex in the API. It doesn't change who can read the article."
|
||||
And "Search result", "Social card" and "Suggestions" blocks, "What the frontend gets", "Cancel" and "Save changes"
|
||||
|
||||
@not-in-design
|
||||
Scenario Outline: The other fallback chips
|
||||
Given <state>
|
||||
Then the chip reads "<chip>" and has a French entry
|
||||
|
||||
Examples:
|
||||
| state | chip |
|
||||
| no meta description but an excerpt | Using the excerpt |
|
||||
| no meta description, no excerpt, no body text | Using the site default description |
|
||||
| no cover and no site social image | No image |
|
||||
|
||||
Scenario: Filled in
|
||||
Given the article has its own meta title, description, excerpt, cover with alt text and the check answers no suggestion
|
||||
Then the fallback chips are gone and the suggestion block shows "All good" with "Nothing to suggest for this article."
|
||||
|
||||
Scenario Outline: Soft counters are a guide
|
||||
Given the user types <n> characters in <field>
|
||||
Then the counter reads "<n> / <max>" in the mono face and is <colour>
|
||||
And Save changes stays enabled and nothing is blocked
|
||||
|
||||
Examples:
|
||||
| field | n | max | colour |
|
||||
| meta title | 29 | 60 | amber (--ui-warning) |
|
||||
| meta title | 30 | 60 | neutral |
|
||||
| meta title | 60 | 60 | neutral |
|
||||
| meta title | 74 | 60 | amber (--ui-warning) |
|
||||
| meta description | 41 | 160 | amber (--ui-warning) |
|
||||
| meta description | 70 | 160 | neutral |
|
||||
| meta description | 160 | 160 | neutral |
|
||||
| meta description | 161 | 160 | amber (--ui-warning) |
|
||||
|
||||
Scenario: Outside the soft ranges
|
||||
Given the user typed the 74-character meta title and the 33-character description of the board
|
||||
Then both counters are amber and never red
|
||||
And the suggestion block shows the title_long and desc_short messages once the check answers
|
||||
And the search result preview shows the title cut off with "Cut off here, the way a search engine would."
|
||||
|
||||
Scenario: The API length limits are not reachable
|
||||
Given the API refuses seo_title above 120, seo_description above 320, cover_image_alt above 200, social_title above 120 and social_description above 320 characters
|
||||
Then each input stops accepting characters at its limit, so the API 400 for those fields is never produced by typing
|
||||
|
||||
Scenario: Excerpt over 300
|
||||
When the user types a 301-character excerpt
|
||||
Then the excerpt shows an inline error and "Save changes" is disabled until it is 300 or fewer
|
||||
And no request is sent for it
|
||||
|
||||
Scenario: Slug edited, Auto and Reset
|
||||
Given the slug is in Auto mode
|
||||
When the user activates the pencil
|
||||
Then the Auto badge is gone, the field is editable and "Reset to match the title" appears
|
||||
When the user activates "Reset to match the title"
|
||||
Then the field shows the slug generated from the title with the Auto badge, and the slug is sent as "" on save so that the API regenerates it
|
||||
And a check with {"slug": ""} confirms its availability
|
||||
|
||||
Scenario: Cover and alt text
|
||||
When the user activates "Choose image" in "Cover image"
|
||||
Then the shared Media picker opens titled "Choose a cover image" with "The cover for this article." and the action "Use image"
|
||||
When the user picks "north-wall.jpg"
|
||||
Then the cover shows its thumbnail and name, an "Alt text" field appears with "Needed before the cover counts as ready. Sent as image alt in the API." and the placeholder "Describe the image for people who can't see it"
|
||||
And a cover without alt text raises cover_alt as a suggestion, never as an error
|
||||
When the user removes the cover
|
||||
Then the "Using the site social image (north-wall.jpg)." line returns and cover_image_id is sent as null on save
|
||||
|
||||
Scenario: Social sharing
|
||||
When the user opens "Social sharing"
|
||||
Then "Social title" shows "Using meta title", "Social description" shows "Using meta description" and "Social image" shows "Using the cover image."
|
||||
When the user fills a social title, a description and picks a social image
|
||||
Then the three "Using …" chips disappear, "Customised" is shown and the social card shows the overrides
|
||||
|
||||
Scenario Outline: Advanced, canonical URL
|
||||
Given "Advanced" is open
|
||||
When the canonical URL field holds "<value>" and loses focus
|
||||
Then <result>
|
||||
|
||||
Examples:
|
||||
| value | result |
|
||||
| renew.example/costs | the field shows "Enter a full address starting with https://" and the field is invalid |
|
||||
| http://renew.example/a | the field shows "Enter a full address starting with https://" |
|
||||
| https://renew.example/a | no error is shown |
|
||||
| | no error is shown and "Canonical URL: automatic" is shown when collapsed |
|
||||
|
||||
Scenario: Canonical refused by the API
|
||||
When PATCH /v1/article/12 answers 422 "invalid_url"
|
||||
Then "Advanced" opens, the canonical field shows "Enter a full address starting with https://" and the values are kept
|
||||
|
||||
Scenario: Hide from search engines
|
||||
When the user turns the switch on
|
||||
Then the helper "Sets noindex in the API. It doesn't change who can read the article." is shown and noindex true is sent on save
|
||||
And the next check answers status "hidden"
|
||||
|
||||
Scenario Outline: The search result preview
|
||||
Given the meta title is "How we price a bathroom renovation: surveys, plumbing, tiling and finishes" and the site title template is "%title% | %site%"
|
||||
When the Search result toggle is on "<device>"
|
||||
Then the title shown is cut at <title> characters (the template included) with an ellipsis and the description at <desc> characters
|
||||
And the width of the preview changes with the device
|
||||
|
||||
Examples:
|
||||
| device | title | desc |
|
||||
| Desktop | 60 | 160 |
|
||||
| Mobile | 70 | 120 |
|
||||
|
||||
Scenario: The previews follow the typing, with the fallbacks
|
||||
Given the user clears the meta title
|
||||
Then the preview shows the article title with the site's title_template applied
|
||||
And the social card shows 1.91:1 image, the host, the title and a 2-line description, with fallback chips
|
||||
|
||||
Scenario: Keyboard
|
||||
Then Tab visits the fields in the order drawn, Escape closes the panel as "Cancel" does, and focus returns to "Edit search and sharing"
|
||||
|
||||
Rule: Slug availability
|
||||
|
||||
Scenario: Checked on blur
|
||||
Given the user typed "bathroom-costs" in the slug field
|
||||
When the field loses focus
|
||||
Then one POST /v1/article/12/seo/check is sent with {"slug": "bathroom-costs"}
|
||||
When it answers slug {"available": false, "suggestion": "bathroom-costs-2"}
|
||||
Then the field shows 'The slug "bathroom-costs" is already used on Renew.' and the action "Use bathroom-costs-2"
|
||||
|
||||
Scenario: Use the suggestion
|
||||
Given the slug-taken state is shown
|
||||
When the user activates "Use bathroom-costs-2"
|
||||
Then the slug field holds "bathroom-costs-2" in manual mode, the error and the action disappear and a check confirms it
|
||||
|
||||
Scenario: Slug taken on save
|
||||
Given the user saved with the slug "bathroom-costs"
|
||||
When PATCH /v1/article/12 answers 409 "slug_taken"
|
||||
Then the panel stays open with the slug-taken state, the action "Use <suggestion>" is offered (the suggestion comes from a follow-up check, as the 409 headers are not readable through useApi) and the other values stay
|
||||
And nothing of the edit is applied to the article and the status stays "Unsaved changes"
|
||||
|
||||
Scenario: A slug the article already owns is available
|
||||
Given the check answers slug {"available": true, "suggestion": null}
|
||||
Then no error and no action are shown
|
||||
|
||||
Rule: Saving, publishing and leaving treat SEO edits like any other edit
|
||||
|
||||
Scenario: Save changes commits the panel to the draft and saves
|
||||
Given the user changed the meta title in the panel and nothing else
|
||||
When the user activates "Save changes"
|
||||
Then exactly one PATCH /v1/article/12 is sent containing seo_title only
|
||||
And fields that did not change are not sent, and the unchanged body, site_id and author_ids are not sent
|
||||
|
||||
Scenario: Saving state
|
||||
Given "Save changes" was activated
|
||||
Then while the request runs every field of the panel is disabled at 40%, the status reads "Saving…" and the page behind stays as it was
|
||||
|
||||
Scenario: Saved
|
||||
When the PATCH answers 200
|
||||
Then the panel closes, the editor status reads "Saved", the summary and the suggestions refresh with a new check
|
||||
And a success toast shows "Search and sharing saved" with "Your search and sharing settings are up to date."
|
||||
|
||||
Scenario Outline: Save failed
|
||||
When the PATCH fails with <failure>
|
||||
Then the panel stays open with the notice "Couldn't save search and sharing. Your changes are still here. Try again in a moment."
|
||||
And the values are kept, the fields are enabled again and the editor shows its "Save failed" state with Retry
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| status 500 |
|
||||
| a network error |
|
||||
|
||||
Scenario: A body or site edit pending in the editor is saved too
|
||||
Given the user edited the body in the editor and then changed the meta title in the panel
|
||||
When the user activates "Save changes"
|
||||
Then one PATCH is sent containing body and seo_title
|
||||
|
||||
Scenario: The editor Save button treats SEO edits like any other edit
|
||||
Given the panel was closed after "Save changes" failed, with the draft holding seo_title
|
||||
Then the editor status reads "Unsaved changes", Save is enabled and "Publish" reads "Save and publish"
|
||||
When the user activates Save, presses Ctrl or Cmd S, or activates "Save and publish"
|
||||
Then the PATCH holds seo_title and the publish request follows for "Save and publish"
|
||||
|
||||
Scenario: Leaving with unsaved SEO edits
|
||||
Given the draft holds an unsaved SEO value
|
||||
When the user navigates away
|
||||
Then the "Leave without saving?" dialog is shown
|
||||
|
||||
Scenario: Session expiry keeps the SEO draft
|
||||
Given the draft holds unsaved SEO values
|
||||
When PATCH answers 401
|
||||
Then the draft, SEO values included, is mirrored to sessionStorage and the user lands on /login?redirect=/article/12/edit
|
||||
And after logging in the editor restores the draft, shows "Unsaved changes" and the panel fields show the restored values
|
||||
|
||||
Scenario: A storage that throws never loses the editor
|
||||
Given sessionStorage throws on every call
|
||||
Then saving, leaving and the mirror still work with the values in memory
|
||||
|
||||
Scenario: Suggestions never block
|
||||
Given the check answers suggestions or the status "hidden"
|
||||
Then Save, "Save and publish", Publish and Ctrl/Cmd S stay enabled when the draft is dirty and no request is delayed or refused because of them
|
||||
|
||||
Scenario: Cancel
|
||||
Given the user typed in the panel
|
||||
When the user activates "Cancel"
|
||||
Then the panel closes and the values typed in it are discarded without changing the editor status
|
||||
And no request is sent
|
||||
|
||||
Scenario: Offline
|
||||
Given the connection dropped
|
||||
Then the check and the dialog GET fail as in "The check could not run" and "Error", and writing continues
|
||||
|
||||
Scenario Outline: Roles
|
||||
Given the session role is "<role>"
|
||||
Then the section, the panel and the dialog are available
|
||||
|
||||
Examples:
|
||||
| role |
|
||||
| owner |
|
||||
| admin |
|
||||
| verified |
|
||||
|
||||
Rule: "What the frontend gets" (ApiPreviewDialog)
|
||||
|
||||
Scenario: Opening from the section and from the panel
|
||||
When the user activates "What the frontend gets" in the section or in the panel
|
||||
Then GET /v1/article/12/seo is requested once on each opening
|
||||
And on desktop a 720 px dialog opens, on mobile a bottom sheet
|
||||
|
||||
Scenario: Formatted
|
||||
Then the dialog shows "What the frontend gets", "The SEO data returned by GET /v1/article/12/seo, read only. RenewCMS doesn't render pages or add meta tags: your frontend does.", the tabs "Formatted" and "Raw JSON", "Copy JSON" and "Close"
|
||||
And Formatted lists the keys title, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at and json_ld as a compact key and value list
|
||||
And each value that came from a fallback (per the fallbacks map) carries a chip
|
||||
|
||||
@not-in-design
|
||||
Scenario Outline: Fallback chips in the dialog
|
||||
Given fallbacks.<key> is "<source>"
|
||||
Then the chip next to <key> reads "<chip>" and has a French entry
|
||||
|
||||
Examples:
|
||||
| key | source | chip |
|
||||
| title | title | Using article title |
|
||||
| description | body | Using first sentence of the body |
|
||||
| description | excerpt | Using the excerpt |
|
||||
| description | site | Using the site default description |
|
||||
| image | cover | Using the cover image |
|
||||
| image | site | Using the site social image |
|
||||
| canonical | site | Using the site address and the slug |
|
||||
| title | seo_title | no chip |
|
||||
| image | none | No image |
|
||||
|
||||
Scenario: Raw JSON
|
||||
When the user activates "Raw JSON"
|
||||
Then the response is shown exactly as the API returned it, as pretty-printed JSON in the mono face
|
||||
|
||||
Scenario: Copy JSON
|
||||
Given the response is loaded
|
||||
When the user activates "Copy JSON"
|
||||
Then the raw object is written to the clipboard as pretty-printed JSON
|
||||
And the button reads "Copied" for 2 seconds and "Copied" is announced in a live region
|
||||
And after 2 seconds it reads "Copy JSON" again
|
||||
|
||||
@not-in-design
|
||||
Scenario: The clipboard is refused
|
||||
Given navigator.clipboard is unavailable or rejects
|
||||
When the user activates "Copy JSON"
|
||||
Then the button keeps reading "Copy JSON", no "Copied" is announced and no error reaches the user
|
||||
|
||||
Scenario: Draft article
|
||||
Given article 12 is a draft
|
||||
Then the dialog shows "This article is a draft. The API returns this data only to signed-in callers until it is published." above the data
|
||||
And the data is shown, since the owner may read drafts
|
||||
|
||||
Scenario: Loading
|
||||
Given GET /v1/article/12/seo is pending
|
||||
Then the dialog shows a loading state and "Copy JSON" is disabled
|
||||
|
||||
Scenario Outline: Error with retry
|
||||
When GET /v1/article/12/seo fails with <failure>
|
||||
Then the dialog shows "Couldn't load the SEO data", "GET /v1/article/12/seo failed. Your article is unchanged. Try again in a moment." and "Try again"
|
||||
And the article, the draft and the status are unchanged
|
||||
When the user activates "Try again"
|
||||
Then GET /v1/article/12/seo is requested again
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| status 500 |
|
||||
| a network error |
|
||||
|
||||
Scenario: The dialog is read only
|
||||
Then it sends no PATCH and no POST, and "Close" or Escape closes it and returns focus to its opener
|
||||
|
||||
Rule: Layering, copy and visual conformance
|
||||
|
||||
Scenario: Every call goes through useApi
|
||||
Then no component calls fetch directly and every URL is relative
|
||||
|
||||
Scenario: French
|
||||
Given the language is "fr"
|
||||
Then every new string, including the messages of the 11 suggestion codes, the "Go to <field>" labels, the chips and the @not-in-design copy, is the French message
|
||||
And a missing French entry fails the i18n spec
|
||||
|
||||
Scenario: Visual conformance
|
||||
Then `task visual:check -- editor` passes every Editor frame, existing ones and the 30 new ones (24 + 6), at 1440x900 and 390x844, in light and dark
|
||||
And no per-state exception is used without the user's approval
|
||||
@@ -0,0 +1,65 @@
|
||||
# QA: Editor, search and sharing
|
||||
|
||||
SPA slice only. No Go change: the API of `features/seo_data_backend.feature` and `features/seo_output_backend.feature` already exists. A wrong contract is reported as a backend defect.
|
||||
|
||||
Roles: the domain defines only `owner` and `admin`. "verified" is a verified account without either role.
|
||||
|
||||
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi`'s `request` per case for GET and PATCH /v1/article/{id}, POST /v1/article/{id}/seo/check, GET /v1/article/{id}/seo, GET /v1/site/ and the media routes of the picker. Mount EditorView inside DashboardShell with a resolved session and a test router. Mock matchMedia for desktop and mobile. Use fakes for localStorage and sessionStorage. Use Vitest fake timers for the debounce delay and the 2 s "Copied", and do not freeze the system date. The clipboard is a stub on navigator.clipboard. Report the web tests as run or NOT RUN, never as passing when not run. Names below are suggestions; one spec file per numbered group, under `web/src/views/__tests__/` (`EditorSeo*.spec.ts`).
|
||||
|
||||
Regression: every existing Editor spec (EditorView, EditorMetaSection, EditorAuthorList, EditorAddAuthor, EditorImageUpload, EditorMediaPicker, EditorVariants, ArticleEditor*, EditorStatusBar) now sees a POST /v1/article/{id}/seo/check on load. Give them a default mock (`{status: "ready", suggestions: [], slug: {available: true, suggestion: null}}`) rather than loosening unmocked-call checks, and show they still pass.
|
||||
|
||||
1. Section: position under "Site and authors", collapsed default, summary Examples (pending, ready, 1 and 2 suggestions, hidden) with the colour class, Loading skeleton summary, no section on 404, mobile sheet placement.
|
||||
2. Memory, with the storage fallback (see "Red/green proof"): own key distinct from `renewcms-meta-section-open`, shared by sidebar and sheet, wins over nothing else, both `getItem` and `setItem` throwing SecurityError, no API call. Opening via "Edit search and sharing", "What the frontend gets" or a suggestion link does not change the stored choice.
|
||||
3. Open section: address from the site's base_url host plus the slug, other site, ready block, up to three suggestions in response order with "Go to <field>" links, "+N more", checking text, hidden line, check failure with "Try again", 401 and 404 on the check.
|
||||
4. Suggestion copy: each of the 11 codes renders its message from params (the four drawn in the design with the exact copy, seven @not-in-design) and has a French entry; the link label Examples and their focus targets (seo_title, seo_description, cover_image_alt, cover_image_id, slug, body, author_ids).
|
||||
5. Check scheduling: exactly one check on load with `{}`; only changed fields in the body; unsaved body included; debounce Examples with fake timers (rapid typing sends nothing, a pause sends one with the latest value) for every text field; discrete changes check immediately; a late answer never overwrites a newer one (resolve out of order) and the stale request is aborted; no PATCH from any check; no client-side rule (a test that feeds a 74-character title and a check answer without `title_long` still shows no `title_long` message).
|
||||
6. Panel: opening on desktop and mobile with the right container, defaults with every chip, other chips (@not-in-design), filled in, soft counter Examples at 29, 30, 60, 61 (title) and 69, 70, 160, 161 (description) with the amber class and no error colour, maxlength at the API limits, excerpt 300 vs 301 with Save changes disabled, slug Auto/pencil/Reset (sends `""`), cover picker with "Use image", alt text appearing with a cover, cover removal sending null, social customised, canonical Examples (on blur and the 422 `invalid_url` reopening Advanced), noindex switch, preview truncation Examples (60 and 160 desktop, 70 and 120 mobile, template included), previews follow typing and fallbacks, Escape/Cancel discards, focus return.
|
||||
7. Slug availability: check on blur with `{"slug": …}`, taken state and `Use <slug>-2`, the suggestion applied and re-checked, 409 `slug_taken` on save keeps the panel open and offers the suggestion through a follow-up check, nothing applied, an owned slug shows no error.
|
||||
8. Save integration: "Save changes" sends one PATCH with `seo_title` only; body pending is sent along; unchanged fields are not sent (assert the exact JSON); Saving disables fields; Saved closes the panel, refreshes the check and shows the toast; 500 and network failure keep the panel, the notice and the values; editor Save, Ctrl/Cmd S and "Save and publish" after a failed panel save; the leave dialog with an unsaved SEO value; the 401 mirror to sessionStorage and the restored draft with the panel fields; sessionStorage throwing; suggestions never disable Save or Publish; Cancel sends nothing; offline; the three roles.
|
||||
9. ApiPreviewDialog: one GET per opening from both openers, desktop dialog and mobile sheet, Formatted keys and the chip Examples, Raw JSON equals the response, Copy JSON writes the pretty-printed raw object, "Copied" for 2 s (fake timers) and the live-region announcement, a rejected or missing clipboard, draft note when `is_online` is false, loading with Copy disabled, error with "Try again" for 500 and a network error, read-only (no PATCH, no POST), Escape/Close focus return.
|
||||
10. i18n: every new `t()` key has a French entry (extend the i18n spec); one French walk through the section, the panel and the dialog.
|
||||
11. API types: `Article` carries the SEO fields and `Site` carries `site_name`, `title_template`, `default_description` and `default_robots`; `useArticleDraft` mirrors the SEO fields in sessionStorage and sends only changed ones (unit test of `savePayload` through `save()`).
|
||||
|
||||
### Red/green proof for the storage fallback (required in the qa report)
|
||||
|
||||
`metaSectionMemory.ts` already wraps its storage calls in try/catch. For the new key, the spec "A storage that throws never breaks the editor" (both `getItem` and `setItem`) must be shown failing without the fix:
|
||||
1. Write the spec with a localStorage whose `getItem` and `setItem` throw `SecurityError`.
|
||||
2. Temporarily remove the try/catch from the new read/write helpers (or call localStorage directly), run `task test:web -- EditorSeoMemory`, and record the red run (the editor throws or the toggle does nothing).
|
||||
3. Restore the helpers, run again, and record the green run.
|
||||
4. Report both outputs. The temporary revert is not committed.
|
||||
|
||||
## Visual (`task visual:check -- editor`, desktop and mobile, light and dark)
|
||||
|
||||
Setups go in `web/visual/states.ts`, one setup per state, with mocked /v1 and the board's own sample data (article 12 "How we price a bathroom renovation", Renew, "Inès Duarte", media "north-wall.jpg" and "kitchen-after.webp", values from the frames). Hard gate: every capture within 10 %, with no per-state exception. Reference frames: `AppFrame.dc.html` (sidebar section), `DirectoryFrame.dc.html` (SeoPanel, SlugField, MediaField, picker) and the board `RenewCMS Screens Back-office.dc.html`.
|
||||
|
||||
- Existing Editor frames with the sidebar: the collapsed section with "Ready" under "Site and authors". Their setups need a default POST check mock. Loading shows the skeleton summary. Not found has none.
|
||||
- Editor: search and sharing (24): Collapsed ready, suggestions, hidden; Open ready, suggestions (4 codes: title_long, cover_alt, no_h, short), checking, hidden, other site (`site: 1` = Journal); Panel defaults (codes: cover_none), filled in, outside the soft ranges, slug edited, slug taken, cover without alt, choose cover, social sharing open (with scrollY 560 on desktop), social customised, advanced with invalid canonical (scrollY 640), hidden from search (scrollY 700), mobile search preview, checking, saving, save failed; Saved (toast).
|
||||
- Editor: what the frontend gets (6): Formatted, Raw JSON, Copied, Draft article, Loading, Error.
|
||||
- States that wait on a timer (the debounce, the 2 s "Copied") are set up by their end state; none depends on the date or time, so none is `skip: clock-sensitive`.
|
||||
- Re-run Articles, Sites, Authors, Profile, Settings and Home: report any capture that moves above 10 %. Any failure needs the user's decision (an `approvedBy` exception or a design update).
|
||||
|
||||
## Exit conditions
|
||||
|
||||
- All existing Editor frames and the 30 new frames pass `task visual:check -- editor` with no per-state exception.
|
||||
- Vitest specs reported honestly, run or NOT RUN, with the red/green proof attached.
|
||||
- `task lint` and `bun lint` clean; no comment added.
|
||||
|
||||
## Copy and behaviour not in the design (feed back to docs/design later)
|
||||
|
||||
Each has a French entry in `web/src/i18n/messages.ts`.
|
||||
|
||||
1. The copy of the codes the design does not draw: title_empty, desc_long, cover_none, slug_long, no_au, body_alt, short.
|
||||
2. The other fallback chips (excerpt, site default description, no image) and the chips of the dialog.
|
||||
3. The check failure text "Couldn't check this article." with "Try again" (no state in the design).
|
||||
4. The "Go to authors" and "Go to URL slug" labels, and where "Go to body" and "Go to authors" move focus.
|
||||
5. The clipboard-refused behaviour (no copy, no state).
|
||||
6. The excerpt-over-300 inline error text, and the "Save changes" disabled rule.
|
||||
|
||||
## Manual checks screenshots cannot cover
|
||||
|
||||
- Keyboard: tab order through the section, the panel (fields, Advanced, switch, previews, buttons) and the dialog; Escape; focus returns to the opener; focus lands on the field named by "Go to …"; "Copied" read by a screen reader.
|
||||
- Hover, focus ring and motion: slide-over and sheet transitions, the amber counter, the chip styles.
|
||||
- Real `task dev`: edit a title, description and cover, watch the check update after a pause, take a slug used by another article (409 flow and "Use …-2"), Save, then open "What the frontend gets" and compare it with `curl` on GET /v1/article/{id}/seo; hide from search and see "Hidden from search"; clipboard on a real browser over http://localhost.
|
||||
- Storage: block site data in the browser and check that the editor still works and the section still toggles.
|
||||
Reference in new issue
Block a user