feat: SEO SPA Sites, Articles and Integration (4/4)
This commit is contained in:
1 parent
cb737b7ae3
commit
02fb8751fd
47 files changed
+3218
-227
No files matched your search
@@ -0,0 +1,505 @@
|
|||||||
|
Feature: SEO round, slice 4: Sites defaults, Articles search status, Integration "Search and sharing" and suggestion dismissal
|
||||||
|
|
||||||
|
As the owner of a RenewCMS install, and as anyone who edits articles or builds a frontend on the API
|
||||||
|
I want to set the site-wide SEO defaults, see which articles need attention, dismiss suggestions I do not want, and read how the SEO routes work
|
||||||
|
So that every article resolves good SEO data and a developer can find the routes that serve it
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given the screens are SitesView (/settings/sites), ArticleView (/article, also reached as /articles), IntegrationView (/integration) and the Editor SEO panel (/article/:id/edit), inside DashboardShell, per docs/design/routes.md
|
||||||
|
And the visual spec of record is docs/design/screens/sites.md ("Sites: SEO defaults", 5 states), articles.md ("Articles: search status", 3 states), integration.md ("Search and sharing"), editor.md (SuggestionList), docs/design/components.md (SeoStatusChip, SuggestionList), docs/design/seo-slices.md 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/DirectoryFrame.dc.html (Sites form, SuggestionList), AppFrame.dc.html (Articles rows, cards and toolbar) and IntegrationPage.dc.html (group and index) are the layout tiebreaker
|
||||||
|
And this slice is SPA only and uses the API of features/seo_data_backend.feature and features/seo_output_backend.feature without change. No Go file changes
|
||||||
|
And the SPA calls, with relative URLs through useApi only: GET /v1/site/, POST /v1/site/ and PATCH /v1/site/{id} (site_name, title_template, default_description), GET /v1/article/ with the optional seo=suggestions parameter, and PATCH /v1/article/{id} with seo_dismissed. The Integration page makes no request
|
||||||
|
And an article list item carries seo_status ("ready", "suggestions" or "hidden") and seo_suggestions (a count)
|
||||||
|
And the SPA never re-implements a suggestion rule or an SEO status. It draws the status and the count the list returned
|
||||||
|
And the API refuses a title_template without the exact, case-sensitive token %title% with 400 invalid_title_template, and refuses site_name above 100, title_template above 200 and default_description above 320 characters
|
||||||
|
And a new site that omits the three fields gets site_name "", title_template "%title% | %site%" and default_description ""
|
||||||
|
And the API refuses a seo_dismissed code outside the 11 known codes with 400, replaces the saved list with the list sent, collapses duplicates, and never reports or counts a dismissed code
|
||||||
|
And the sample site is Renew (id 1, default, renew.example), the sample article title is "How we price a bathroom renovation", and the sample rows are those of the board frame "Articles: search status"
|
||||||
|
And the roles are those of features/sites_authors_integration.feature: only the owner reaches the Sites form
|
||||||
|
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 screens use 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: Sites form: Site name, Title template and Default description (owner only)
|
||||||
|
|
||||||
|
Scenario: Field order in the form, as the board draws it
|
||||||
|
Given the owner opens "Edit Renew"
|
||||||
|
Then the fields appear in this order: Name, Key, Base URL, Default social image, Site name, Title template (with its Example), Default description, Default robots policy, Locale, the default switch
|
||||||
|
And under the fields come the "Canonical URL preview" block and then the "How articles use these defaults" block
|
||||||
|
And the slide-over keeps its existing width, header, "Cancel" and "Save changes"
|
||||||
|
|
||||||
|
Scenario: Edit: SEO defaults
|
||||||
|
Given the site Renew has site_name "Renew", title_template "%title% | %site%" and default_description ""
|
||||||
|
When the owner opens "Edit Renew"
|
||||||
|
Then "Site name" shows "Renew" with "Shown in titles through the template, and sent as the Open Graph site name."
|
||||||
|
And "Site name" has no "(optional)" label and an empty value is allowed
|
||||||
|
And "Title template" shows "%title% | %site%" in the mono face with "Use %title% for the article title and %site% for the site name."
|
||||||
|
And the "Example" row reads "How we price a bathroom renovation | Renew"
|
||||||
|
And "Default description" is marked optional, shows the placeholder "One or two sentences about the site." and the counter "0 / 320"
|
||||||
|
And the helper reads "Used when an article has no description, excerpt or body text."
|
||||||
|
And the block "How articles use these defaults" lists Title "Article title, then the template.", Description "Used last, after the article's description, excerpt and body.", Image "Used when an article has no cover image." and Robots "Used unless an article is hidden from search engines."
|
||||||
|
|
||||||
|
Scenario: Add: defaults
|
||||||
|
When the owner opens "Add site"
|
||||||
|
Then "Site name" is empty with the placeholder "e.g. Renew"
|
||||||
|
And "Title template" holds "%title% | %site%"
|
||||||
|
And "Default description" is empty with the placeholder "One or two sentences about the site."
|
||||||
|
And the "How articles use these defaults" block is shown
|
||||||
|
|
||||||
|
Scenario Outline: The live example follows the typing
|
||||||
|
Given the form is open and "Site name" holds "<site_name>" and "Name" holds "<name>"
|
||||||
|
When the owner types "<template>" in "Title template"
|
||||||
|
Then the "Example" row reads "<example>"
|
||||||
|
And no request is sent
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| name | site_name | template | example |
|
||||||
|
| Journal | Journal | %title% – %site% | How we price a bathroom renovation – Journal |
|
||||||
|
| Journal | | %title% – %site% | How we price a bathroom renovation – Journal |
|
||||||
|
| Journal | Le Journal | %site%: %title% | Le Journal: How we price a bathroom renovation |
|
||||||
|
| Journal | Journal | %title% | How we price a bathroom renovation |
|
||||||
|
| Journal | Journal | %site% / %site% | Journal / Journal |
|
||||||
|
|
||||||
|
@not-in-design
|
||||||
|
Scenario: The example uses the site name, then the name, then the placeholder
|
||||||
|
Given "Site name" and "Name" are both empty
|
||||||
|
Then the Example row uses "Renew" as the site name
|
||||||
|
And when "Site name" is empty and "Name" is "Journal", it uses "Journal", as the API does
|
||||||
|
|
||||||
|
Scenario Outline: Template: not valid
|
||||||
|
Given the owner typed "<template>" in "Title template"
|
||||||
|
When the field loses focus or the owner activates "Save changes"
|
||||||
|
Then the field shows the error "The template must contain %title%." and the Example row is not shown
|
||||||
|
And nothing is sent, the slide-over stays open and focus goes to the first invalid field
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| template |
|
||||||
|
| just text |
|
||||||
|
| %site% |
|
||||||
|
| %TITLE% |
|
||||||
|
| |
|
||||||
|
|
||||||
|
Scenario: A valid template clears the error
|
||||||
|
Given "Title template" shows "The template must contain %title%."
|
||||||
|
When the owner types "%title%"
|
||||||
|
Then the error is gone and the Example row is shown again
|
||||||
|
|
||||||
|
Scenario: The API refuses a template the form accepted
|
||||||
|
When PATCH /v1/site/1 answers 400 "invalid_title_template"
|
||||||
|
Then "Title template" shows "The template must contain %title%." and the values are kept
|
||||||
|
|
||||||
|
Scenario Outline: The inputs stop at the API limits
|
||||||
|
Given the owner types a long text in <field>
|
||||||
|
Then the input stops accepting characters at <limit>, so the API 400 for those fields is never produced by typing
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| field | limit |
|
||||||
|
| Site name | 100 |
|
||||||
|
| Title template | 200 |
|
||||||
|
| Default description | 320 |
|
||||||
|
|
||||||
|
Scenario Outline: The description counter
|
||||||
|
When the owner types <n> characters in "Default description"
|
||||||
|
Then the counter reads "<n> / 320" and nothing is blocked
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| n |
|
||||||
|
| 0 |
|
||||||
|
| 45 |
|
||||||
|
| 320 |
|
||||||
|
|
||||||
|
Scenario: Saving only sends what changed
|
||||||
|
Given the owner opened "Edit Renew" and changed "Title template" to "%title% – %site%" and nothing else
|
||||||
|
When the owner activates "Save changes"
|
||||||
|
Then exactly one PATCH /v1/site/1 is sent containing title_template "%title% – %site%" only
|
||||||
|
And on 200 the slide-over closes and the existing saved behaviour of the Sites list applies
|
||||||
|
|
||||||
|
Scenario: Several SEO fields at once
|
||||||
|
Given the owner changed "Site name" to "Renew Journal", "Default description" to "Notes on renewal work." and "Default robots policy" to "Noindex"
|
||||||
|
When the owner activates "Save changes"
|
||||||
|
Then exactly one PATCH /v1/site/1 is sent containing site_name, default_description and default_robots
|
||||||
|
And values are compared and sent trimmed, as the existing Name field is
|
||||||
|
|
||||||
|
Scenario: Creating a site with the defaults untouched
|
||||||
|
Given the owner filled Name, Key and Base URL and left the three SEO fields as they were
|
||||||
|
When the owner activates "Add site"
|
||||||
|
Then POST /v1/site/ is sent without site_name, title_template or default_description
|
||||||
|
And the created site has the API defaults
|
||||||
|
|
||||||
|
Scenario: Creating a site with SEO values
|
||||||
|
Given the owner typed "Renew Shop" as site name, "%title% - %site%" as template and "A shop about renewal." as description
|
||||||
|
When the owner activates "Add site"
|
||||||
|
Then POST /v1/site/ holds site_name, title_template and default_description with those values
|
||||||
|
|
||||||
|
Scenario: A site without the new fields
|
||||||
|
Given GET /v1/site/ returned a site with no site_name, title_template or default_description
|
||||||
|
When the owner opens its edit form
|
||||||
|
Then "Site name" and "Default description" are empty and "Title template" holds "%title% | %site%"
|
||||||
|
And "Save changes" with no other change sends no request
|
||||||
|
|
||||||
|
Scenario: The new fields count as changes
|
||||||
|
Given the owner typed in any of the three fields
|
||||||
|
When the owner closes the slide-over
|
||||||
|
Then the existing "Discard changes?" dialog is shown
|
||||||
|
|
||||||
|
Scenario Outline: Save failed
|
||||||
|
When the save fails with <failure>
|
||||||
|
Then the slide-over stays open with the notice "Couldn't save the site. Your changes are still here. Try again in a moment."
|
||||||
|
And the values of the three fields are kept
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| failure |
|
||||||
|
| status 500 |
|
||||||
|
| a network error |
|
||||||
|
|
||||||
|
Scenario: Owner only, as on the rest of Sites
|
||||||
|
Given the session role is "admin" or "verified"
|
||||||
|
Then there is no Sites entry and no form
|
||||||
|
When the user opens /settings/sites or /settings/sites?edit=renew from a bookmark
|
||||||
|
Then the existing 403 page "You don't have access to Sites" is shown, with nothing about SEO added
|
||||||
|
|
||||||
|
Scenario: A 403 owner_only on save is handled like any Sites save
|
||||||
|
Given the owner's role changed while the form was open
|
||||||
|
When PATCH /v1/site/1 answers 403 owner_only
|
||||||
|
Then the existing action toast "Only the owner can manage sites" with "Ask the owner of this site." is shown, as for the other Sites fields
|
||||||
|
|
||||||
|
Scenario: A 401 sends the user to login
|
||||||
|
When PATCH /v1/site/1 answers 401
|
||||||
|
Then the user lands on /login?redirect=/settings/sites&reason=expired, as on the rest of Sites
|
||||||
|
|
||||||
|
Rule: Articles list: the search status chip (SeoStatusChip)
|
||||||
|
|
||||||
|
Scenario Outline: The chip follows the status the list returns
|
||||||
|
Given an article item with seo_status "<status>" and seo_suggestions <count>
|
||||||
|
Then the Status cell shows <cell>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| status | count | cell |
|
||||||
|
| ready | 0 | its Online or Draft badge and no SEO chip |
|
||||||
|
| suggestions | 2 | its badge and a warning-soft chip with a lightbulb icon and "2" |
|
||||||
|
| suggestions | 1 | its badge and a warning-soft chip with a lightbulb icon and "1" |
|
||||||
|
| hidden | 0 | its badge and an outline chip with an eye-off icon and "Hidden" |
|
||||||
|
| hidden | 2 | its badge and an outline chip with an eye-off icon and "Hidden", no count |
|
||||||
|
|
||||||
|
Scenario: Chip placement on desktop
|
||||||
|
Then the chip sits in the Status cell, in the same row as the Online or Draft badge, 6 px after it, 22 px high, with a 12 px icon
|
||||||
|
And the Status cell is wide enough that the chip never wraps below the badge in the board's 1440 px frame
|
||||||
|
|
||||||
|
Scenario: Chip placement on mobile cards
|
||||||
|
Given the viewport is 390 px wide
|
||||||
|
Then the chip sits in the card's meta row under the title, after the Online or Draft badge and before "Updated …", 20 px high
|
||||||
|
And the card has no other new element
|
||||||
|
|
||||||
|
Scenario Outline: Tooltips and accessible names
|
||||||
|
Given a chip with <status> and <count>
|
||||||
|
Then its tooltip and its accessible name read "<text>"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| status | count | text |
|
||||||
|
| suggestions | 1 | 1 suggestion |
|
||||||
|
| suggestions | 2 | 2 suggestions |
|
||||||
|
| suggestions | 3 | 3 suggestions |
|
||||||
|
| hidden | 0 | Hidden from search engines |
|
||||||
|
|
||||||
|
@not-in-design
|
||||||
|
Scenario: A touch device has no hover, so the name is read
|
||||||
|
Given the viewport is 390 px wide
|
||||||
|
Then the chip carries its text for assistive technology, and the visible mobile chip is the same as on desktop
|
||||||
|
|
||||||
|
@not-in-design
|
||||||
|
Scenario Outline: Items the chip cannot draw show nothing
|
||||||
|
Given an article item <item>
|
||||||
|
Then no SEO chip is shown and nothing breaks
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| item |
|
||||||
|
| without seo_status |
|
||||||
|
| with seo_status "suggestions" and seo_suggestions 0 |
|
||||||
|
| with an unknown seo_status |
|
||||||
|
|
||||||
|
@not-in-design
|
||||||
|
Scenario: A row action keeps the chip
|
||||||
|
Given a row with a chip
|
||||||
|
When the user publishes or unpublishes it and the PATCH answers 200
|
||||||
|
Then the row updates in place and the chip is unchanged
|
||||||
|
And the chip is refreshed by the next list load
|
||||||
|
|
||||||
|
Scenario: The chip changes nothing else in the row
|
||||||
|
Then the sort, the row menu, the Authors and Site columns, the busy state and the delete flow behave as before
|
||||||
|
|
||||||
|
Rule: Articles list: the "Needs attention" toggle
|
||||||
|
|
||||||
|
Scenario: The toggle sits next to the search field
|
||||||
|
Then on desktop a 32 px "Needs attention" switch with a lightbulb icon and a count sits directly left of the search field, on the toolbar baseline
|
||||||
|
And on mobile a 44 px "Needs attention" switch with the same content is part of the toolbar stack, above the Site filter when it exists
|
||||||
|
And off, it has the default background, an accented border and the toned text colour
|
||||||
|
And on, it uses --rc-sand as fill and border, --rc-on-sand as text and weight 500
|
||||||
|
And it is a switch: role "switch" with aria-checked, reachable by Tab, toggled by Space and Enter
|
||||||
|
|
||||||
|
Scenario: Turning it on
|
||||||
|
Given the URL is /articles?page=3&size=50&q=plaster&status=online&sort=title:asc&site=journal
|
||||||
|
When the user turns "Needs attention" on
|
||||||
|
Then the URL becomes /articles?size=50&q=plaster&status=online&sort=title:asc&site=journal&seo=suggestions with page omitted
|
||||||
|
And GET /v1/article/ is requested with seo=suggestions, size 50, q "plaster", status online, sort title:asc, site journal and page 1
|
||||||
|
And the tab counts are unchanged
|
||||||
|
|
||||||
|
Scenario: Turning it off
|
||||||
|
Given the URL holds ?seo=suggestions&page=2
|
||||||
|
When the user turns the switch off
|
||||||
|
Then seo leaves the URL, the page resets to 1, and q, status, sort, site and size are kept
|
||||||
|
And GET /v1/article/ is requested without seo
|
||||||
|
|
||||||
|
Scenario Outline: Other changes keep the filter
|
||||||
|
Given the URL holds ?seo=suggestions
|
||||||
|
When the user <action>
|
||||||
|
Then ?seo=suggestions stays in the URL
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| action |
|
||||||
|
| searches for "plaster" |
|
||||||
|
| changes the tab |
|
||||||
|
| changes the sort |
|
||||||
|
| chooses a site |
|
||||||
|
| goes to page 2 |
|
||||||
|
| changes the page size |
|
||||||
|
|
||||||
|
Scenario Outline: Changes made while the filter is on reset the page, as everywhere else
|
||||||
|
Given the URL holds ?seo=suggestions&page=2
|
||||||
|
When the user <action>
|
||||||
|
Then the page resets to 1 and ?seo=suggestions stays
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| action |
|
||||||
|
| searches for "plaster" |
|
||||||
|
| changes the tab |
|
||||||
|
| changes the sort |
|
||||||
|
| chooses a site |
|
||||||
|
| changes the page size |
|
||||||
|
|
||||||
|
Scenario: Opening a URL with the filter
|
||||||
|
When the user opens /articles?seo=suggestions&page=2
|
||||||
|
Then the switch is on and GET /v1/article/?seo=suggestions&page=2 is requested
|
||||||
|
|
||||||
|
Scenario Outline: Unknown seo values are dropped silently
|
||||||
|
Given the URL holds <query>
|
||||||
|
Then seo is removed from the URL with no notice, the switch is off and the unfiltered list is requested
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| query |
|
||||||
|
| ?seo=maybe |
|
||||||
|
| ?seo= |
|
||||||
|
| ?seo=READY |
|
||||||
|
| ?seo=ready |
|
||||||
|
| ?seo=hidden |
|
||||||
|
| ?seo=suggestions,x |
|
||||||
|
|
||||||
|
Scenario: Needs attention on
|
||||||
|
Given the filter is on and the response holds the three articles with suggestions
|
||||||
|
Then the list shows only those three articles, each with its chip
|
||||||
|
And hidden-only articles are not in the list, because the API does not return them for seo=suggestions
|
||||||
|
And the tab counts still show the full counts of the unfiltered list
|
||||||
|
|
||||||
|
Scenario: Same behaviour on a single-site install
|
||||||
|
Given GET /v1/site/ returned one site
|
||||||
|
Then the Site column and the Site filter are not rendered
|
||||||
|
And the switch, the chips and ?seo=suggestions behave exactly as with several sites
|
||||||
|
|
||||||
|
Scenario Outline: The count on the switch
|
||||||
|
Given the count request answers <answer>
|
||||||
|
Then the switch reads <label>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| answer | label |
|
||||||
|
| total 3 | "Needs attention" with the count "3" in the mono face |
|
||||||
|
| total 0 | "Needs attention" with the count "0" |
|
||||||
|
| still pending | "Needs attention" with no count |
|
||||||
|
| status 500 or a network error | "Needs attention" with no count and no notice |
|
||||||
|
|
||||||
|
@not-in-design
|
||||||
|
Scenario: Where the count comes from
|
||||||
|
Given the switch is off
|
||||||
|
Then one extra GET /v1/article/?seo=suggestions&page=1&size=5 is sent with the current q, status and site, and its total is the count
|
||||||
|
And the count is read from the main response total when the switch is on, with no extra request
|
||||||
|
And the count request is refreshed whenever q, tab or site change, and never when only the page, the size or the sort change
|
||||||
|
And a failing count request never blocks the list, never shows a notice and never changes the URL
|
||||||
|
|
||||||
|
Scenario: Needs attention: none
|
||||||
|
Given ?seo=suggestions, no search, the tab "All", and the response is empty
|
||||||
|
Then the state shows "Nothing needs attention", "Every article has a clean check. Suggestions are optional, so this is just a bonus." and "Show all articles"
|
||||||
|
And the paginator and summary are hidden
|
||||||
|
When the user activates "Show all articles"
|
||||||
|
Then seo leaves the URL, the page resets to 1, the other query values are kept and the list is requested without seo
|
||||||
|
|
||||||
|
@not-in-design
|
||||||
|
Scenario Outline: Zero results with the filter and a search or another tab
|
||||||
|
Given ?seo=suggestions and <condition>
|
||||||
|
And the response is empty
|
||||||
|
Then the existing <state> is shown and the filter stays on
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| condition | state |
|
||||||
|
| a search for "tiles" | "No results" state |
|
||||||
|
| the tab "Draft" | "No drafts" state |
|
||||||
|
| the tab "Online" | "Nothing is online yet" state |
|
||||||
|
|
||||||
|
@not-in-design
|
||||||
|
Scenario: Zero results with the filter and a site
|
||||||
|
Given ?seo=suggestions&site=archive, no search, the tab "All" and the response is empty
|
||||||
|
Then "Nothing needs attention" is shown rather than "No articles on "Archive""
|
||||||
|
|
||||||
|
Scenario: Existing states keep working with the filter
|
||||||
|
Then Loading, Empty, row actions, delete, the Site filter and pagination behave as in docs/design/screens/articles.md with the filter applied
|
||||||
|
And an error loading a page keeps the previous rows and shows its Retry notice as before
|
||||||
|
|
||||||
|
Rule: Editor SEO panel: dismissing a suggestion (deferred from slice 3)
|
||||||
|
|
||||||
|
Scenario: The dismiss column and the footnote, as the board draws them
|
||||||
|
Given the SEO panel is open and the check answers title_long and cover_alt
|
||||||
|
Then each suggestion row ends with a dismiss "×" button, as in the board frame
|
||||||
|
And under the list the footnote reads "Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article."
|
||||||
|
And the compact suggestion list of the open section keeps no dismiss button and no footnote, as drawn
|
||||||
|
|
||||||
|
Scenario: Accessible name and keyboard
|
||||||
|
Given the dismiss button of the row "The meta title is 74 characters. Search engines may cut it off after about 60."
|
||||||
|
Then its accessible name reads "Dismiss suggestion: The meta title is 74 characters. Search engines may cut it off after about 60." and it has a visible 2 px focus ring
|
||||||
|
And Tab reaches it after the row's "Go to meta title" link, and Enter or Space activates it
|
||||||
|
And the name has a French entry with the message interpolated
|
||||||
|
|
||||||
|
Scenario: Dismiss succeeds
|
||||||
|
Given the article saved seo_dismissed is ["no_au"] and the check answers title_long, cover_alt and no_au was already filtered out
|
||||||
|
When the user activates the dismiss button of title_long
|
||||||
|
Then exactly one PATCH /v1/article/12 is sent containing seo_dismissed ["no_au", "title_long"] and no other field
|
||||||
|
And the row stays and the button is disabled with a busy state until the PATCH answers
|
||||||
|
And the unsaved draft, the panel values and the editor status are not touched by the request
|
||||||
|
|
||||||
|
Scenario: Row removal and count update
|
||||||
|
When the PATCH answers 200
|
||||||
|
Then the title_long row leaves the list and the remaining rows keep the order of the check
|
||||||
|
And one new POST /v1/article/12/seo/check is sent so that the list, the section summary line and the article's status come from the server
|
||||||
|
And the summary reads "1 suggestion" when one suggestion remains, and "Ready" with "All good" when none remains
|
||||||
|
And the "Articles" chip count of this article is the one of the next list load
|
||||||
|
|
||||||
|
Scenario: Dismissing the last suggestion
|
||||||
|
Given one suggestion is left
|
||||||
|
When it is dismissed and the PATCH answers 200
|
||||||
|
Then "All good" is shown with "{n} dismissed suggestions are hidden." (the board's copy, with the French plural forms) and the link "Show dismissed"
|
||||||
|
And focus moves to the "Show dismissed" link
|
||||||
|
|
||||||
|
Scenario Outline: Focus after a row leaves
|
||||||
|
Given the list holds three rows and the user dismisses the <position> one
|
||||||
|
Then focus moves to the dismiss button of <target>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| position | target |
|
||||||
|
| first | the new first row |
|
||||||
|
| second | the row that took its place |
|
||||||
|
| last | the new last row |
|
||||||
|
|
||||||
|
Scenario Outline: Dismiss failed
|
||||||
|
When the PATCH fails with <failure>
|
||||||
|
Then the row stays, the button is enabled again and an error toast shows "Couldn't dismiss the suggestion" with "Try again in a moment."
|
||||||
|
And the panel, the draft and the editor status are unchanged and no check is sent
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| failure |
|
||||||
|
| status 500 |
|
||||||
|
| a network error |
|
||||||
|
|
||||||
|
Scenario Outline: A session problem during the dismissal follows the existing rules
|
||||||
|
When the PATCH 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 |
|
||||||
|
|
||||||
|
@not-in-design
|
||||||
|
Scenario: Show dismissed
|
||||||
|
Given the article has seo_dismissed ["no_au", "short"]
|
||||||
|
Then "All good" or the list footer shows "2 dismissed suggestions are hidden." and "Show dismissed"
|
||||||
|
When the user activates "Show dismissed"
|
||||||
|
Then exactly one PATCH /v1/article/12 is sent with seo_dismissed [] and, on 200, a new check is sent and the restored suggestions appear
|
||||||
|
|
||||||
|
Scenario: Dismissal is per article and never blocks anything
|
||||||
|
Then a dismissed code stays hidden for this article only and for every later check of it
|
||||||
|
And Save, "Save and publish", Publish and Ctrl or Cmd S stay enabled whatever the dismissed list is
|
||||||
|
|
||||||
|
Scenario: Who can dismiss
|
||||||
|
Given the session role is "owner", "admin" or "verified"
|
||||||
|
Then the dismiss button is available, since anyone who can edit the article edits its SEO fields
|
||||||
|
|
||||||
|
Rule: Integration: the "Search and sharing" group
|
||||||
|
|
||||||
|
Scenario: The group sits after Articles
|
||||||
|
Then web/src/data/apiCatalogue.ts has a group "Search and sharing" (key "seo") placed directly after "Articles" in API_GROUPS
|
||||||
|
And the group description reads "RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs."
|
||||||
|
And the group shows "2 routes"
|
||||||
|
|
||||||
|
Scenario: The two cards
|
||||||
|
Then the group holds GET /v1/article/{id}/seo and GET /v1/site/{key}/sitemap, both with the badge "Public" and "API key OK", collapsed by default
|
||||||
|
And GET /v1/article/{id}/seo is removed from the "Articles" group and GET /v1/site/{key}/sitemap is removed from the "Sites" group, so no route appears twice
|
||||||
|
And POST /v1/article/{id}/seo/check stays in "Articles"
|
||||||
|
|
||||||
|
Scenario: Article SEO card
|
||||||
|
When the user expands GET /v1/article/{id}/seo
|
||||||
|
Then the card describes the resolved SEO object: title with the site template applied, description, canonical_url, robots, og{type,title,description,url,site_name,image,image_alt,locale}, twitter{card,title,description,image,image_alt}, image{url,alt,source}, authors[{name,url,same_as}], published_at, modified_at, fallbacks and json_ld
|
||||||
|
And it says that drafts answer 404 to anonymous callers
|
||||||
|
And it shows "No request body.", the fetch, curl and Response tabs in one CodeSnippet with Copy, and the error rows 400, 401 and 404 of the real API
|
||||||
|
|
||||||
|
Scenario: Sitemap card
|
||||||
|
When the user expands GET /v1/site/{key}/sitemap
|
||||||
|
Then the card shows the query rows page ("A whole number from 1. Default 1.") and size ("25, 50, 100 or 500. Default 100."), the item shape { url, modified_at, noindex } and that the total is in the X-Total-Count header
|
||||||
|
And the path in its examples uses the sample site key
|
||||||
|
And it shows the error rows of the real API: 400 with the body-mismatch message, 401, and 404 "site_not_found"
|
||||||
|
|
||||||
|
Scenario: Sidebar index and Jump to select
|
||||||
|
Then the sticky section index lists, in this order, Authentication, Articles, Search and sharing, Media, Users and invites, Settings, Sites, Authors, Images and variants, Session only, Errors, Notes
|
||||||
|
And the mobile "Jump to" select lists the same options in the same order
|
||||||
|
When the user activates "Search and sharing" in the index
|
||||||
|
Then the page scrolls to the group and the item has aria-current while it is in view
|
||||||
|
And the group has an anchor and a deep link #seo
|
||||||
|
|
||||||
|
Scenario: Counts stay in sync
|
||||||
|
Then the cards per group are Articles 8, Search and sharing 2, Media 10, Users and invites 6, Settings 1
|
||||||
|
And the group "Sites" shows "1 route" and its description no longer mentions the sitemap
|
||||||
|
And the total number of endpoint cards in API_GROUPS is unchanged
|
||||||
|
And web/src/data/__tests__/apiCatalogue.spec.ts and web/src/views/__tests__/IntegrationView.spec.ts use these numbers and the new section list
|
||||||
|
|
||||||
|
Scenario: The PATCH reference lists the new fields
|
||||||
|
Then PATCH /v1/article/{id} lists the optional fields slug, excerpt, seo_title, seo_description, cover_image_id, cover_image_alt, social_title, social_description, social_image_id, canonical_url, noindex and seo_dismissed, with the rules of the API
|
||||||
|
And that is asserted by apiCatalogue.spec.ts and no field is added twice
|
||||||
|
|
||||||
|
Scenario: The 403 row of the status table
|
||||||
|
Then the 403 row of the Errors table also states, in a note under its messages, that site SEO defaults are owner only and that article SEO fields need only the right to edit the article
|
||||||
|
And the plain-text messages of that row are unchanged
|
||||||
|
|
||||||
|
Scenario: Nothing changes for the other groups
|
||||||
|
Then the Session only group, the Errors table, the Images and variants section, the Notes and the sample error response are as before, apart from the 403 note
|
||||||
|
|
||||||
|
Scenario: The page still makes no request
|
||||||
|
Then opening and expanding the new cards sends no /v1 request
|
||||||
|
|
||||||
|
Rule: French, layering and visual conformance
|
||||||
|
|
||||||
|
Scenario: French
|
||||||
|
Given the language is "fr"
|
||||||
|
Then every new string of the four screens, including "Needs attention", "Hidden", the tooltips, "Nothing needs attention", the Sites form copy, the dismiss button name, footnote and toast, the new group title, description, card descriptions and query rows, the 403 note, and the @not-in-design copy, is the French message
|
||||||
|
And a missing French entry fails the i18n spec
|
||||||
|
|
||||||
|
Scenario: French plurals of the tooltip
|
||||||
|
Given the language is "fr"
|
||||||
|
Then a chip with 1 suggestion has the tooltip "1 suggestion" and with 2 or more "{n} suggestions" in French
|
||||||
|
|
||||||
|
Scenario: Every call goes through useApi
|
||||||
|
Then no component calls fetch directly and every URL is relative
|
||||||
|
|
||||||
|
Scenario: Slice 3 leftovers
|
||||||
|
Then a grep of web/src for hard-coded English in templates and for t(...) keys without a French entry finds none, or every finding is reported in the qa report with its file and line
|
||||||
|
|
||||||
|
Scenario: Visual conformance
|
||||||
|
Then `task visual:check -- articles`, `-- sites`, `-- integration`, `-- editor`, `-- settings`, `-- users`, `-- profile` and `-- home` pass every frame at 1440x900 and 390x844, in light and dark
|
||||||
|
And that includes the 5 new "Sites: SEO defaults" frames, the 3 new "Articles: search status" frames and every existing frame of those screens
|
||||||
|
And the slice 3 mobile exceptions for the dismiss column are removed from web/visual/states.ts and the Editor panel frames pass without any exception
|
||||||
|
And no per-state exception is added; a failure is reported to the user instead
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# QA: SEO round, slice 4 (Sites defaults, Articles search status, Integration, suggestion dismissal)
|
||||||
|
|
||||||
|
SPA slice only. No Go change: the API of `features/seo_data_backend.feature` and `features/seo_output_backend.feature` already exists, so this slice has no httptest part. 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: GET/POST/PATCH /v1/site/..., GET /v1/article/ (items with `seo_status` and `seo_suggestions`, `counts`, `total`), PATCH /v1/article/{id}, POST /v1/article/{id}/seo/check, and the media routes of the shared picker. Mount SitesView, ArticleView, IntegrationView and EditorView inside DashboardShell with a resolved session and a test router. Mock matchMedia for desktop and mobile. Use fake timers for the search debounce. Do not freeze the system date. The rendering of "Updated …" is not asserted. Report the web tests as run or NOT RUN, never as passing when not run. One spec file per numbered group, under `web/src/views/__tests__/` (`SeoSites*.spec.ts`, `SeoArticles*.spec.ts`, `SeoDismiss*.spec.ts`, `SeoIntegration*.spec.ts`).
|
||||||
|
|
||||||
|
Regression: every existing Sites, Articles (ArticlesPagination, ArticlesPageSizeMemory, ArticlesSitesAuthors), Integration and EditorSeo* spec now sees the toolbar count request, the new fields and the dismiss buttons. Give them default mocks (count request answers total 3 on the request carrying `seo=suggestions`; sites with the three SEO fields) rather than loosening unmocked-call checks, and show that they still pass.
|
||||||
|
|
||||||
|
1. Sites form fields: order, copy of states 1 and 2 (edit and add), placeholders, helpers, the counter, no "(optional)" on Site name, the "How articles use these defaults" block and its placement under the preview block.
|
||||||
|
2. Live example: the Examples table (site name, name fallback, placeholder "Renew" fallback, `%title%` only, two `%site%`), updated on each keystroke, no request sent; global replace, not first occurrence.
|
||||||
|
3. Template validation: the four invalid values (`just text`, `%site%`, `%TITLE%`, empty) on blur and on submit, nothing sent, focus on the first invalid field, example hidden, error cleared on a valid value, 400 `invalid_title_template` from the server shows the same message.
|
||||||
|
4. Limits: maxlength 100, 200 and 320; the counter at 0, 45 and 320.
|
||||||
|
5. Save payloads: PATCH holds only changed fields (assert exact JSON) for template only and for name + description + robots; POST omits the three fields when untouched and sends them when changed; the discard dialog on a changed SEO field; a site without the fields opens with defaults and "Save changes" sends nothing.
|
||||||
|
6. Save failure and sessions: 500 and network failure keep the panel and the notice; 403 `owner_only` gives the existing toast; 401 goes to `/login?redirect=/settings/sites&reason=expired`; admin and verified users get the 403 page and no form.
|
||||||
|
7. SeoStatusChip: the status table (ready, suggestions 1 and 2, hidden with and without a count), placement in the Status cell and in the card meta row, tooltip/accessible name (singular and plural), the unusable items (no seo_status, suggestions with 0, unknown status), the row action that keeps the chip.
|
||||||
|
8. Toggle and URL: parse and write `seo` (`parseArticleQuery`, `toRouteQuery`, `sameArticleQuery`), page reset, q/status/sort/site/size kept, other changes keep seo, reset-to-1 table, opened with `?seo=suggestions&page=2`, unknown values dropped silently via replace (the six values), the request carries `seo=suggestions` only when on, tab counts unchanged.
|
||||||
|
9. Count: total 3, total 0, pending, failure (no count, no notice, no URL change); the extra request (`seo=suggestions&page=1&size=5`, with q, status and site) is made only while off, only when q/tab/site change, and the main response total is used while on.
|
||||||
|
10. Empty states: "Nothing needs attention" with its action (clears seo only), the not-in-design combinations (search, Draft, Online, a site), paginator hidden, Retry notice still works.
|
||||||
|
11. Single-site install: the Site column and filter are absent and everything above behaves the same.
|
||||||
|
12. Dismiss ×, rendering: one button per row of the panel list, none in the compact section list; the footnote; accessible name with the message interpolated; Tab order (after "Go to …"); Enter and Space; visible focus class.
|
||||||
|
13. Dismiss ×, behaviour: success sends exactly one PATCH with `seo_dismissed` equal to the saved list plus the code (assert exact JSON, no other field, no draft field); the button is busy and the row stays until 200; the row leaves; one new check follows; summary line and "All good" follow the check; the draft, the panel values and the editor status are untouched; focus table (first, second, last, and the last remaining row going to "Show dismissed"); 500 and network failure keep the row and show the toast, send no check; 401 mirrors the draft and redirects; 404 shows "Not found"; the three roles; Save and Publish stay enabled; "Show dismissed" sends `seo_dismissed: []` then a check.
|
||||||
|
14. Integration group: position after Articles, group description, 2 routes, both cards "Public" and "API key OK", expanded content (fields, query rows, tabs, error rows with the real strings), no duplicate route in Articles or Sites, `POST …/seo/check` still in Articles, index and Jump to order, deep link #seo, counts 8/2/10/6/1 and "1 route" for Sites, the 403 note, no /v1 request.
|
||||||
|
15. Catalogue spec: update the group list, the per-group counts, the Sites description, the "Public" assertions (they currently list `GET /v1/article/{id}/seo` and the sitemap with the Articles and Sites groups) and the 403 note.
|
||||||
|
16. i18n: every new `t()` key has a French entry (extend the i18n spec), the French plurals, one French walk through the four screens.
|
||||||
|
|
||||||
|
## Visual (`task visual:check -- <screen>`, desktop and mobile, light and dark)
|
||||||
|
|
||||||
|
Setups go in `web/visual/states.ts`, one per state, with mocked /v1 and the board's own sample data. Hard gate: every capture within 10 %, no per-state exception. Reference frames: `DirectoryFrame.dc.html` (Sites form, SuggestionList), `AppFrame.dc.html` (Articles) and `IntegrationPage.dc.html`.
|
||||||
|
|
||||||
|
- Sites: SEO defaults (5): Edit: SEO defaults (Renew, `f.sname`, template `%title% | %site%`, example "How we price a bathroom renovation | Renew"), Add: defaults, Template: custom (site name Journal, template `%title% – %site%`), Template: not valid, Save failed. The existing Sites form frames (7) now contain the three fields and the new block: update their setups with the board values.
|
||||||
|
- Articles: search status (3): Status chips (rows: 2 suggestions, hidden, 3 suggestions, 1 suggestion), Needs attention on, Needs attention: none. Every existing Articles and pagination frame now shows the toggle with the count 3 and the chips of the board rows, so each setup mocks the count request with total 3. States whose "Updated" column or text is clock-sensitive follow what the existing setups already do (`skip: clock-sensitive`), and state 8 "Published" stays skipped.
|
||||||
|
- Integration (8 existing states): the index and the mobile select now contain "Search and sharing", and the page has the new group (collapsed). Mock nothing new. The Navigation state's open select must list the new option. If the 403 note makes a capture exceed 10 %, report it to the user; do not add an exception.
|
||||||
|
- Editor: the dismiss column is now implemented. Remove the five `withMobileException(..., dismissColumnReason)` wrappers (and the `dismissColumnReason` constant, `withMobileException` if nothing else uses it) in `web/visual/states.ts`, covering 'Panel: defaults', 'Panel: outside the soft ranges', 'Panel: cover without alt', 'Panel: choose cover' and the fifth state at line ~5828. Those states must pass `task visual:check -- editor` at the default 10 % in both viewports. If one still fails, report it; do not restore an exception.
|
||||||
|
- Re-run Settings, Users, Profile and Home: report any capture above 10 %. Any failure needs the user's decision (an `approvedBy` exception or a design update).
|
||||||
|
|
||||||
|
## Final pass: slice 3 leftovers (required in the qa report)
|
||||||
|
|
||||||
|
1. Grep `web/src` (excluding `__tests__`) for text in templates not passed through `t(...)`, and for `title="..."`, `placeholder="..."` and `aria-label="..."` attributes with English literals, in the files of slice 3 (`SeoPanel.vue`, `SearchSharingSection.vue`, `SuggestionList.vue`, `ApiPreviewDialog.vue`, `SearchPreview.vue`, `SocialCardPreview.vue`, `SeoDisclosure.vue`, `EditorView.vue`, `seoCopy.ts`, `seoApiPreview.ts`, `seoPreview.ts`).
|
||||||
|
2. Extract every `t('…')` key used by those files and by the files of this slice, and confirm each has an entry in `web/src/i18n/messages.ts` (the i18n spec must cover them). Spot check on the current tree: no missing entry found among the editor strings sampled; the new ones for this slice are still to add.
|
||||||
|
3. Report findings by file and line. Fix those that belong to this slice. Anything else is reported to the user, not fixed silently.
|
||||||
|
|
||||||
|
## Exit conditions
|
||||||
|
|
||||||
|
- All Articles, Sites and Integration frames (existing and the 8 new ones) pass `task visual:check`, plus Editor, Settings, Users, Profile and Home re-run with no regression above 10 %.
|
||||||
|
- The slice 3 dismiss-column exceptions are gone from `web/visual/states.ts` and `grep -n dismissColumnReason web/visual/states.ts` finds nothing; the Editor panel frames pass without exception.
|
||||||
|
- Vitest specs reported honestly, run or NOT RUN.
|
||||||
|
- `bun lint` clean. No comment added. No Go file changed.
|
||||||
|
|
||||||
|
## Divergences between the design and the real API (feed back to docs/design)
|
||||||
|
|
||||||
|
- Sitemap errors: the design says 404 "Site not found" and 400 "Invalid page or size."; the API answers 404 `site_not_found` and 400 with the generic body-mismatch message (`apperr.BodyErrorMessage`). The Integration cards use the real strings.
|
||||||
|
- Article list: the design (routes.md) describes an array body with `X-Total-Count`; the code reads `{items, total, page, size, counts}`. Unchanged by this slice.
|
||||||
|
- The 403 note under the Errors table has no layout in the design.
|
||||||
|
- The 403 note under the Errors table is a design gap: it pushes the following rows down, so Integration "6. Errors" carries an approved exception (16 % desktop, 22 % mobile).
|
||||||
|
- Hidden chip: the board wraps the "Hidden" chip under the Online badge in the 110 px Status column; the feature says it never wraps, and the app follows the feature.
|
||||||
|
- Add: defaults: the board's Example row always falls back to "Renew"; the app falls back to the Name, then "Renew", as the feature requires.
|
||||||
|
- Articles row 5: the design text says row 5 has 1 suggestion, but the board data draws no chip on it; the visual setup follows the board data.
|
||||||
|
- Picker debt: on mobile the shared media picker sheet is taller and shows more tiles than the board, so Editor "Panel: choose cover" carries an approved mobile exception (29 %). The picker was not changed in this slice.
|
||||||
|
|
||||||
|
## 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. Source of the "Needs attention" count (extra request, `total`).
|
||||||
|
2. Zero count shown as "0", no count while pending or on failure.
|
||||||
|
3. Empty-state precedence when `seo=suggestions` is combined with a search, a tab or a site.
|
||||||
|
4. Chips refresh only with the next list load after a row action.
|
||||||
|
5. Items without a usable status show no chip.
|
||||||
|
6. Example fallback to the name, then to "Renew" as site name.
|
||||||
|
7. Accessible name of the chip on touch.
|
||||||
|
8. The 403 row note on the Errors table.
|
||||||
|
9. Dismiss ×: accessible name "Dismiss suggestion: <message>", the failure toast "Couldn't dismiss the suggestion" / "Try again in a moment.", the busy state, the focus rules, and "Show dismissed" restoring all codes.
|
||||||
|
|
||||||
|
## Proposed French (for review, in `web/src/i18n/messages.ts`)
|
||||||
|
|
||||||
|
| English key | French |
|
||||||
|
| --- | --- |
|
||||||
|
| Needs attention | À traiter |
|
||||||
|
| Hidden | Masqué |
|
||||||
|
| Hidden from search engines | Masqué des moteurs de recherche |
|
||||||
|
| Nothing needs attention | Rien à traiter |
|
||||||
|
| Every article has a clean check. Suggestions are optional, so this is just a bonus. | Tous les articles passent la vérification. Les suggestions sont facultatives, c'est un petit plus. |
|
||||||
|
| Show all articles | Afficher tous les articles |
|
||||||
|
| Site name | Nom du site |
|
||||||
|
| Title template | Modèle de titre |
|
||||||
|
| Use %title% for the article title and %site% for the site name. | Utilisez %title% pour le titre de l'article et %site% pour le nom du site. |
|
||||||
|
| The template must contain %title%. | Le modèle doit contenir %title%. |
|
||||||
|
| Example | Exemple |
|
||||||
|
| Default description | Description par défaut |
|
||||||
|
| How articles use these defaults | Comment les articles utilisent ces valeurs par défaut |
|
||||||
|
| Dismiss suggestion: {0} | Ignorer la suggestion : {0} |
|
||||||
|
| Couldn't dismiss the suggestion | Impossible d'ignorer la suggestion |
|
||||||
|
| Show dismissed | Afficher les suggestions ignorées |
|
||||||
|
| Search and sharing | Recherche et partage (already present) |
|
||||||
|
|
||||||
|
## Manual checks screenshots cannot cover
|
||||||
|
|
||||||
|
- Keyboard: tab order through the Sites form (the new fields sit between the image and the robots policy), Space and Enter on "Needs attention", focus on the first invalid field, the dismiss button order and focus after a row leaves, the index and the Jump to select (Enter, Escape).
|
||||||
|
- Hover and touch: the chip tooltips on desktop, the chip text read by a screen reader, the toggle's pressed state.
|
||||||
|
- Real `task dev`: the owner sets a template and sees the title change in "What the frontend gets"; dismiss a suggestion, reload, and see it stay hidden and the article's chip count drop; the Articles filter agrees with `curl '…/v1/article/?seo=suggestions'`; the sitemap card matches `curl` on `/v1/site/renew/sitemap` and its X-Total-Count; read the screens in French.
|
||||||
@@ -3,10 +3,12 @@ import { t } from '@/i18n'
|
|||||||
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||||
|
import SeoStatusChip from '@/components/SeoStatusChip.vue'
|
||||||
import AuthorStack from '@/components/AuthorStack.vue'
|
import AuthorStack from '@/components/AuthorStack.vue'
|
||||||
import { authorsSummary } from '@/lib/articleMeta'
|
import { authorsSummary } from '@/lib/articleMeta'
|
||||||
import { formatRelativeDate } from '@/lib/relativeDate'
|
import { formatRelativeDate } from '@/lib/relativeDate'
|
||||||
import type { Article } from '@/composables/useArticles'
|
import type { Article } from '@/composables/useArticles'
|
||||||
|
import { seoChipOf } from '@/lib/articleSeoStatus'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -137,6 +139,11 @@ function onRowClick(article: Article, event: MouseEvent) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
:pending="pendingFor(article)"
|
:pending="pendingFor(article)"
|
||||||
/>
|
/>
|
||||||
|
<SeoStatusChip
|
||||||
|
v-if="seoChipOf(article)"
|
||||||
|
:chip="seoChipOf(article)!"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
<span :title="article.updated_at"
|
<span :title="article.updated_at"
|
||||||
>{{ t('Updated') }}
|
>{{ t('Updated') }}
|
||||||
{{ formatRelativeDate(article.updated_at, now) }}</span
|
{{ formatRelativeDate(article.updated_at, now) }}</span
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ const selected = computed({
|
|||||||
icon="i-lucide-globe"
|
icon="i-lucide-globe"
|
||||||
:search-input="false"
|
:search-input="false"
|
||||||
:aria-label="t('Site')"
|
:aria-label="t('Site')"
|
||||||
class="w-full sm:w-37.5"
|
class="w-full md:w-37.5"
|
||||||
:ui="{
|
:ui="{
|
||||||
base: `h-11 rounded-none text-lg ring-(--ui-border-accented) sm:h-8 sm:text-sm ${siteKey ? 'text-highlighted' : 'text-muted'}`,
|
base: `h-11 rounded-none text-lg ring-(--ui-border-accented) md:h-8 md:text-sm ${siteKey ? 'text-highlighted' : 'text-muted'}`,
|
||||||
leadingIcon: 'size-3.5',
|
leadingIcon: 'size-3.5',
|
||||||
trailingIcon: 'size-3.5 text-muted',
|
trailingIcon: 'size-3.5 text-muted',
|
||||||
content: 'rounded-none shadow-(--shadow-popover)',
|
content: 'rounded-none shadow-(--shadow-popover)',
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
withDefaults(
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
online: boolean
|
online: boolean
|
||||||
size?: 'sm' | 'md' | 'lg'
|
size?: 'sm' | 'md' | 'lg'
|
||||||
@@ -11,6 +13,9 @@ withDefaults(
|
|||||||
size: 'md',
|
size: 'md',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const themeSize = computed(() => (props.size === 'sm' ? 'md' : props.size))
|
||||||
|
const compact = computed(() => (props.size === 'sm' ? 'h-5 px-1.5' : undefined))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -19,13 +24,20 @@ withDefaults(
|
|||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon="i-lucide-loader-circle"
|
icon="i-lucide-loader-circle"
|
||||||
:size="size"
|
:size="themeSize"
|
||||||
:class="size === 'lg' ? undefined : 'px-2'"
|
:class="[size === 'lg' ? undefined : 'px-2', compact]"
|
||||||
:ui="{ leadingIcon: 'animate-spin' }"
|
:ui="{ leadingIcon: 'animate-spin' }"
|
||||||
>
|
>
|
||||||
{{ pending === 'publish' ? t('Publishing…') : t('Unpublishing…') }}
|
{{ pending === 'publish' ? t('Publishing…') : t('Unpublishing…') }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
<UBadge v-else-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
|
<UBadge
|
||||||
|
v-else-if="online"
|
||||||
|
color="neutral"
|
||||||
|
variant="solid"
|
||||||
|
icon="i-lucide-globe"
|
||||||
|
:size="themeSize"
|
||||||
|
:class="compact"
|
||||||
|
>
|
||||||
{{ t('Online') }}
|
{{ t('Online') }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
<UBadge
|
<UBadge
|
||||||
@@ -33,8 +45,8 @@ withDefaults(
|
|||||||
color="neutral"
|
color="neutral"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon="i-lucide-pencil-line"
|
icon="i-lucide-pencil-line"
|
||||||
:size="size"
|
:size="themeSize"
|
||||||
:class="size === 'lg' ? undefined : 'px-2'"
|
:class="[size === 'lg' ? undefined : 'px-2', compact]"
|
||||||
>
|
>
|
||||||
{{ t('Draft') }}
|
{{ t('Draft') }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { t } from '@/i18n'
|
|||||||
import { computed, ref, watchPostEffect } from 'vue'
|
import { computed, ref, watchPostEffect } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||||
|
import SeoStatusChip from '@/components/SeoStatusChip.vue'
|
||||||
import AuthorStack from '@/components/AuthorStack.vue'
|
import AuthorStack from '@/components/AuthorStack.vue'
|
||||||
import type { Site } from '@/composables/useSites'
|
import type { Site } from '@/composables/useSites'
|
||||||
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
|
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
|
||||||
import type { Article } from '@/composables/useArticles'
|
import type { Article } from '@/composables/useArticles'
|
||||||
|
import { seoChipOf } from '@/lib/articleSeoStatus'
|
||||||
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
|
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -20,7 +22,7 @@ const props = withDefaults(
|
|||||||
sites?: Site[]
|
sites?: Site[]
|
||||||
now?: Date
|
now?: Date
|
||||||
}>(),
|
}>(),
|
||||||
{ skeletonRows: 5, sort: DEFAULT_ARTICLE_SORT, sites: () => [] },
|
{ skeletonRows: 6, sort: DEFAULT_ARTICLE_SORT, sites: () => [] },
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -38,6 +40,15 @@ const SINGLE_SITE_WIDTHS =
|
|||||||
const SEVERAL_SITES_WIDTHS =
|
const SEVERAL_SITES_WIDTHS =
|
||||||
'[&:nth-child(2)]:w-26 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-27.5 [&:nth-child(5)]:w-32.5 [&:nth-child(6)]:w-35 [&:nth-child(7)]:w-11'
|
'[&:nth-child(2)]:w-26 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-27.5 [&:nth-child(5)]:w-32.5 [&:nth-child(6)]:w-35 [&:nth-child(7)]:w-11'
|
||||||
|
|
||||||
|
const rows = computed(() =>
|
||||||
|
props.loading
|
||||||
|
? Array.from(
|
||||||
|
{ length: props.skeletonRows },
|
||||||
|
(_, index) => ({ id: -(index + 1) }) as Article,
|
||||||
|
)
|
||||||
|
: props.articles,
|
||||||
|
)
|
||||||
|
|
||||||
const showSite = computed(() => props.sites.length > 1)
|
const showSite = computed(() => props.sites.length > 1)
|
||||||
const columnWidths = computed(() => (showSite.value ? SEVERAL_SITES_WIDTHS : SINGLE_SITE_WIDTHS))
|
const columnWidths = computed(() => (showSite.value ? SEVERAL_SITES_WIDTHS : SINGLE_SITE_WIDTHS))
|
||||||
|
|
||||||
@@ -212,29 +223,22 @@ function actionsFor(article: Article) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onSelect(_event: Event, row: { original: Article }) {
|
function onSelect(_event: Event, row: { original: Article }) {
|
||||||
|
if (props.loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
router.push(editPath(row.original))
|
router.push(editPath(row.original))
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
|
<div
|
||||||
<span role="status" class="sr-only">{{ t('Loading articles') }}</span>
|
ref="tableRoot"
|
||||||
<div
|
:aria-busy="loading ? 'true' : undefined"
|
||||||
v-for="n in skeletonRows"
|
class="hidden border border-default bg-default md:block"
|
||||||
:key="n"
|
>
|
||||||
class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0"
|
<span v-if="loading" role="status" class="sr-only">{{ t('Loading articles') }}</span>
|
||||||
>
|
|
||||||
<USkeleton class="h-3 max-w-[55%] flex-1 rounded-none bg-accented" />
|
|
||||||
<USkeleton class="h-6 w-15 rounded-none bg-accented" />
|
|
||||||
<USkeleton v-if="showSite" class="h-3 w-17.5 rounded-none bg-accented" />
|
|
||||||
<USkeleton class="h-5 w-15 rounded-none bg-accented" />
|
|
||||||
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
|
|
||||||
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else ref="tableRoot" class="hidden border border-default bg-default md:block">
|
|
||||||
<UTable
|
<UTable
|
||||||
:data="articles"
|
:data="rows"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:ui="{
|
:ui="{
|
||||||
base: 'table-fixed',
|
base: 'table-fixed',
|
||||||
@@ -257,6 +261,7 @@ function onSelect(_event: Event, row: { original: Article }) {
|
|||||||
<template #title-header="{ column }">
|
<template #title-header="{ column }">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
:disabled="loading"
|
||||||
class="inline-flex items-center gap-1 text-highlighted"
|
class="inline-flex items-center gap-1 text-highlighted"
|
||||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||||
>
|
>
|
||||||
@@ -273,6 +278,7 @@ function onSelect(_event: Event, row: { original: Article }) {
|
|||||||
<template #status-header="{ column }">
|
<template #status-header="{ column }">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
:disabled="loading"
|
||||||
class="inline-flex items-center gap-1 text-highlighted"
|
class="inline-flex items-center gap-1 text-highlighted"
|
||||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||||
>
|
>
|
||||||
@@ -289,6 +295,7 @@ function onSelect(_event: Event, row: { original: Article }) {
|
|||||||
<template #created_at-header="{ column }">
|
<template #created_at-header="{ column }">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
:disabled="loading"
|
||||||
class="inline-flex items-center gap-1 text-highlighted"
|
class="inline-flex items-center gap-1 text-highlighted"
|
||||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||||
>
|
>
|
||||||
@@ -305,6 +312,7 @@ function onSelect(_event: Event, row: { original: Article }) {
|
|||||||
<template #updated_at-header="{ column }">
|
<template #updated_at-header="{ column }">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
:disabled="loading"
|
||||||
class="inline-flex items-center gap-1 text-highlighted"
|
class="inline-flex items-center gap-1 text-highlighted"
|
||||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||||
>
|
>
|
||||||
@@ -319,7 +327,9 @@ function onSelect(_event: Event, row: { original: Article }) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #title-cell="{ row }">
|
<template #title-cell="{ row }">
|
||||||
|
<USkeleton v-if="loading" class="h-3 w-3/5 rounded-none bg-accented" />
|
||||||
<RouterLink
|
<RouterLink
|
||||||
|
v-else
|
||||||
:to="editPath(row.original)"
|
:to="editPath(row.original)"
|
||||||
:data-row-title="row.original.id"
|
:data-row-title="row.original.id"
|
||||||
class="font-medium text-highlighted"
|
class="font-medium text-highlighted"
|
||||||
@@ -330,7 +340,8 @@ function onSelect(_event: Event, row: { original: Article }) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #authors-cell="{ row }">
|
<template #authors-cell="{ row }">
|
||||||
<span class="flex min-w-0 items-center" :title="authorNames(row.original)">
|
<USkeleton v-if="loading" class="h-6 w-15 rounded-none bg-accented" />
|
||||||
|
<span v-else class="flex min-w-0 items-center" :title="authorNames(row.original)">
|
||||||
<AuthorStack
|
<AuthorStack
|
||||||
v-if="row.original.authors?.length"
|
v-if="row.original.authors?.length"
|
||||||
:authors="row.original.authors"
|
:authors="row.original.authors"
|
||||||
@@ -340,32 +351,46 @@ function onSelect(_event: Event, row: { original: Article }) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #site-cell="{ row }">
|
<template #site-cell="{ row }">
|
||||||
<span class="block truncate text-toned">{{ siteNameOf(row.original) }}</span>
|
<USkeleton v-if="loading" class="h-3 w-17.5 rounded-none bg-accented" />
|
||||||
|
<span v-else class="block truncate text-toned">{{ siteNameOf(row.original) }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #status-cell="{ row }">
|
<template #status-cell="{ row }">
|
||||||
<ArticleStatusBadge
|
<USkeleton v-if="loading" class="h-5 w-15 rounded-none bg-accented" />
|
||||||
:online="row.original.is_online"
|
<span v-else class="flex items-center gap-1.5">
|
||||||
size="sm"
|
<ArticleStatusBadge
|
||||||
:pending="pendingFor(row.original)"
|
:online="row.original.is_online"
|
||||||
:aria-busy="isBusy(row.original) ? 'true' : undefined"
|
size="md"
|
||||||
/>
|
:pending="pendingFor(row.original)"
|
||||||
|
:aria-busy="isBusy(row.original) ? 'true' : undefined"
|
||||||
|
/>
|
||||||
|
<SeoStatusChip
|
||||||
|
v-if="seoChipOf(row.original)"
|
||||||
|
:chip="seoChipOf(row.original)!"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #created_at-cell="{ row }">
|
<template #created_at-cell="{ row }">
|
||||||
<span class="text-muted">{{
|
<USkeleton v-if="loading" class="h-3 w-20 rounded-none bg-accented" />
|
||||||
|
<span v-else class="text-muted">{{
|
||||||
formatAbsoluteDateFromIso(row.original.created_at)
|
formatAbsoluteDateFromIso(row.original.created_at)
|
||||||
}}</span>
|
}}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #updated_at-cell="{ row }">
|
<template #updated_at-cell="{ row }">
|
||||||
<span class="text-muted" :title="row.original.updated_at">{{
|
<USkeleton v-if="loading" class="h-3 w-20 rounded-none bg-accented" />
|
||||||
|
<span v-else class="text-muted" :title="row.original.updated_at">{{
|
||||||
formatRelativeDate(row.original.updated_at, now)
|
formatRelativeDate(row.original.updated_at, now)
|
||||||
}}</span>
|
}}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #actions-cell="{ row }">
|
<template #actions-cell="{ row }">
|
||||||
<UDropdownMenu :items="actionsFor(row.original)" :content="{ align: 'end' }">
|
<UDropdownMenu
|
||||||
|
v-if="!loading"
|
||||||
|
:items="actionsFor(row.original)"
|
||||||
|
:content="{ align: 'end' }"
|
||||||
|
>
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-lucide-ellipsis"
|
icon="i-lucide-ellipsis"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|||||||
@@ -78,8 +78,10 @@ function focusOnOpen(event: Event) {
|
|||||||
nextTick(() => target.focus())
|
nextTick(() => target.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusFirstInvalid() {
|
async function focusFirstInvalid() {
|
||||||
nextTick(() => form.value?.$el.querySelector<HTMLElement>(INVALID_SELECTOR)?.focus())
|
await new Promise((resolve) => setTimeout(resolve))
|
||||||
|
await nextTick()
|
||||||
|
form.value?.$el.querySelector<HTMLElement>(INVALID_SELECTOR)?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ focusFirstInvalid })
|
defineExpose({ focusFirstInvalid })
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import { computed } from 'vue'
|
|||||||
const QUERY_MAX_LENGTH = 40
|
const QUERY_MAX_LENGTH = 40
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online' | 'no-site'
|
variant:
|
||||||
|
| 'no-articles'
|
||||||
|
| 'no-results'
|
||||||
|
| 'no-drafts'
|
||||||
|
| 'no-online'
|
||||||
|
| 'no-site'
|
||||||
|
| 'needs-attention'
|
||||||
query?: string
|
query?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -62,6 +68,19 @@ const variants = computed(() => ({
|
|||||||
action: t('Show all sites'),
|
action: t('Show all sites'),
|
||||||
primary: false,
|
primary: false,
|
||||||
},
|
},
|
||||||
|
'needs-attention': {
|
||||||
|
icon: 'i-lucide-circle-check',
|
||||||
|
get title() {
|
||||||
|
return t('Nothing needs attention')
|
||||||
|
},
|
||||||
|
get description() {
|
||||||
|
return t(
|
||||||
|
'Every article has a clean check. Suggestions are optional, so this is just a bonus.',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
action: t('Show all articles'),
|
||||||
|
primary: false,
|
||||||
|
},
|
||||||
'no-online': {
|
'no-online': {
|
||||||
icon: 'i-lucide-globe',
|
icon: 'i-lucide-globe',
|
||||||
get title() {
|
get title() {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
count: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const pressed = defineModel<boolean>({ default: false })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UButton
|
||||||
|
role="switch"
|
||||||
|
:aria-checked="pressed"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-lightbulb"
|
||||||
|
data-testid="needs-attention-toggle"
|
||||||
|
class="h-11 shrink-0 justify-start gap-2 border px-2.5 text-md ring-0 md:h-8 md:text-sm"
|
||||||
|
:class="
|
||||||
|
pressed
|
||||||
|
? 'border-(--rc-sand) bg-(--rc-sand) font-medium text-(--rc-on-sand) hover:bg-(--rc-sand)'
|
||||||
|
: 'border-accented bg-default font-normal text-toned'
|
||||||
|
"
|
||||||
|
:ui="{ leadingIcon: 'size-3.5' }"
|
||||||
|
@click="pressed = !pressed"
|
||||||
|
>
|
||||||
|
{{ t('Needs attention') }}
|
||||||
|
<span v-if="count !== null" class="font-mono text-2xs font-medium">{{ count }}</span>
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
@@ -13,9 +13,10 @@ const props = withDefaults(
|
|||||||
phase: SeoCheckPhase
|
phase: SeoCheckPhase
|
||||||
status: SeoCheckStatus | null
|
status: SeoCheckStatus | null
|
||||||
suggestions: SeoSuggestion[]
|
suggestions: SeoSuggestion[]
|
||||||
|
dismissedCount?: number
|
||||||
sheet?: boolean
|
sheet?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{ sheet: false },
|
{ sheet: false, dismissedCount: 0 },
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -24,6 +25,7 @@ const emit = defineEmits<{
|
|||||||
preview: []
|
preview: []
|
||||||
retry: []
|
retry: []
|
||||||
go: [field: SuggestionField]
|
go: [field: SuggestionField]
|
||||||
|
restore: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const bodyId = useId()
|
const bodyId = useId()
|
||||||
@@ -93,6 +95,8 @@ const buttonClass = computed(
|
|||||||
compact
|
compact
|
||||||
:phase="phase"
|
:phase="phase"
|
||||||
:suggestions="suggestions"
|
:suggestions="suggestions"
|
||||||
|
:dismissed-count="dismissedCount"
|
||||||
|
@restore="emit('restore')"
|
||||||
@go="emit('go', $event)"
|
@go="emit('go', $event)"
|
||||||
@more="emit('edit', 'suggestions')"
|
@more="emit('edit', 'suggestions')"
|
||||||
@retry="emit('retry')"
|
@retry="emit('retry')"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
type PreviewDevice,
|
type PreviewDevice,
|
||||||
type SeoPreviewInput,
|
type SeoPreviewInput,
|
||||||
} from '@/lib/seoPreview'
|
} from '@/lib/seoPreview'
|
||||||
import type { SeoFocus, SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
|
import type { SeoFocus, SeoSuggestion, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
|
||||||
|
|
||||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||||
const PANEL_FIELDS: SuggestionField[] = [
|
const PANEL_FIELDS: SuggestionField[] = [
|
||||||
@@ -58,6 +58,8 @@ const props = defineProps<{
|
|||||||
site: Site | undefined
|
site: Site | undefined
|
||||||
phase: SeoCheckPhase
|
phase: SeoCheckPhase
|
||||||
suggestions: SeoSuggestion[]
|
suggestions: SeoSuggestion[]
|
||||||
|
dismissedCount: number
|
||||||
|
dismissingCode: SuggestionCode | null
|
||||||
slugVerdict: { available: boolean; suggestion: string | null } | null
|
slugVerdict: { available: boolean; suggestion: string | null } | null
|
||||||
checkedSlug: string | undefined
|
checkedSlug: string | undefined
|
||||||
slugTakenOnSave: string | null
|
slugTakenOnSave: string | null
|
||||||
@@ -74,6 +76,8 @@ const emit = defineEmits<{
|
|||||||
close: []
|
close: []
|
||||||
preview: []
|
preview: []
|
||||||
go: [field: SuggestionField]
|
go: [field: SuggestionField]
|
||||||
|
dismiss: [code: SuggestionCode]
|
||||||
|
restore: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const [DefineSuggestions, Suggestions] = createReusableTemplate()
|
const [DefineSuggestions, Suggestions] = createReusableTemplate()
|
||||||
@@ -349,6 +353,10 @@ const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD
|
|||||||
<SuggestionList
|
<SuggestionList
|
||||||
:phase="phase"
|
:phase="phase"
|
||||||
:suggestions="suggestions"
|
:suggestions="suggestions"
|
||||||
|
:dismissed-count="dismissedCount"
|
||||||
|
:dismissing-code="dismissingCode"
|
||||||
|
@dismiss="emit('dismiss', $event)"
|
||||||
|
@restore="emit('restore')"
|
||||||
@go="goTo"
|
@go="goTo"
|
||||||
@retry="emit('check', true)"
|
@retry="emit('check', true)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { SeoChip } from '@/lib/articleSeoStatus'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
chip: SeoChip
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
}>(),
|
||||||
|
{ size: 'md' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const description = computed(() => {
|
||||||
|
if (props.chip.kind === 'hidden') {
|
||||||
|
return t('Hidden from search engines')
|
||||||
|
}
|
||||||
|
return props.chip.count === 1 ? t('1 suggestion') : t('{0} suggestions', [props.chip.count])
|
||||||
|
})
|
||||||
|
|
||||||
|
const appearance = computed(() =>
|
||||||
|
props.chip.kind === 'hidden'
|
||||||
|
? { icon: 'i-lucide-eye-off', class: 'border border-accented bg-transparent text-toned' }
|
||||||
|
: {
|
||||||
|
icon: 'i-lucide-lightbulb',
|
||||||
|
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UBadge
|
||||||
|
role="img"
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
:icon="appearance.icon"
|
||||||
|
:title="description"
|
||||||
|
:aria-label="description"
|
||||||
|
data-testid="seo-status-chip"
|
||||||
|
:class="appearance.class"
|
||||||
|
:ui="{
|
||||||
|
base: [
|
||||||
|
size === 'md' ? 'h-5.5' : 'h-5',
|
||||||
|
'shrink-0 gap-1 px-1.5 py-0 text-xs font-medium whitespace-nowrap',
|
||||||
|
],
|
||||||
|
leadingIcon: 'size-3',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ chip.kind === 'hidden' ? t('Hidden') : chip.count }}
|
||||||
|
</UBadge>
|
||||||
|
</template>
|
||||||
@@ -2,17 +2,25 @@
|
|||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||||
import DirectoryField from '@/components/DirectoryField.vue'
|
import DirectoryField from '@/components/DirectoryField.vue'
|
||||||
import DirectoryPanel from '@/components/DirectoryPanel.vue'
|
import DirectoryPanel from '@/components/DirectoryPanel.vue'
|
||||||
import MediaField from '@/components/MediaField.vue'
|
import MediaField from '@/components/MediaField.vue'
|
||||||
import SlugField from '@/components/SlugField.vue'
|
import SlugField from '@/components/SlugField.vue'
|
||||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||||
import { useSites, type Site, type SitePatch, type SiteRobots } from '@/composables/useSites'
|
import {
|
||||||
|
useSites,
|
||||||
|
type NewSite,
|
||||||
|
type Site,
|
||||||
|
type SitePatch,
|
||||||
|
type SiteRobots,
|
||||||
|
} from '@/composables/useSites'
|
||||||
import { useSlugField } from '@/composables/useSlugField'
|
import { useSlugField } from '@/composables/useSlugField'
|
||||||
import type { MediaItem } from '@/composables/useMedia'
|
import type { MediaItem } from '@/composables/useMedia'
|
||||||
import {
|
import {
|
||||||
SITE_KEY_MAX,
|
SITE_KEY_MAX,
|
||||||
canonicalPreview,
|
canonicalPreview,
|
||||||
|
characterCount,
|
||||||
siteUrlMessage,
|
siteUrlMessage,
|
||||||
siteUrlProblem,
|
siteUrlProblem,
|
||||||
suggestFreeSlug,
|
suggestFreeSlug,
|
||||||
@@ -20,6 +28,14 @@ import {
|
|||||||
import { copy } from '@/lib/directoryCopy'
|
import { copy } from '@/lib/directoryCopy'
|
||||||
import { classifyFailure } from '@/lib/directoryErrors'
|
import { classifyFailure } from '@/lib/directoryErrors'
|
||||||
import { baseUrlProblem, nameProblem, siteKeyProblem } from '@/lib/directoryRules'
|
import { baseUrlProblem, nameProblem, siteKeyProblem } from '@/lib/directoryRules'
|
||||||
|
import {
|
||||||
|
DEFAULT_DESCRIPTION_MAX,
|
||||||
|
DEFAULT_TITLE_TEMPLATE,
|
||||||
|
SITE_NAME_MAX,
|
||||||
|
TITLE_TEMPLATE_MAX,
|
||||||
|
exampleTitle,
|
||||||
|
templateProblem,
|
||||||
|
} from '@/lib/siteTitleTemplate'
|
||||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||||
|
|
||||||
const PREVIEW_FALLBACK_URL = 'https://journal.example.com'
|
const PREVIEW_FALLBACK_URL = 'https://journal.example.com'
|
||||||
@@ -47,6 +63,9 @@ const currentDefault = computed(() => props.sites.find((site) => site.is_default
|
|||||||
const values = reactive({
|
const values = reactive({
|
||||||
name: props.site?.name ?? '',
|
name: props.site?.name ?? '',
|
||||||
baseUrl: props.site?.base_url ?? '',
|
baseUrl: props.site?.base_url ?? '',
|
||||||
|
siteName: props.site?.site_name ?? '',
|
||||||
|
template: props.site?.title_template ?? DEFAULT_TITLE_TEMPLATE,
|
||||||
|
description: props.site?.default_description ?? '',
|
||||||
imageId: props.site?.default_social_image_id ?? null,
|
imageId: props.site?.default_social_image_id ?? null,
|
||||||
robots: (props.site?.default_robots ?? 'index') as SiteRobots,
|
robots: (props.site?.default_robots ?? 'index') as SiteRobots,
|
||||||
locale: props.site?.locale ?? '',
|
locale: props.site?.locale ?? '',
|
||||||
@@ -62,6 +81,7 @@ const errors = reactive({
|
|||||||
suggestion: '',
|
suggestion: '',
|
||||||
url: '',
|
url: '',
|
||||||
locale: '',
|
locale: '',
|
||||||
|
template: '',
|
||||||
})
|
})
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const notice = ref<DirectoryNotice | null>(null)
|
const notice = ref<DirectoryNotice | null>(null)
|
||||||
@@ -72,6 +92,9 @@ function snapshot() {
|
|||||||
values.name,
|
values.name,
|
||||||
slug.value.value,
|
slug.value.value,
|
||||||
values.baseUrl,
|
values.baseUrl,
|
||||||
|
values.siteName,
|
||||||
|
values.template,
|
||||||
|
values.description,
|
||||||
values.imageId,
|
values.imageId,
|
||||||
values.robots,
|
values.robots,
|
||||||
values.locale,
|
values.locale,
|
||||||
@@ -102,12 +125,23 @@ const switchHelp = computed(() =>
|
|||||||
currentDefault.value?.name ?? '',
|
currentDefault.value?.name ?? '',
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
|
const example = computed(() => exampleTitle(values.template, values.siteName, values.name))
|
||||||
|
const descriptionCount = computed(() => characterCount(values.description))
|
||||||
const urlError = computed(() => errors.url)
|
const urlError = computed(() => errors.url)
|
||||||
const previewUrl = computed(() =>
|
const previewUrl = computed(() =>
|
||||||
values.baseUrl.trim() && !urlError.value && siteUrlProblem(values.baseUrl) === null
|
values.baseUrl.trim() && !urlError.value && siteUrlProblem(values.baseUrl) === null
|
||||||
? canonicalPreview(values.baseUrl)
|
? canonicalPreview(values.baseUrl)
|
||||||
: canonicalPreview(PREVIEW_FALLBACK_URL),
|
: canonicalPreview(PREVIEW_FALLBACK_URL),
|
||||||
)
|
)
|
||||||
|
const defaultsRows = computed(() => [
|
||||||
|
{ label: t('Title'), text: t('Article title, then the template.') },
|
||||||
|
{
|
||||||
|
label: t('Description'),
|
||||||
|
text: t("Used last, after the article's description, excerpt and body."),
|
||||||
|
},
|
||||||
|
{ label: t('Image'), text: t('Used when an article has no cover image.') },
|
||||||
|
{ label: t('Robots'), text: t('Used unless an article is hidden from search engines.') },
|
||||||
|
])
|
||||||
const picker = computed(() => ({
|
const picker = computed(() => ({
|
||||||
title: t('Choose an image'),
|
title: t('Choose an image'),
|
||||||
description: t('The default social image for {0}.', [values.name || t('this site')]),
|
description: t('The default social image for {0}.', [values.name || t('this site')]),
|
||||||
@@ -190,11 +224,24 @@ function checkKeyShape() {
|
|||||||
return errors.key === ''
|
return errors.key === ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onTemplate(value: string) {
|
||||||
|
values.template = value
|
||||||
|
if (errors.template && templateProblem(value) === '') {
|
||||||
|
errors.template = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkTemplate() {
|
||||||
|
errors.template = templateProblem(values.template)
|
||||||
|
return errors.template === ''
|
||||||
|
}
|
||||||
|
|
||||||
function validate() {
|
function validate() {
|
||||||
const nameOk = checkName()
|
const nameOk = checkName()
|
||||||
const keyOk = nameOk ? checkKeyShape() : true
|
const keyOk = nameOk ? checkKeyShape() : true
|
||||||
const urlOk = checkUrl()
|
const urlOk = checkUrl()
|
||||||
return nameOk && keyOk && urlOk
|
const templateOk = checkTemplate()
|
||||||
|
return nameOk && keyOk && urlOk && templateOk
|
||||||
}
|
}
|
||||||
|
|
||||||
function changedFields(site: Site) {
|
function changedFields(site: Site) {
|
||||||
@@ -205,6 +252,15 @@ function changedFields(site: Site) {
|
|||||||
if (values.baseUrl.trim() !== site.base_url) {
|
if (values.baseUrl.trim() !== site.base_url) {
|
||||||
patch.base_url = values.baseUrl.trim()
|
patch.base_url = values.baseUrl.trim()
|
||||||
}
|
}
|
||||||
|
if (values.siteName.trim() !== (site.site_name ?? '')) {
|
||||||
|
patch.site_name = values.siteName.trim()
|
||||||
|
}
|
||||||
|
if (values.template.trim() !== (site.title_template ?? DEFAULT_TITLE_TEMPLATE)) {
|
||||||
|
patch.title_template = values.template.trim()
|
||||||
|
}
|
||||||
|
if (values.description.trim() !== (site.default_description ?? '')) {
|
||||||
|
patch.default_description = values.description.trim()
|
||||||
|
}
|
||||||
if (values.robots !== site.default_robots) {
|
if (values.robots !== site.default_robots) {
|
||||||
patch.default_robots = values.robots
|
patch.default_robots = values.robots
|
||||||
}
|
}
|
||||||
@@ -217,6 +273,20 @@ function changedFields(site: Site) {
|
|||||||
return patch
|
return patch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function newSeoFields() {
|
||||||
|
const fields: Pick<NewSite, 'site_name' | 'title_template' | 'default_description'> = {}
|
||||||
|
if (values.siteName.trim()) {
|
||||||
|
fields.site_name = values.siteName.trim()
|
||||||
|
}
|
||||||
|
if (values.template.trim() !== DEFAULT_TITLE_TEMPLATE) {
|
||||||
|
fields.title_template = values.template.trim()
|
||||||
|
}
|
||||||
|
if (values.description.trim()) {
|
||||||
|
fields.default_description = values.description.trim()
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
const created = await createSite({
|
const created = await createSite({
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
@@ -225,6 +295,7 @@ async function create() {
|
|||||||
default_robots: values.robots,
|
default_robots: values.robots,
|
||||||
...(values.locale.trim() ? { locale: values.locale.trim() } : {}),
|
...(values.locale.trim() ? { locale: values.locale.trim() } : {}),
|
||||||
...(values.imageId !== null ? { default_social_image_id: values.imageId } : {}),
|
...(values.imageId !== null ? { default_social_image_id: values.imageId } : {}),
|
||||||
|
...newSeoFields(),
|
||||||
is_default: values.makeDefault,
|
is_default: values.makeDefault,
|
||||||
})
|
})
|
||||||
emit('saved', created)
|
emit('saved', created)
|
||||||
@@ -257,6 +328,10 @@ function handleFailure(error: unknown) {
|
|||||||
errors.url = siteUrlMessage('invalid')
|
errors.url = siteUrlMessage('invalid')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (kind === 'invalidTitleTemplate') {
|
||||||
|
errors.template = t('The template must contain %title%.')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (kind === 'invalidLocale') {
|
if (kind === 'invalidLocale') {
|
||||||
errors.locale = t('Use a language tag like en or fr-CH.')
|
errors.locale = t('Use a language tag like en or fr-CH.')
|
||||||
return
|
return
|
||||||
@@ -370,6 +445,69 @@ async function submit() {
|
|||||||
:picker="picker"
|
:picker="picker"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DirectoryField
|
||||||
|
:label="t('Site name')"
|
||||||
|
:help="t('Shown in titles through the template, and sent as the Open Graph site name.')"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
:model-value="values.siteName"
|
||||||
|
:placeholder="t('e.g. Renew')"
|
||||||
|
:maxlength="SITE_NAME_MAX"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
|
||||||
|
@update:model-value="values.siteName = String($event)"
|
||||||
|
/>
|
||||||
|
</DirectoryField>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<DirectoryField
|
||||||
|
:label="t('Title template')"
|
||||||
|
:error="errors.template"
|
||||||
|
:help="t('Use %title% for the article title and %site% for the site name.')"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
:model-value="values.template"
|
||||||
|
:color="errors.template ? 'error' : undefined"
|
||||||
|
:maxlength="TITLE_TEMPLATE_MAX"
|
||||||
|
autocomplete="off"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
|
||||||
|
@update:model-value="onTemplate(String($event))"
|
||||||
|
@blur="checkTemplate"
|
||||||
|
/>
|
||||||
|
</DirectoryField>
|
||||||
|
<div
|
||||||
|
v-if="!errors.template"
|
||||||
|
class="flex min-w-0 items-center gap-1.5 bg-elevated px-2.5 py-2"
|
||||||
|
>
|
||||||
|
<span class="shrink-0 text-xs text-muted">{{ t('Example') }}</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate text-highlighted">{{ example }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DirectoryField
|
||||||
|
:label="t('Default description')"
|
||||||
|
optional
|
||||||
|
:help="t('Used when an article has no description, excerpt or body text.')"
|
||||||
|
>
|
||||||
|
<template #hint>
|
||||||
|
<CharacterCounter
|
||||||
|
:value="descriptionCount"
|
||||||
|
:max="DEFAULT_DESCRIPTION_MAX"
|
||||||
|
variant="field"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<UTextarea
|
||||||
|
:model-value="values.description"
|
||||||
|
:placeholder="t('One or two sentences about the site.')"
|
||||||
|
:maxlength="DEFAULT_DESCRIPTION_MAX"
|
||||||
|
class="w-full"
|
||||||
|
:ui="{ base: 'min-h-30 text-lg md:min-h-24 md:text-sm' }"
|
||||||
|
@update:model-value="values.description = String($event)"
|
||||||
|
/>
|
||||||
|
</DirectoryField>
|
||||||
|
|
||||||
<DirectoryField :label="t('Default robots policy')" :help="robotsHelp">
|
<DirectoryField :label="t('Default robots policy')" :help="robotsHelp">
|
||||||
<URadioGroup
|
<URadioGroup
|
||||||
v-model="values.robots"
|
v-model="values.robots"
|
||||||
@@ -430,6 +568,13 @@ async function submit() {
|
|||||||
t('Shown when the base URL is valid.')
|
t('Shown when the base URL is valid.')
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5 bg-elevated p-3">
|
||||||
|
<span class="text-xs text-muted">{{ t('How articles use these defaults') }}</span>
|
||||||
|
<div v-for="row in defaultsRows" :key="row.label" class="flex gap-2">
|
||||||
|
<span class="w-21 shrink-0 font-medium text-highlighted">{{ row.label }}</span>
|
||||||
|
<span class="text-pretty text-toned">{{ row.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DirectoryPanel>
|
</DirectoryPanel>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
import { computed } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { isChecking, type SeoCheckPhase } from '@/composables/useSeoCheck'
|
import { isChecking, type SeoCheckPhase } from '@/composables/useSeoCheck'
|
||||||
import { suggestionLinkLabel, suggestionMessage } from '@/lib/seoCopy'
|
import { suggestionLinkLabel, suggestionMessage } from '@/lib/seoCopy'
|
||||||
import type { SeoSuggestion, SuggestionField } from '@/lib/seoTypes'
|
import type { SeoSuggestion, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
|
||||||
|
|
||||||
const COMPACT_LIMIT = 3
|
const COMPACT_LIMIT = 3
|
||||||
const SKELETON_WIDTHS = ['w-7/10', 'w-9/10', 'w-11/20']
|
const SKELETON_WIDTHS = ['w-7/10', 'w-9/10', 'w-11/20']
|
||||||
@@ -14,14 +14,18 @@ const props = withDefaults(
|
|||||||
phase: SeoCheckPhase
|
phase: SeoCheckPhase
|
||||||
suggestions: SeoSuggestion[]
|
suggestions: SeoSuggestion[]
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
|
dismissedCount?: number
|
||||||
|
dismissingCode?: SuggestionCode | null
|
||||||
}>(),
|
}>(),
|
||||||
{ compact: false },
|
{ compact: false, dismissedCount: 0, dismissingCode: null },
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
go: [field: SuggestionField]
|
go: [field: SuggestionField]
|
||||||
more: []
|
more: []
|
||||||
retry: []
|
retry: []
|
||||||
|
dismiss: [code: SuggestionCode]
|
||||||
|
restore: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const shown = computed(() =>
|
const shown = computed(() =>
|
||||||
@@ -29,10 +33,57 @@ const shown = computed(() =>
|
|||||||
)
|
)
|
||||||
const hiddenCount = computed(() => props.suggestions.length - shown.value.length)
|
const hiddenCount = computed(() => props.suggestions.length - shown.value.length)
|
||||||
const checking = computed(() => isChecking(props.phase))
|
const checking = computed(() => isChecking(props.phase))
|
||||||
|
const dismissedText = computed(() =>
|
||||||
|
props.dismissedCount === 1
|
||||||
|
? t('1 dismissed suggestion is hidden.')
|
||||||
|
: t('{0} dismissed suggestions are hidden.', [props.dismissedCount]),
|
||||||
|
)
|
||||||
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
let removedCode: SuggestionCode | null = null
|
||||||
|
let removedIndex = 0
|
||||||
|
|
||||||
|
function dismiss(code: SuggestionCode, index: number) {
|
||||||
|
removedCode = code
|
||||||
|
removedIndex = index
|
||||||
|
emit('dismiss', code)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusAfterRemoval() {
|
||||||
|
await nextTick()
|
||||||
|
const buttons = root.value?.querySelectorAll<HTMLElement>('[data-testid="suggestion-dismiss"]')
|
||||||
|
if (buttons && buttons.length > 0) {
|
||||||
|
buttons[Math.min(removedIndex, buttons.length - 1)]?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root.value?.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.suggestions.map((suggestion) => suggestion.code),
|
||||||
|
(codes) => {
|
||||||
|
if (removedCode !== null && !codes.includes(removedCode)) {
|
||||||
|
removedCode = null
|
||||||
|
focusAfterRemoval()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.dismissingCode,
|
||||||
|
(code) => {
|
||||||
|
if (code === null && removedCode !== null) {
|
||||||
|
removedCode = null
|
||||||
|
focusAfterRemoval()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
ref="root"
|
||||||
class="flex flex-col gap-3 bg-elevated p-3"
|
class="flex flex-col gap-3 bg-elevated p-3"
|
||||||
:aria-busy="checking ? 'true' : undefined"
|
:aria-busy="checking ? 'true' : undefined"
|
||||||
data-testid="suggestion-list"
|
data-testid="suggestion-list"
|
||||||
@@ -69,12 +120,24 @@ const checking = computed(() => isChecking(props.phase))
|
|||||||
<UIcon name="i-lucide-circle-check" class="mt-0.5 size-3.5 shrink-0 text-success" />
|
<UIcon name="i-lucide-circle-check" class="mt-0.5 size-3.5 shrink-0 text-success" />
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
<span class="font-semibold text-highlighted">{{ t('All good') }}</span>
|
<span class="font-semibold text-highlighted">{{ t('All good') }}</span>
|
||||||
<span class="text-muted">{{ t('Nothing to suggest for this article.') }}</span>
|
<span class="text-muted">{{
|
||||||
|
dismissedCount > 0 ? dismissedText : t('Nothing to suggest for this article.')
|
||||||
|
}}</span>
|
||||||
|
<UButton
|
||||||
|
v-if="dismissedCount > 0"
|
||||||
|
color="neutral"
|
||||||
|
variant="link"
|
||||||
|
class="min-h-11 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
|
||||||
|
data-testid="suggestions-restore"
|
||||||
|
@click="emit('restore')"
|
||||||
|
>
|
||||||
|
{{ t('Show dismissed') }}
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<div
|
||||||
v-for="suggestion in shown"
|
v-for="(suggestion, index) in shown"
|
||||||
:key="suggestion.code"
|
:key="suggestion.code"
|
||||||
class="flex items-start gap-2"
|
class="flex items-start gap-2"
|
||||||
data-testid="suggestion"
|
data-testid="suggestion"
|
||||||
@@ -95,13 +158,47 @@ const checking = computed(() => isChecking(props.phase))
|
|||||||
{{ suggestionLinkLabel(suggestion.field) }}
|
{{ suggestionLinkLabel(suggestion.field) }}
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
<UButton
|
||||||
|
v-if="!compact"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
icon="i-lucide-x"
|
||||||
|
:loading="dismissingCode === suggestion.code"
|
||||||
|
:disabled="dismissingCode !== null"
|
||||||
|
:aria-busy="dismissingCode === suggestion.code ? 'true' : undefined"
|
||||||
|
:aria-label="t('Dismiss suggestion: {0}', [suggestionMessage(suggestion)])"
|
||||||
|
class="size-11 shrink-0 justify-center p-0 text-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary md:size-6"
|
||||||
|
:ui="{ leadingIcon: 'size-3.5' }"
|
||||||
|
data-testid="suggestion-dismiss"
|
||||||
|
@click="dismiss(suggestion.code, index)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
v-if="!compact"
|
v-if="!compact"
|
||||||
class="text-xs/4.5 text-pretty text-muted"
|
class="text-xs/4.5 text-pretty text-muted"
|
||||||
data-testid="suggestions-note"
|
data-testid="suggestions-note"
|
||||||
>{{ t('Suggestions never block saving or publishing.') }}</span
|
>{{
|
||||||
|
t(
|
||||||
|
'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.',
|
||||||
|
)
|
||||||
|
}}</span
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
v-if="!compact && dismissedCount > 0"
|
||||||
|
class="flex flex-col gap-0.5"
|
||||||
|
data-testid="suggestions-dismissed"
|
||||||
|
>
|
||||||
|
<span class="text-xs/4.5 text-muted">{{ dismissedText }}</span>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="link"
|
||||||
|
class="min-h-11 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
|
||||||
|
data-testid="suggestions-restore"
|
||||||
|
@click="emit('restore')"
|
||||||
|
>
|
||||||
|
{{ t('Show dismissed') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
<UButton
|
<UButton
|
||||||
v-if="hiddenCount > 0"
|
v-if="hiddenCount > 0"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Article } from '@/composables/useArticles'
|
||||||
|
import { DEFAULT_ARTICLE_SORT } from '@/lib/articleListQuery'
|
||||||
|
import { useArticleRowActions } from '../useArticleRowActions'
|
||||||
|
|
||||||
|
const confirmDelete = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/composables/useArticleActions', () => ({
|
||||||
|
useArticleActions: () => ({
|
||||||
|
confirmDelete: (...args: unknown[]) => confirmDelete(...args),
|
||||||
|
publish: vi.fn(),
|
||||||
|
deleteArticle: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/i18n/presentation', () => ({
|
||||||
|
useLocalizedToast: () => ({ add: vi.fn() }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const article = {
|
||||||
|
id: 4,
|
||||||
|
title: 'Winter checklist',
|
||||||
|
body: '',
|
||||||
|
is_online: false,
|
||||||
|
created_at: '2026-09-02T09:00:00Z',
|
||||||
|
updated_at: '2026-09-05T09:00:00Z',
|
||||||
|
} as Article
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
return useArticleRowActions({
|
||||||
|
items: ref([article]),
|
||||||
|
total: ref(1),
|
||||||
|
counts: ref({ all: 1, online: 0, draft: 1 }),
|
||||||
|
shown: ref({
|
||||||
|
page: 1,
|
||||||
|
size: 25,
|
||||||
|
q: '',
|
||||||
|
status: '',
|
||||||
|
sort: DEFAULT_ARTICLE_SORT,
|
||||||
|
site: '',
|
||||||
|
seo: '',
|
||||||
|
}),
|
||||||
|
reload: vi.fn(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('handleDelete', () => {
|
||||||
|
it('opens one confirm dialog when asked twice in a row', async () => {
|
||||||
|
confirmDelete.mockReset()
|
||||||
|
let settle: (value: boolean) => void = () => undefined
|
||||||
|
confirmDelete.mockReturnValue(new Promise<boolean>((resolve) => (settle = resolve)))
|
||||||
|
const { handleDelete } = setup()
|
||||||
|
|
||||||
|
const first = handleDelete(article)
|
||||||
|
const second = handleDelete(article)
|
||||||
|
expect(confirmDelete).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
settle(false)
|
||||||
|
await Promise.all([first, second])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows another dialog once the first one is settled', async () => {
|
||||||
|
confirmDelete.mockReset()
|
||||||
|
confirmDelete.mockResolvedValue(false)
|
||||||
|
const { handleDelete } = setup()
|
||||||
|
|
||||||
|
await handleDelete(article)
|
||||||
|
await handleDelete(article)
|
||||||
|
|
||||||
|
expect(confirmDelete).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -180,9 +180,19 @@ export function useArticleRowActions({
|
|||||||
document.querySelector<HTMLElement>('[data-testid="articles-heading"]')?.focus()
|
document.querySelector<HTMLElement>('[data-testid="articles-heading"]')?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let deleteDialogOpen = false
|
||||||
|
|
||||||
async function handleDelete(article: Article) {
|
async function handleDelete(article: Article) {
|
||||||
|
if (deleteDialogOpen) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleteDialogOpen = true
|
||||||
const idsAtOpen = items.value.map((existing) => existing.id)
|
const idsAtOpen = items.value.map((existing) => existing.id)
|
||||||
const confirmed = await actions.confirmDelete(article, () => performDelete(article))
|
const confirmed = await actions
|
||||||
|
.confirmDelete(article, () => performDelete(article))
|
||||||
|
.finally(() => {
|
||||||
|
deleteDialogOpen = false
|
||||||
|
})
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
focusRowOrHeading(idsAtOpen, article.id)
|
focusRowOrHeading(idsAtOpen, article.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import type { SuggestionCode } from '@/lib/seoTypes'
|
||||||
import type { ArticleSeo } from '@/lib/articleSeo'
|
import type { ArticleSeo } from '@/lib/articleSeo'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import type { Byline } from '@/lib/articleTypes'
|
import type { Byline } from '@/lib/articleTypes'
|
||||||
import type { ArticleStatus } from '@/lib/articleListQuery'
|
import type { ArticleStatus, SEO_FILTER } from '@/lib/articleListQuery'
|
||||||
|
|
||||||
export type { Byline }
|
export type { Byline }
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ export interface Article extends Partial<ArticleSeo> {
|
|||||||
authors?: Byline[]
|
authors?: Byline[]
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
seo_status?: string
|
||||||
|
seo_dismissed?: SuggestionCode[]
|
||||||
|
seo_suggestions?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { ArticleStatus }
|
export type { ArticleStatus }
|
||||||
@@ -39,6 +43,13 @@ export interface ListArticlesParams {
|
|||||||
status?: ArticleStatus | ''
|
status?: ArticleStatus | ''
|
||||||
sort?: string
|
sort?: string
|
||||||
site?: string
|
site?: string
|
||||||
|
seo?: '' | typeof SEO_FILTER
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CountNeedsAttentionParams {
|
||||||
|
q?: string
|
||||||
|
status?: ArticleStatus | ''
|
||||||
|
site?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useArticles() {
|
export function useArticles() {
|
||||||
@@ -62,8 +73,26 @@ export function useArticles() {
|
|||||||
if (params.site) {
|
if (params.site) {
|
||||||
query.set('site', params.site)
|
query.set('site', params.site)
|
||||||
}
|
}
|
||||||
|
if (params.seo) {
|
||||||
|
query.set('seo', params.seo)
|
||||||
|
}
|
||||||
return request<ArticlePage>(`/v1/article/?${query.toString()}`)
|
return request<ArticlePage>(`/v1/article/?${query.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { listArticles }
|
async function countNeedsAttention(params: CountNeedsAttentionParams): Promise<number> {
|
||||||
|
const query = new URLSearchParams({ seo: 'suggestions', page: '1', size: '5' })
|
||||||
|
if (params.q) {
|
||||||
|
query.set('q', params.q)
|
||||||
|
}
|
||||||
|
if (params.status) {
|
||||||
|
query.set('status', params.status)
|
||||||
|
}
|
||||||
|
if (params.site) {
|
||||||
|
query.set('site', params.site)
|
||||||
|
}
|
||||||
|
const page = await request<Pick<ArticlePage, 'total'>>(`/v1/article/?${query.toString()}`)
|
||||||
|
return page.total
|
||||||
|
}
|
||||||
|
|
||||||
|
return { listArticles, countNeedsAttention }
|
||||||
}
|
}
|
||||||
@@ -43,11 +43,13 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
|||||||
}, SEO_CHECK_DEBOUNCE_MS)
|
}, SEO_CHECK_DEBOUNCE_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function send(body: SeoCheckBody, payload: string) {
|
async function send(body: SeoCheckBody, payload: string, silent: boolean) {
|
||||||
controller?.abort()
|
controller?.abort()
|
||||||
controller = new AbortController()
|
controller = new AbortController()
|
||||||
const current = ++ticket
|
const current = ++ticket
|
||||||
phase.value = 'pending'
|
if (!silent) {
|
||||||
|
phase.value = 'pending'
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const answer = await request<SeoCheckResult>(
|
const answer = await request<SeoCheckResult>(
|
||||||
`/v1/article/${options.articleId}/seo/check`,
|
`/v1/article/${options.articleId}/seo/check`,
|
||||||
@@ -81,7 +83,7 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function run(force = false) {
|
function run(force = false, silent = false) {
|
||||||
clearTimer()
|
clearTimer()
|
||||||
const body = options.body()
|
const body = options.body()
|
||||||
const payload = JSON.stringify(body)
|
const payload = JSON.stringify(body)
|
||||||
@@ -89,7 +91,7 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
|||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
lastPayload = payload
|
lastPayload = payload
|
||||||
return send(body, payload)
|
return send(body, payload, silent)
|
||||||
}
|
}
|
||||||
|
|
||||||
function runNow() {
|
function runNow() {
|
||||||
@@ -100,6 +102,26 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
|||||||
return run(true)
|
return run(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runQuietly() {
|
||||||
|
return run(true, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropSuggestion(code: string) {
|
||||||
|
const current = result.value
|
||||||
|
if (!current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const remaining = current.suggestions.filter((suggestion) => suggestion.code !== code)
|
||||||
|
result.value = {
|
||||||
|
...current,
|
||||||
|
suggestions: remaining,
|
||||||
|
status:
|
||||||
|
remaining.length === 0 && current.status === 'suggestions'
|
||||||
|
? 'ready'
|
||||||
|
: current.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function restore() {
|
function restore() {
|
||||||
const payload = JSON.stringify(options.body())
|
const payload = JSON.stringify(options.body())
|
||||||
const answer = answers.get(payload)
|
const answer = answers.get(payload)
|
||||||
@@ -143,6 +165,8 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
|||||||
schedule,
|
schedule,
|
||||||
runNow,
|
runNow,
|
||||||
runFresh,
|
runFresh,
|
||||||
|
runQuietly,
|
||||||
|
dropSuggestion,
|
||||||
restore,
|
restore,
|
||||||
invalidate,
|
invalidate,
|
||||||
stop,
|
stop,
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export interface NewSite {
|
|||||||
default_robots: SiteRobots
|
default_robots: SiteRobots
|
||||||
locale?: string
|
locale?: string
|
||||||
default_social_image_id?: number
|
default_social_image_id?: number
|
||||||
|
site_name?: string
|
||||||
|
title_template?: string
|
||||||
|
default_description?: string
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +38,9 @@ export interface SitePatch {
|
|||||||
default_robots?: SiteRobots
|
default_robots?: SiteRobots
|
||||||
locale?: string | null
|
locale?: string | null
|
||||||
default_social_image_id?: number | null
|
default_social_image_id?: number | null
|
||||||
|
site_name?: string
|
||||||
|
title_template?: string
|
||||||
|
default_description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSites() {
|
export function useSites() {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
|||||||
Articles: [
|
Articles: [
|
||||||
'GET /v1/article/',
|
'GET /v1/article/',
|
||||||
'GET /v1/article/{id}',
|
'GET /v1/article/{id}',
|
||||||
'GET /v1/article/{id}/seo',
|
|
||||||
'POST /v1/article/',
|
'POST /v1/article/',
|
||||||
'PATCH /v1/article/{id}',
|
'PATCH /v1/article/{id}',
|
||||||
'PATCH /v1/article/{id}/publish',
|
'PATCH /v1/article/{id}/publish',
|
||||||
@@ -29,6 +28,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
|||||||
'DELETE /v1/article/{id}',
|
'DELETE /v1/article/{id}',
|
||||||
'POST /v1/article/{id}/seo/check',
|
'POST /v1/article/{id}/seo/check',
|
||||||
],
|
],
|
||||||
|
'Search and sharing': ['GET /v1/article/{id}/seo', 'GET /v1/site/{key}/sitemap'],
|
||||||
Media: [
|
Media: [
|
||||||
'GET /v1/media/',
|
'GET /v1/media/',
|
||||||
'POST /v1/media/',
|
'POST /v1/media/',
|
||||||
@@ -50,7 +50,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
|||||||
'GET /v1/auth/me',
|
'GET /v1/auth/me',
|
||||||
],
|
],
|
||||||
Settings: ['GET /v1/settings'],
|
Settings: ['GET /v1/settings'],
|
||||||
Sites: ['GET /v1/site/', 'GET /v1/site/{key}/sitemap'],
|
Sites: ['GET /v1/site/'],
|
||||||
Authors: ['GET /v1/author/'],
|
Authors: ['GET /v1/author/'],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +207,16 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Catalogue routes', () => {
|
describe('Catalogue routes', () => {
|
||||||
it('holds exactly the 29 key-callable routes in 6 groups, in order', () => {
|
it('holds exactly the 29 key-callable routes in 7 groups, in order', () => {
|
||||||
|
expect(API_GROUPS.map((group) => group.title)).toEqual([
|
||||||
|
'Articles',
|
||||||
|
'Search and sharing',
|
||||||
|
'Media',
|
||||||
|
'Users and invites',
|
||||||
|
'Settings',
|
||||||
|
'Sites',
|
||||||
|
'Authors',
|
||||||
|
])
|
||||||
expect(
|
expect(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
|
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
|
||||||
@@ -242,7 +251,7 @@ describe('Catalogue routes', () => {
|
|||||||
),
|
),
|
||||||
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
|
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
|
||||||
]
|
]
|
||||||
expect(described).toHaveLength(6 + 29 + 25 + 6)
|
expect(described).toHaveLength(7 + 29 + 25 + 6)
|
||||||
const blank = described.filter(([, description]) => description.trim() === '')
|
const blank = described.filter(([, description]) => description.trim() === '')
|
||||||
expect(blank.map(([name]) => name)).toEqual([])
|
expect(blank.map(([name]) => name)).toEqual([])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface ErrorRow {
|
|||||||
status: number
|
status: number
|
||||||
meaning: string
|
meaning: string
|
||||||
messages: string[]
|
messages: string[]
|
||||||
|
note?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EndpointExamples {
|
export interface EndpointExamples {
|
||||||
@@ -596,15 +597,6 @@ export const API_GROUPS: ApiGroup[] = [
|
|||||||
json(article()),
|
json(article()),
|
||||||
{ query: [FORMAT_QUERY] },
|
{ query: [FORMAT_QUERY] },
|
||||||
),
|
),
|
||||||
endpoint(
|
|
||||||
'art-seo',
|
|
||||||
'GET',
|
|
||||||
'/v1/article/{id}/seo',
|
|
||||||
'Get the resolved SEO object of an article: title, description, canonical address, robots, Open Graph, Twitter, image, authors, dates and ready JSON-LD. Drafts answer 404 unless the caller is verified.',
|
|
||||||
'Public',
|
|
||||||
[e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Article not found')],
|
|
||||||
json(resolvedSeo()),
|
|
||||||
),
|
|
||||||
endpoint(
|
endpoint(
|
||||||
'art-create',
|
'art-create',
|
||||||
'POST',
|
'POST',
|
||||||
@@ -821,6 +813,33 @@ export const API_GROUPS: ApiGroup[] = [
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'seo',
|
||||||
|
title: 'Search and sharing',
|
||||||
|
description:
|
||||||
|
"RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs.",
|
||||||
|
endpoints: [
|
||||||
|
endpoint(
|
||||||
|
'art-seo',
|
||||||
|
'GET',
|
||||||
|
'/v1/article/{id}/seo',
|
||||||
|
'Get the resolved SEO object of an article: title with the site template applied, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks and json_ld. Drafts answer 404 to anonymous callers.',
|
||||||
|
'Public',
|
||||||
|
[e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Article not found')],
|
||||||
|
json(resolvedSeo()),
|
||||||
|
),
|
||||||
|
endpoint(
|
||||||
|
'site-sitemap',
|
||||||
|
'GET',
|
||||||
|
'/v1/site/{key}/sitemap',
|
||||||
|
'List the published URLs of a site, by id ascending, as an array of { url, modified_at, noindex }. The total is in the X-Total-Count header.',
|
||||||
|
'Public',
|
||||||
|
[e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(404, 'site_not_found')],
|
||||||
|
json(sitemapPage()),
|
||||||
|
{ query: SITEMAP_QUERY },
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'media',
|
key: 'media',
|
||||||
title: 'Media',
|
title: 'Media',
|
||||||
@@ -1199,7 +1218,7 @@ export const API_GROUPS: ApiGroup[] = [
|
|||||||
key: 'sites',
|
key: 'sites',
|
||||||
title: 'Sites',
|
title: 'Sites',
|
||||||
description:
|
description:
|
||||||
'The sites an article can belong to. Any verified user can read them and anyone can read a sitemap; changes are made in the browser by the owner.',
|
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
endpoint(
|
endpoint(
|
||||||
'site-list',
|
'site-list',
|
||||||
@@ -1226,16 +1245,6 @@ export const API_GROUPS: ApiGroup[] = [
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
endpoint(
|
|
||||||
'site-sitemap',
|
|
||||||
'GET',
|
|
||||||
'/v1/site/{key}/sitemap',
|
|
||||||
'List the published URLs of a site, by id ascending, as an array. The total is in the X-Total-Count header.',
|
|
||||||
'Public',
|
|
||||||
[e(400, BODY_MISMATCH), e(401, UNAUTHORIZED), e(404, 'site_not_found')],
|
|
||||||
json(sitemapPage()),
|
|
||||||
{ query: SITEMAP_QUERY },
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1407,6 +1416,7 @@ export const ERROR_ROWS: ErrorRow[] = [
|
|||||||
status: 403,
|
status: 403,
|
||||||
meaning: 'Forbidden',
|
meaning: 'Forbidden',
|
||||||
messages: [SESSION_REQUIRED_MESSAGE, OWNER_REQUIRED_MESSAGE, VERIFY_REQUIRED],
|
messages: [SESSION_REQUIRED_MESSAGE, OWNER_REQUIRED_MESSAGE, VERIFY_REQUIRED],
|
||||||
|
note: 'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
status: 404,
|
status: 404,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const SOURCES = [
|
|||||||
'lib/directory.ts',
|
'lib/directory.ts',
|
||||||
'lib/directoryCopy.ts',
|
'lib/directoryCopy.ts',
|
||||||
'lib/directoryRules.ts',
|
'lib/directoryRules.ts',
|
||||||
|
'lib/siteTitleTemplate.ts',
|
||||||
'views/AuthorsView.vue',
|
'views/AuthorsView.vue',
|
||||||
'views/SitesView.vue',
|
'views/SitesView.vue',
|
||||||
]
|
]
|
||||||
@@ -44,6 +45,9 @@ const SAME_IN_FRENCH = new Set([
|
|||||||
'Sites',
|
'Sites',
|
||||||
'Guest',
|
'Guest',
|
||||||
'Linked',
|
'Linked',
|
||||||
|
'Description',
|
||||||
|
'Image',
|
||||||
|
'Robots',
|
||||||
])
|
])
|
||||||
|
|
||||||
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
|
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
|
||||||
|
|||||||
+115
-4
@@ -1967,10 +1967,6 @@ export const messages = {
|
|||||||
'Lié à votre compte. Les nouveaux articles commencent avec cet auteur.',
|
'Lié à votre compte. Les nouveaux articles commencent avec cet auteur.',
|
||||||
"You don't have an author profile yet. Create one to be credited on your own articles.":
|
"You don't have an author profile yet. Create one to be credited on your own articles.":
|
||||||
'Vous n’avez pas encore de profil d’auteur. Créez-en un pour être crédité sur vos propres articles.',
|
'Vous n’avez pas encore de profil d’auteur. Créez-en un pour être crédité sur vos propres articles.',
|
||||||
'The sites an article can belong to. Any verified user can read them and anyone can read a sitemap; changes are made in the browser by the owner.':
|
|
||||||
'Les sites auxquels un article peut appartenir. Tout utilisateur vérifié peut les lire et tout le monde peut lire un sitemap ; les modifications se font dans le navigateur par le propriétaire.',
|
|
||||||
'List the published URLs of a site, by id ascending, as an array. The total is in the X-Total-Count header.':
|
|
||||||
'Liste les adresses publiées d’un site, par identifiant croissant, sous forme de tableau. Le total est dans l’en-tête X-Total-Count.',
|
|
||||||
'The request cannot be processed due to a mismatch in the format of the body.':
|
'The request cannot be processed due to a mismatch in the format of the body.':
|
||||||
'La requête ne peut pas être traitée car le format du corps ne correspond pas.',
|
'La requête ne peut pas être traitée car le format du corps ne correspond pas.',
|
||||||
site_not_found: 'Site introuvable',
|
site_not_found: 'Site introuvable',
|
||||||
@@ -2144,6 +2140,121 @@ export const messages = {
|
|||||||
'cover image': 'image de couverture',
|
'cover image': 'image de couverture',
|
||||||
'site social image': 'image de partage du site',
|
'site social image': 'image de partage du site',
|
||||||
'site address + slug': 'adresse du site + slug',
|
'site address + slug': 'adresse du site + slug',
|
||||||
|
'Site name': 'Nom du site',
|
||||||
|
'Shown in titles through the template, and sent as the Open Graph site name.':
|
||||||
|
'Affiché dans les titres via le modèle, et envoyé comme nom de site Open Graph.',
|
||||||
|
'e.g. Renew': 'p. ex. Renew',
|
||||||
|
'Title template': 'Modèle de titre',
|
||||||
|
'Use %title% for the article title and %site% for the site name.':
|
||||||
|
'Utilisez %title% pour le titre de l’article et %site% pour le nom du site.',
|
||||||
|
'The template must contain %title%.': 'Le modèle doit contenir %title%.',
|
||||||
|
Example: 'Exemple',
|
||||||
|
'Default description': 'Description par défaut',
|
||||||
|
'Used when an article has no description, excerpt or body text.':
|
||||||
|
'Utilisée quand un article n’a ni description, ni extrait, ni texte.',
|
||||||
|
'One or two sentences about the site.': 'Une ou deux phrases sur le site.',
|
||||||
|
'How articles use these defaults': 'Comment les articles utilisent ces valeurs par défaut',
|
||||||
|
'Article title, then the template.': 'Titre de l’article, puis le modèle.',
|
||||||
|
Description: 'Description',
|
||||||
|
"Used last, after the article's description, excerpt and body.":
|
||||||
|
'Utilisée en dernier, après la description, l’extrait et le texte de l’article.',
|
||||||
|
'Used when an article has no cover image.':
|
||||||
|
'Utilisée quand un article n’a pas d’image de couverture.',
|
||||||
|
Robots: 'Robots',
|
||||||
|
'Used unless an article is hidden from search engines.':
|
||||||
|
'Utilisée sauf si l’article est masqué des moteurs de recherche.',
|
||||||
|
'Needs attention': 'À traiter',
|
||||||
|
Hidden: 'Masqué',
|
||||||
|
'Hidden from search engines': 'Masqué des moteurs de recherche',
|
||||||
|
'Nothing needs attention': 'Rien à traiter',
|
||||||
|
'Every article has a clean check. Suggestions are optional, so this is just a bonus.':
|
||||||
|
'Tous les articles passent la vérification. Les suggestions sont facultatives, c’est un petit plus.',
|
||||||
|
'Show all articles': 'Afficher tous les articles',
|
||||||
|
'1 dismissed suggestion is hidden.': '1 suggestion ignorée est masquée.',
|
||||||
|
'{0} dismissed suggestions are hidden.': '{0} suggestions ignorées sont masquées.',
|
||||||
|
'Show dismissed': 'Afficher les suggestions ignorées',
|
||||||
|
'Dismiss suggestion: {0}': 'Ignorer la suggestion : {0}',
|
||||||
|
'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.':
|
||||||
|
'Les suggestions ne bloquent jamais l’enregistrement ni la publication. Ignorez-en une et elle reste masquée pour cet article.',
|
||||||
|
"Couldn't dismiss the suggestion": 'Impossible d’ignorer la suggestion',
|
||||||
|
'The key of a site. Only its articles are listed. An unknown key answers an empty page.':
|
||||||
|
'La clé d’un site. Seuls ses articles sont listés. Une clé inconnue renvoie une page vide.',
|
||||||
|
'ready, suggestions or hidden. Only the articles with that SEO status are listed, before paging; counts stay unfiltered. Any other value is refused.':
|
||||||
|
'ready, suggestions ou hidden. Seuls les articles ayant ce statut SEO sont listés, avant la pagination ; les compteurs restent non filtrés. Toute autre valeur est refusée.',
|
||||||
|
'Update the body of an article, move it to another site, or edit its SEO fields. Send at least one field; absent fields are untouched.':
|
||||||
|
'Modifie le corps d’un article, le déplace vers un autre site ou modifie ses champs SEO. Envoyez au moins un champ ; les champs absents ne sont pas touchés.',
|
||||||
|
'slug_taken (the X-Slug-Suggestion header holds the first free slug)':
|
||||||
|
'slug_taken (l’en-tête X-Slug-Suggestion contient le premier slug libre)',
|
||||||
|
'Up to 10 000 characters. An empty string empties the body; null is refused.':
|
||||||
|
'Jusqu’à 10 000 caractères. Une chaîne vide vide le corps ; null est refusé.',
|
||||||
|
'The id of an existing site, 1 or more. null is refused.':
|
||||||
|
'L’identifiant d’un site existant, 1 ou plus. null est refusé.',
|
||||||
|
'Lowercase letters, digits and single dashes, 1 to 100 characters, unique on the site. An empty string regenerates it from the title. null is refused.':
|
||||||
|
'Lettres minuscules, chiffres et tirets simples, de 1 à 100 caractères, unique sur le site. Une chaîne vide le régénère à partir du titre. null est refusé.',
|
||||||
|
'Up to 300 characters. An empty string clears it; null is refused.':
|
||||||
|
'Jusqu’à 300 caractères. Une chaîne vide l’efface ; null est refusé.',
|
||||||
|
'Up to 120 characters. An empty string clears it; null is refused.':
|
||||||
|
'Jusqu’à 120 caractères. Une chaîne vide l’efface ; null est refusé.',
|
||||||
|
'Up to 320 characters. An empty string clears it; null is refused.':
|
||||||
|
'Jusqu’à 320 caractères. Une chaîne vide l’efface ; null est refusé.',
|
||||||
|
'The id of an existing image, 1 or more. null removes the cover.':
|
||||||
|
'L’identifiant d’une image existante, 1 ou plus. null retire la couverture.',
|
||||||
|
'Up to 200 characters. An empty string clears it; null is refused.':
|
||||||
|
'Jusqu’à 200 caractères. Une chaîne vide l’efface ; null est refusé.',
|
||||||
|
'The id of an existing image, 1 or more. null removes the social image.':
|
||||||
|
'L’identifiant d’une image existante, 1 ou plus. null retire l’image de partage.',
|
||||||
|
'An absolute https address with a host, up to 2048 characters. An empty string clears it; null is refused.':
|
||||||
|
'Une adresse https absolue avec un hôte, jusqu’à 2048 caractères. Une chaîne vide l’efface ; null est refusé.',
|
||||||
|
'Only a flag: the article stays online. null is refused.':
|
||||||
|
'Un simple indicateur : l’article reste en ligne. null est refusé.',
|
||||||
|
'Dismissed suggestion codes: title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short. Duplicates are collapsed; null is refused.':
|
||||||
|
'Codes des suggestions ignorées : title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt, short. Les doublons sont fusionnés ; null est refusé.',
|
||||||
|
'Check the SEO of an article without saving anything. Send {} for the saved state, or fields that replace the saved values for this check only.':
|
||||||
|
'Vérifie le SEO d’un article sans rien enregistrer. Envoyez {} pour l’état enregistré, ou des champs qui remplacent les valeurs enregistrées pour cette vérification seulement.',
|
||||||
|
'Replaces the saved body for this check only. null is refused.':
|
||||||
|
'Remplace le corps enregistré pour cette vérification seulement. null est refusé.',
|
||||||
|
'Same rules as PATCH. An empty string regenerates it from the title. null is refused.':
|
||||||
|
'Mêmes règles que PATCH. Une chaîne vide le régénère à partir du titre. null est refusé.',
|
||||||
|
'Up to 300 characters. null is refused.': 'Jusqu’à 300 caractères. null est refusé.',
|
||||||
|
'Up to 120 characters. null is refused.': 'Jusqu’à 120 caractères. null est refusé.',
|
||||||
|
'Up to 320 characters. null is refused.': 'Jusqu’à 320 caractères. null est refusé.',
|
||||||
|
'The id of an existing image. null means no cover.':
|
||||||
|
'L’identifiant d’une image existante. null signifie pas de couverture.',
|
||||||
|
'Up to 200 characters. null is refused.': 'Jusqu’à 200 caractères. null est refusé.',
|
||||||
|
'The id of an existing image. null means no social image.':
|
||||||
|
'L’identifiant d’une image existante. null signifie pas d’image de partage.',
|
||||||
|
'An absolute https address with a host, up to 2048 characters. null is refused.':
|
||||||
|
'Une adresse https absolue avec un hôte, jusqu’à 2048 caractères. null est refusé.',
|
||||||
|
'null is refused.': 'null est refusé.',
|
||||||
|
'Dismissed suggestion codes, replacing the saved list for this check only. null is refused.':
|
||||||
|
'Codes des suggestions ignorées, qui remplacent la liste enregistrée pour cette vérification seulement. null est refusé.',
|
||||||
|
"RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs.":
|
||||||
|
'RenewCMS n’affiche pas de pages et n’ajoute pas de balises meta. Ces routes donnent à votre frontend les données SEO résolues et la liste des URL publiées.',
|
||||||
|
'Get the resolved SEO object of an article: title with the site template applied, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks and json_ld. Drafts answer 404 to anonymous callers.':
|
||||||
|
'Renvoie l’objet SEO résolu d’un article : title avec le modèle du site appliqué, description, canonical_url, robots, og, twitter, image, authors, published_at, modified_at, fallbacks et json_ld. Les brouillons répondent 404 aux appelants anonymes.',
|
||||||
|
'List the published URLs of a site, by id ascending, as an array of { url, modified_at, noindex }. The total is in the X-Total-Count header.':
|
||||||
|
'Liste les adresses publiées d’un site, par identifiant croissant, sous forme de tableau de { url, modified_at, noindex }. Le total est dans l’en-tête X-Total-Count.',
|
||||||
|
'The sites an article can belong to. Any verified user can read them; changes are made in the browser by the owner.':
|
||||||
|
'Les sites auxquels un article peut appartenir. Tout utilisateur vérifié peut les lire ; les modifications se font dans le navigateur par le propriétaire.',
|
||||||
|
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.':
|
||||||
|
'Les valeurs SEO par défaut d’un site sont réservées au propriétaire. Les champs SEO d’un article demandent seulement le droit de modifier l’article.',
|
||||||
|
'The server expects the ID to be in the format of an unsigned 32-bit integer (uint32).':
|
||||||
|
'Le serveur attend un identifiant au format d’un entier non signé de 32 bits (uint32).',
|
||||||
|
'Article not found': 'Article introuvable',
|
||||||
|
'Choose an image to keep and at least one other image to merge into it.':
|
||||||
|
'Choisissez une image à conserver et au moins une autre image à fusionner dedans.',
|
||||||
|
'Some of the images to merge changed. Refresh and try again.':
|
||||||
|
'Certaines images à fusionner ont changé. Actualisez et réessayez.',
|
||||||
|
'List the articles, authors and sites that use an image, with the places where each one uses it.':
|
||||||
|
'Liste les articles, auteurs et sites qui utilisent une image, avec les endroits où chacun l’utilise.',
|
||||||
|
'Log in. Made for the browser. A key is ignored here, with no 403.':
|
||||||
|
'Se connecter. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||||
|
'Log out. Made for the browser. A key is ignored here, with no 403.':
|
||||||
|
'Se déconnecter. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||||
|
'Register. Made for the browser. A key is ignored here, with no 403.':
|
||||||
|
'S’inscrire. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||||
|
'Reset password. Made for the browser. A key is ignored here, with no 403.':
|
||||||
|
'Réinitialiser le mot de passe. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type MessageKey = keyof typeof messages
|
export type MessageKey = keyof typeof messages
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { API_GROUPS, ERROR_ROWS, PUBLIC_AUTH_ROWS, SESSION_GROUPS } from '@/data/apiCatalogue'
|
||||||
|
import { messages } from './messages'
|
||||||
|
|
||||||
|
const SEO_ROUTES = ['/seo', '/sitemap', '/seo/check']
|
||||||
|
const SAME_IN_FRENCH = new Set(['Articles', 'Sites', 'Hello world.'])
|
||||||
|
const CODES = /^[a-z_]+(?: \(.*\))?$/
|
||||||
|
|
||||||
|
function frenchOf(text: string) {
|
||||||
|
return (messages as Record<string, string>)[text]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Search and sharing catalogue in French', () => {
|
||||||
|
const group = API_GROUPS.find((candidate) => candidate.key === 'seo')!
|
||||||
|
const seoEndpoints = API_GROUPS.flatMap((candidate) => candidate.endpoints).filter((item) =>
|
||||||
|
SEO_ROUTES.some((suffix) => item.path.endsWith(suffix)),
|
||||||
|
)
|
||||||
|
const texts = [
|
||||||
|
group.title,
|
||||||
|
group.description,
|
||||||
|
API_GROUPS.find((candidate) => candidate.key === 'sites')!.description,
|
||||||
|
...ERROR_ROWS.flatMap((row) => (row.note ? [row.note] : [])),
|
||||||
|
...seoEndpoints.flatMap((item) => [
|
||||||
|
item.description,
|
||||||
|
...item.query.map((field) => field.rule),
|
||||||
|
...item.body.map((field) => field.rule),
|
||||||
|
...item.errors.map((error) => error.caption).filter((caption) => !CODES.test(caption)),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
it('covers the group, the note and both routes', () => {
|
||||||
|
expect(seoEndpoints).toHaveLength(3)
|
||||||
|
expect(texts.length).toBeGreaterThan(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([...new Set(texts)])('"%s" has its own French entry', (text) => {
|
||||||
|
expect(frenchOf(text)).toMatch(/\S/)
|
||||||
|
if (!SAME_IN_FRENCH.has(text)) {
|
||||||
|
expect(frenchOf(text)).not.toBe(text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Whole catalogue in French, API-literal errors excepted', () => {
|
||||||
|
const texts = [
|
||||||
|
...API_GROUPS.flatMap((group) => [
|
||||||
|
group.title,
|
||||||
|
group.description,
|
||||||
|
...group.endpoints.flatMap((item) => [
|
||||||
|
item.description,
|
||||||
|
...item.query.map((field) => field.rule),
|
||||||
|
...item.body.map((field) => field.rule),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
...SESSION_GROUPS.flatMap((group) => [
|
||||||
|
group.title,
|
||||||
|
...group.rows.map((row) => row.description),
|
||||||
|
]),
|
||||||
|
...PUBLIC_AUTH_ROWS.map((row) => row.description),
|
||||||
|
...ERROR_ROWS.flatMap((row) => (row.note ? [row.note] : [])),
|
||||||
|
]
|
||||||
|
|
||||||
|
it.each([...new Set(texts)])('"%s" has its own French entry', (text) => {
|
||||||
|
expect(frenchOf(text)).toMatch(/\S/)
|
||||||
|
if (!SAME_IN_FRENCH.has(text)) {
|
||||||
|
expect(frenchOf(text)).not.toBe(text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,6 +9,10 @@ import type { ResolvedSeo, SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
|
|||||||
|
|
||||||
const SOURCES = [
|
const SOURCES = [
|
||||||
'components/ApiPreviewDialog.vue',
|
'components/ApiPreviewDialog.vue',
|
||||||
|
'components/EmptyState.vue',
|
||||||
|
'components/NeedsAttentionToggle.vue',
|
||||||
|
'components/SeoStatusChip.vue',
|
||||||
|
'views/ArticleView.vue',
|
||||||
'components/FallbackChip.vue',
|
'components/FallbackChip.vue',
|
||||||
'components/SearchPreview.vue',
|
'components/SearchPreview.vue',
|
||||||
'components/SearchSharingSection.vue',
|
'components/SearchSharingSection.vue',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ describe('parseArticleQuery', () => {
|
|||||||
status: '',
|
status: '',
|
||||||
sort: '',
|
sort: '',
|
||||||
site: '',
|
site: '',
|
||||||
|
seo: '',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ describe('parseArticleQuery', () => {
|
|||||||
status: 'draft',
|
status: 'draft',
|
||||||
sort: 'title:asc',
|
sort: 'title:asc',
|
||||||
site: '',
|
site: '',
|
||||||
|
seo: '',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -67,7 +69,7 @@ describe('sort parameter', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('omits the default from the route and treats it as equal to no sort', () => {
|
it('omits the default from the route and treats it as equal to no sort', () => {
|
||||||
const base = { page: 1, size: 25, q: '', status: '' as const, site: '' }
|
const base = { page: 1, size: 25, q: '', status: '' as const, site: '', seo: '' as const }
|
||||||
expect(toRouteQuery({ ...base, sort: 'updated_at:desc' })).toEqual({})
|
expect(toRouteQuery({ ...base, sort: 'updated_at:desc' })).toEqual({})
|
||||||
expect(toRouteQuery({ ...base, sort: 'title:asc' })).toEqual({
|
expect(toRouteQuery({ ...base, sort: 'title:asc' })).toEqual({
|
||||||
sort: 'title:asc',
|
sort: 'title:asc',
|
||||||
@@ -98,6 +100,7 @@ describe('toRouteQuery', () => {
|
|||||||
status: '',
|
status: '',
|
||||||
sort: '',
|
sort: '',
|
||||||
site: '',
|
site: '',
|
||||||
|
seo: '',
|
||||||
}),
|
}),
|
||||||
).toEqual({})
|
).toEqual({})
|
||||||
})
|
})
|
||||||
@@ -111,6 +114,7 @@ describe('toRouteQuery', () => {
|
|||||||
status: 'online',
|
status: 'online',
|
||||||
sort: 'title:desc',
|
sort: 'title:desc',
|
||||||
site: '',
|
site: '',
|
||||||
|
seo: '',
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
page: '2',
|
page: '2',
|
||||||
@@ -131,6 +135,7 @@ describe('sameArticleQuery', () => {
|
|||||||
status: '' as const,
|
status: '' as const,
|
||||||
sort: '',
|
sort: '',
|
||||||
site: '',
|
site: '',
|
||||||
|
seo: '',
|
||||||
}
|
}
|
||||||
expect(sameArticleQuery(base, { ...base })).toBe(true)
|
expect(sameArticleQuery(base, { ...base })).toBe(true)
|
||||||
expect(sameArticleQuery(base, { ...base, page: 2 })).toBe(false)
|
expect(sameArticleQuery(base, { ...base, page: 2 })).toBe(false)
|
||||||
@@ -148,7 +153,7 @@ describe('site parameter', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('omits an empty site and tells two queries apart by their site', () => {
|
it('omits an empty site and tells two queries apart by their site', () => {
|
||||||
const base = { page: 1, size: 25, q: '', status: '' as const, sort: '' }
|
const base = { page: 1, size: 25, q: '', status: '' as const, sort: '', seo: '' as const }
|
||||||
expect(toRouteQuery({ ...base, site: '' })).toEqual({})
|
expect(toRouteQuery({ ...base, site: '' })).toEqual({})
|
||||||
expect(sameArticleQuery({ ...base, site: 'journal' }, { ...base, site: '' })).toBe(false)
|
expect(sameArticleQuery({ ...base, site: 'journal' }, { ...base, site: '' })).toBe(false)
|
||||||
expect(sameArticleQuery({ ...base, site: 'journal' }, { ...base, site: 'journal' })).toBe(
|
expect(sameArticleQuery({ ...base, site: 'journal' }, { ...base, site: 'journal' })).toBe(
|
||||||
@@ -156,3 +161,24 @@ describe('site parameter', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('seo parameter', () => {
|
||||||
|
it('reads only the value suggestions and writes it after the site', () => {
|
||||||
|
const parsed = parseArticleQuery({ site: 'journal', seo: 'suggestions' })
|
||||||
|
expect(parsed.seo).toBe('suggestions')
|
||||||
|
expect(Object.keys(toRouteQuery(parsed))).toEqual(['site', 'seo'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['maybe', '', 'READY', 'ready', 'hidden', 'suggestions,x'])(
|
||||||
|
'drops the unknown value "%s"',
|
||||||
|
(seo) => {
|
||||||
|
expect(parseArticleQuery({ seo }).seo).toBe('')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('tells two queries apart by their seo filter', () => {
|
||||||
|
const base = parseArticleQuery({})
|
||||||
|
expect(sameArticleQuery(base, { ...base, seo: 'suggestions' })).toBe(false)
|
||||||
|
expect(sameArticleQuery(base, { ...base })).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,6 +9,8 @@ export const DEFAULT_ARTICLE_SORT = 'updated_at:desc'
|
|||||||
export const ARTICLE_SORT_FIELDS = ['title', 'status', 'created_at', 'updated_at'] as const
|
export const ARTICLE_SORT_FIELDS = ['title', 'status', 'created_at', 'updated_at'] as const
|
||||||
export type ArticleSortField = (typeof ARTICLE_SORT_FIELDS)[number]
|
export type ArticleSortField = (typeof ARTICLE_SORT_FIELDS)[number]
|
||||||
|
|
||||||
|
export const SEO_FILTER = 'suggestions'
|
||||||
|
|
||||||
const SITE_KEY_MAX = 32
|
const SITE_KEY_MAX = 32
|
||||||
const POSITIVE_INTEGER = /^[1-9]\d*$/
|
const POSITIVE_INTEGER = /^[1-9]\d*$/
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ export interface ArticleListQuery {
|
|||||||
status: ArticleStatus | ''
|
status: ArticleStatus | ''
|
||||||
sort: string
|
sort: string
|
||||||
site: string
|
site: string
|
||||||
|
seo: '' | typeof SEO_FILTER
|
||||||
}
|
}
|
||||||
|
|
||||||
function first(value: LocationQuery[string] | undefined) {
|
function first(value: LocationQuery[string] | undefined) {
|
||||||
@@ -52,6 +55,7 @@ export function parseArticleQuery(query: LocationQuery): ArticleListQuery {
|
|||||||
status: status === 'online' || status === 'draft' ? status : '',
|
status: status === 'online' || status === 'draft' ? status : '',
|
||||||
sort: validSort(first(query.sort)),
|
sort: validSort(first(query.sort)),
|
||||||
site: first(query.site).slice(0, SITE_KEY_MAX),
|
site: first(query.site).slice(0, SITE_KEY_MAX),
|
||||||
|
seo: first(query.seo) === SEO_FILTER ? SEO_FILTER : '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +79,9 @@ export function toRouteQuery(query: ArticleListQuery) {
|
|||||||
if (query.site) {
|
if (query.site) {
|
||||||
route.site = query.site
|
route.site = query.site
|
||||||
}
|
}
|
||||||
|
if (query.seo) {
|
||||||
|
route.seo = query.seo
|
||||||
|
}
|
||||||
return route
|
return route
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +92,7 @@ export function sameArticleQuery(left: ArticleListQuery, right: ArticleListQuery
|
|||||||
left.q === right.q &&
|
left.q === right.q &&
|
||||||
left.status === right.status &&
|
left.status === right.status &&
|
||||||
left.site === right.site &&
|
left.site === right.site &&
|
||||||
|
left.seo === right.seo &&
|
||||||
effectiveSort(left.sort) === effectiveSort(right.sort)
|
effectiveSort(left.sort) === effectiveSort(right.sort)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export type SeoChip = { kind: 'suggestions'; count: number } | { kind: 'hidden' }
|
||||||
|
|
||||||
|
interface SeoStatusSource {
|
||||||
|
seo_status?: unknown
|
||||||
|
seo_suggestions?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seoChipOf(article: SeoStatusSource): SeoChip | null {
|
||||||
|
if (article.seo_status === 'hidden') {
|
||||||
|
return { kind: 'hidden' }
|
||||||
|
}
|
||||||
|
const count = article.seo_suggestions
|
||||||
|
if (article.seo_status === 'suggestions' && typeof count === 'number' && count > 0) {
|
||||||
|
return { kind: 'suggestions', count }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export const SITE_CODES = {
|
|||||||
defaultSite: 'default_site',
|
defaultSite: 'default_site',
|
||||||
invalidUrl: 'invalid_site_url',
|
invalidUrl: 'invalid_site_url',
|
||||||
invalidLocale: 'invalid_locale',
|
invalidLocale: 'invalid_locale',
|
||||||
|
invalidTitleTemplate: 'invalid_title_template',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const AUTHOR_CODES = {
|
export const AUTHOR_CODES = {
|
||||||
@@ -45,6 +46,7 @@ export type FailureKind =
|
|||||||
| 'keyTaken'
|
| 'keyTaken'
|
||||||
| 'invalidSiteUrl'
|
| 'invalidSiteUrl'
|
||||||
| 'invalidLocale'
|
| 'invalidLocale'
|
||||||
|
| 'invalidTitleTemplate'
|
||||||
| 'slugTaken'
|
| 'slugTaken'
|
||||||
| 'userLinked'
|
| 'userLinked'
|
||||||
| 'bioTooLong'
|
| 'bioTooLong'
|
||||||
@@ -73,6 +75,7 @@ export function classifyFailure(error: unknown, context: FailureContext): Failur
|
|||||||
[`409 ${SITE_CODES.hasArticles}`]: 'hasArticles',
|
[`409 ${SITE_CODES.hasArticles}`]: 'hasArticles',
|
||||||
[`400 ${SITE_CODES.invalidUrl}`]: 'invalidSiteUrl',
|
[`400 ${SITE_CODES.invalidUrl}`]: 'invalidSiteUrl',
|
||||||
[`400 ${SITE_CODES.invalidLocale}`]: 'invalidLocale',
|
[`400 ${SITE_CODES.invalidLocale}`]: 'invalidLocale',
|
||||||
|
[`400 ${SITE_CODES.invalidTitleTemplate}`]: 'invalidTitleTemplate',
|
||||||
},
|
},
|
||||||
author: {
|
author: {
|
||||||
[`409 ${AUTHOR_CODES.slugTaken}`]: 'slugTaken',
|
[`409 ${AUTHOR_CODES.slugTaken}`]: 'slugTaken',
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
export const SITE_NAME_MAX = 100
|
||||||
|
export const TITLE_TEMPLATE_MAX = 200
|
||||||
|
export const DEFAULT_DESCRIPTION_MAX = 320
|
||||||
|
export const DEFAULT_TITLE_TEMPLATE = '%title% | %site%'
|
||||||
|
|
||||||
|
const TITLE_TOKEN = '%title%'
|
||||||
|
const SITE_TOKEN = '%site%'
|
||||||
|
const SAMPLE_TITLE = 'How we price a bathroom renovation'
|
||||||
|
const SAMPLE_SITE_NAME = 'Renew'
|
||||||
|
|
||||||
|
export function templateProblem(template: string) {
|
||||||
|
return template.includes(TITLE_TOKEN) ? '' : t('The template must contain %title%.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exampleTitle(template: string, siteName: string, name: string) {
|
||||||
|
const site = siteName.trim() || name.trim() || SAMPLE_SITE_NAME
|
||||||
|
return template.split(TITLE_TOKEN).join(SAMPLE_TITLE).split(SITE_TOKEN).join(site)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import EmptyState from '@/components/EmptyState.vue'
|
|||||||
import NewArticleModal from '@/components/NewArticleModal.vue'
|
import NewArticleModal from '@/components/NewArticleModal.vue'
|
||||||
import AppPaginator from '@/components/AppPaginator.vue'
|
import AppPaginator from '@/components/AppPaginator.vue'
|
||||||
import ArticleSiteFilter from '@/components/ArticleSiteFilter.vue'
|
import ArticleSiteFilter from '@/components/ArticleSiteFilter.vue'
|
||||||
|
import NeedsAttentionToggle from '@/components/NeedsAttentionToggle.vue'
|
||||||
import { useArticleRowActions } from '@/composables/useArticleRowActions'
|
import { useArticleRowActions } from '@/composables/useArticleRowActions'
|
||||||
import {
|
import {
|
||||||
useArticles,
|
useArticles,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
import { useSites, type Site } from '@/composables/useSites'
|
import { useSites, type Site } from '@/composables/useSites'
|
||||||
import {
|
import {
|
||||||
ARTICLE_PAGE_SIZES,
|
ARTICLE_PAGE_SIZES,
|
||||||
|
SEO_FILTER,
|
||||||
effectiveSort,
|
effectiveSort,
|
||||||
nextSort,
|
nextSort,
|
||||||
parseArticleQuery,
|
parseArticleQuery,
|
||||||
@@ -30,11 +32,12 @@ import {
|
|||||||
type ArticleListQuery,
|
type ArticleListQuery,
|
||||||
} from '@/lib/articleListQuery'
|
} from '@/lib/articleListQuery'
|
||||||
import { pageCountFor } from '@/lib/pagination'
|
import { pageCountFor } from '@/lib/pagination'
|
||||||
|
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
|
||||||
import { ARTICLES_PAGE_SIZE_KEY, readStoredPageSize, storePageSize } from '@/lib/pageSizeMemory'
|
import { ARTICLES_PAGE_SIZE_KEY, readStoredPageSize, storePageSize } from '@/lib/pageSizeMemory'
|
||||||
|
|
||||||
const SKELETON_DELAY_MS = 150
|
const SKELETON_DELAY_MS = 150
|
||||||
const SEARCH_DEBOUNCE_MS = 120
|
const SEARCH_DEBOUNCE_MS = 120
|
||||||
const DEFAULT_SKELETON_ROWS = 5
|
const DEFAULT_SKELETON_ROWS = 6
|
||||||
const ARTICLES_PATH = '/article'
|
const ARTICLES_PATH = '/article'
|
||||||
|
|
||||||
type UrlMode = 'push' | 'replace'
|
type UrlMode = 'push' | 'replace'
|
||||||
@@ -42,13 +45,14 @@ type Notice = { kind: 'range' } | { kind: 'error'; query: ArticleListQuery; mode
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { listArticles } = useArticles()
|
const { listArticles, countNeedsAttention } = useArticles()
|
||||||
const { listSites } = useSites()
|
const { listSites } = useSites()
|
||||||
|
|
||||||
const items = ref<Article[]>([])
|
const items = ref<Article[]>([])
|
||||||
const sites = ref<Site[]>([])
|
const sites = ref<Site[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
|
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
|
||||||
|
const needsAttentionCount = ref<number | null>(null)
|
||||||
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
|
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
|
||||||
const parsed = parseArticleQuery(query)
|
const parsed = parseArticleQuery(query)
|
||||||
if (query.size !== undefined) {
|
if (query.size !== undefined) {
|
||||||
@@ -71,6 +75,8 @@ const newArticleModalOpen = ref(false)
|
|||||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let requestToken = 0
|
let requestToken = 0
|
||||||
|
let countToken = 0
|
||||||
|
let countedFor: string | null = null
|
||||||
let syncedTarget: Record<string, string> | null = null
|
let syncedTarget: Record<string, string> | null = null
|
||||||
|
|
||||||
const searchInput = ref(shown.value.q)
|
const searchInput = ref(shown.value.q)
|
||||||
@@ -135,6 +141,9 @@ const emptyVariant = computed(() => {
|
|||||||
if (shown.value.status === 'online') {
|
if (shown.value.status === 'online') {
|
||||||
return 'no-online' as const
|
return 'no-online' as const
|
||||||
}
|
}
|
||||||
|
if (shown.value.seo) {
|
||||||
|
return 'needs-attention' as const
|
||||||
|
}
|
||||||
if (shown.value.site) {
|
if (shown.value.site) {
|
||||||
return 'no-site' as const
|
return 'no-site' as const
|
||||||
}
|
}
|
||||||
@@ -174,8 +183,37 @@ async function syncUrl(query: ArticleListQuery, mode: UrlMode) {
|
|||||||
await router[mode]({ query: target })
|
await router[mode]({ query: target })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function countKey(query: ArticleListQuery) {
|
||||||
|
return JSON.stringify([query.q, query.status, query.site])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshNeedsAttentionCount(query: ArticleListQuery) {
|
||||||
|
const key = countKey(query)
|
||||||
|
if (query.seo || key === countedFor) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const token = ++countToken
|
||||||
|
countedFor = key
|
||||||
|
needsAttentionCount.value = null
|
||||||
|
try {
|
||||||
|
const result = await countNeedsAttention({
|
||||||
|
q: query.q,
|
||||||
|
status: query.status,
|
||||||
|
site: query.site,
|
||||||
|
})
|
||||||
|
if (token === countToken) {
|
||||||
|
needsAttentionCount.value = result
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (token === countToken) {
|
||||||
|
countedFor = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notice | null = null) {
|
async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notice | null = null) {
|
||||||
const token = ++requestToken
|
const token = ++requestToken
|
||||||
|
refreshNeedsAttentionCount(query)
|
||||||
desired.value = query
|
desired.value = query
|
||||||
loading.value = true
|
loading.value = true
|
||||||
showSkeleton.value = false
|
showSkeleton.value = false
|
||||||
@@ -209,6 +247,11 @@ async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notic
|
|||||||
items.value = response.items
|
items.value = response.items
|
||||||
total.value = response.total
|
total.value = response.total
|
||||||
counts.value = response.counts
|
counts.value = response.counts
|
||||||
|
if (effective.seo) {
|
||||||
|
countToken++
|
||||||
|
countedFor = countKey(effective)
|
||||||
|
needsAttentionCount.value = response.total
|
||||||
|
}
|
||||||
shown.value = effective
|
shown.value = effective
|
||||||
desired.value = effective
|
desired.value = effective
|
||||||
hasLoaded.value = true
|
hasLoaded.value = true
|
||||||
@@ -258,6 +301,14 @@ function changeSite(site: string) {
|
|||||||
load({ ...desired.value, site, page: 1 }, 'push')
|
load({ ...desired.value, site, page: 1 }, 'push')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function changeSeo(on: boolean) {
|
||||||
|
load({ ...desired.value, seo: on ? SEO_FILTER : '', page: 1 }, 'push')
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSeoFilter() {
|
||||||
|
changeSeo(false)
|
||||||
|
}
|
||||||
|
|
||||||
function changeSort(field: ArticleSortField) {
|
function changeSort(field: ArticleSortField) {
|
||||||
load(
|
load(
|
||||||
{
|
{
|
||||||
@@ -347,6 +398,10 @@ function clearSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onEmptyAction() {
|
function onEmptyAction() {
|
||||||
|
if (emptyVariant.value === 'needs-attention') {
|
||||||
|
clearSeoFilter()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (emptyVariant.value === 'no-site') {
|
if (emptyVariant.value === 'no-site') {
|
||||||
changeSite('')
|
changeSite('')
|
||||||
return
|
return
|
||||||
@@ -383,7 +438,7 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardShell>
|
<DashboardShell compact-padding>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="flex flex-wrap items-end gap-4">
|
<div class="flex flex-wrap items-end gap-4">
|
||||||
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
|
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
|
||||||
@@ -407,23 +462,38 @@ onBeforeUnmount(() => {
|
|||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col-reverse gap-4 sm:flex-row sm:items-end sm:justify-between">
|
<div
|
||||||
|
class="flex flex-col-reverse gap-3 md:flex-row md:items-end md:gap-4 md:border-b md:border-default"
|
||||||
|
>
|
||||||
<UTabs
|
<UTabs
|
||||||
v-model="activeTab"
|
v-model="activeTab"
|
||||||
:items="tabItems"
|
:items="tabItems"
|
||||||
variant="link"
|
variant="link"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
:content="false"
|
:content="false"
|
||||||
:ui="{ list: 'gap-5', trigger: 'px-0' }"
|
class="md:-mb-px md:flex-1"
|
||||||
|
:ui="{
|
||||||
|
list: 'gap-5 border-b-0 p-0 max-md:border-b',
|
||||||
|
trigger: `${UNDERLINE_TAB_TRIGGER} max-md:text-base`,
|
||||||
|
}"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
|
<div class="flex flex-col gap-3 md:flex-row md:items-end md:gap-4 md:pb-1.5">
|
||||||
<UInput
|
<UInput
|
||||||
v-model="searchInput"
|
v-model="searchInput"
|
||||||
type="search"
|
type="search"
|
||||||
icon="i-lucide-search"
|
icon="i-lucide-search"
|
||||||
:placeholder="t('Search by title')"
|
:placeholder="t('Search by title')"
|
||||||
class="w-full sm:w-65"
|
class="w-full md:w-65"
|
||||||
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
|
:ui="{
|
||||||
|
base: 'h-11 text-lg md:h-8 md:text-sm',
|
||||||
|
leadingIcon: 'size-4 md:size-3.5',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<NeedsAttentionToggle
|
||||||
|
:model-value="desired.seo !== ''"
|
||||||
|
:count="needsAttentionCount"
|
||||||
|
class="md:-order-1"
|
||||||
|
@update:model-value="changeSeo"
|
||||||
/>
|
/>
|
||||||
<ArticleSiteFilter
|
<ArticleSiteFilter
|
||||||
v-if="severalSites"
|
v-if="severalSites"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { readSeoSectionOpen, storeSeoSectionOpen } from '@/lib/seoSectionMemory'
|
|||||||
import { SEO_LIMITS, charCount, seoChanges, seoOf, type ArticleSeo } from '@/lib/articleSeo'
|
import { SEO_LIMITS, charCount, seoChanges, seoOf, type ArticleSeo } from '@/lib/articleSeo'
|
||||||
import { hostOf, slugify } from '@/lib/directory'
|
import { hostOf, slugify } from '@/lib/directory'
|
||||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||||
import type { SeoCheckBody, SeoFocus, SuggestionField } from '@/lib/seoTypes'
|
import type { SeoCheckBody, SeoFocus, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
|
||||||
import { bodyCounts } from '@/lib/bodyCounts'
|
import { bodyCounts } from '@/lib/bodyCounts'
|
||||||
import { useCanManageVariants } from '@/composables/useCanManageVariants'
|
import { useCanManageVariants } from '@/composables/useCanManageVariants'
|
||||||
import { bodyHasVariantImage } from '@/lib/variants/blockVariant'
|
import { bodyHasVariantImage } from '@/lib/variants/blockVariant'
|
||||||
@@ -93,6 +93,8 @@ const panelSaving = ref(false)
|
|||||||
const slugTakenOnSave = ref<string | null>(null)
|
const slugTakenOnSave = ref<string | null>(null)
|
||||||
const canonicalRejected = ref(false)
|
const canonicalRejected = ref(false)
|
||||||
const apiPreviewOpen = ref(false)
|
const apiPreviewOpen = ref(false)
|
||||||
|
const dismissedCodes = ref<SuggestionCode[]>([])
|
||||||
|
const dismissingCode = ref<SuggestionCode | null>(null)
|
||||||
let panelSaveFailed = false
|
let panelSaveFailed = false
|
||||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
@@ -153,6 +155,61 @@ const check = useSeoCheck({
|
|||||||
onUnauthorized: draft.mirrorToStorage,
|
onUnauthorized: draft.mirrorToStorage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function reportDismissFailure() {
|
||||||
|
toast.add(() => ({
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-lucide-circle-x',
|
||||||
|
get title() {
|
||||||
|
return t("Couldn't dismiss the suggestion")
|
||||||
|
},
|
||||||
|
get description() {
|
||||||
|
return t('Try again in a moment.')
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDismissed(next: SuggestionCode[]) {
|
||||||
|
try {
|
||||||
|
const updated = await request<Pick<Article, 'seo_dismissed'>>(`/v1/article/${articleId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ seo_dismissed: next }),
|
||||||
|
})
|
||||||
|
dismissedCodes.value = updated?.seo_dismissed ?? next
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 404) {
|
||||||
|
loadState.value = 'notFound'
|
||||||
|
} else if (err instanceof ApiError && err.status === 401) {
|
||||||
|
draft.mirrorToStorage()
|
||||||
|
} else {
|
||||||
|
reportDismissFailure()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissSuggestion(code: SuggestionCode) {
|
||||||
|
if (dismissingCode.value !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dismissingCode.value = code
|
||||||
|
const saved = await saveDismissed([...new Set([...dismissedCodes.value, code])])
|
||||||
|
if (saved) {
|
||||||
|
check.dropSuggestion(code)
|
||||||
|
check.invalidate()
|
||||||
|
check.runQuietly()
|
||||||
|
}
|
||||||
|
dismissingCode.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreDismissed() {
|
||||||
|
if (await saveDismissed([])) {
|
||||||
|
check.invalidate()
|
||||||
|
check.runFresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function editBody(newBody: string) {
|
function editBody(newBody: string) {
|
||||||
draft.edit(newBody)
|
draft.edit(newBody)
|
||||||
check.schedule()
|
check.schedule()
|
||||||
@@ -309,6 +366,7 @@ async function loadArticle() {
|
|||||||
fetchSites(),
|
fetchSites(),
|
||||||
])
|
])
|
||||||
article.value = result
|
article.value = result
|
||||||
|
dismissedCodes.value = result.seo_dismissed ?? []
|
||||||
sites.value = siteList
|
sites.value = siteList
|
||||||
draft.load(
|
draft.load(
|
||||||
result.body,
|
result.body,
|
||||||
@@ -559,12 +617,14 @@ onBeforeRouteLeave(async () => {
|
|||||||
:phase="check.phase.value"
|
:phase="check.phase.value"
|
||||||
:status="check.status.value"
|
:status="check.status.value"
|
||||||
:suggestions="check.suggestions.value"
|
:suggestions="check.suggestions.value"
|
||||||
|
:dismissed-count="dismissedCodes.length"
|
||||||
:sheet="sheet"
|
:sheet="sheet"
|
||||||
@update:remembered="rememberSeoSection"
|
@update:remembered="rememberSeoSection"
|
||||||
@edit="openPanel"
|
@edit="openPanel"
|
||||||
@preview="apiPreviewOpen = true"
|
@preview="apiPreviewOpen = true"
|
||||||
@retry="check.runFresh"
|
@retry="check.runFresh"
|
||||||
@go="goToField"
|
@go="goToField"
|
||||||
|
@restore="restoreDismissed"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ArticleDetails>
|
</ArticleDetails>
|
||||||
@@ -756,6 +816,8 @@ onBeforeRouteLeave(async () => {
|
|||||||
:site="siteOfArticle"
|
:site="siteOfArticle"
|
||||||
:phase="check.phase.value"
|
:phase="check.phase.value"
|
||||||
:suggestions="check.suggestions.value"
|
:suggestions="check.suggestions.value"
|
||||||
|
:dismissed-count="dismissedCodes.length"
|
||||||
|
:dismissing-code="dismissingCode"
|
||||||
:slug-verdict="check.slugVerdict.value"
|
:slug-verdict="check.slugVerdict.value"
|
||||||
:checked-slug="check.checkedSlug.value"
|
:checked-slug="check.checkedSlug.value"
|
||||||
:slug-taken-on-save="slugTakenOnSave"
|
:slug-taken-on-save="slugTakenOnSave"
|
||||||
@@ -769,6 +831,8 @@ onBeforeRouteLeave(async () => {
|
|||||||
@close="closePanel"
|
@close="closePanel"
|
||||||
@preview="apiPreviewOpen = true"
|
@preview="apiPreviewOpen = true"
|
||||||
@go="goToField"
|
@go="goToField"
|
||||||
|
@dismiss="dismissSuggestion"
|
||||||
|
@restore="restoreDismissed"
|
||||||
/>
|
/>
|
||||||
<ApiPreviewDialog
|
<ApiPreviewDialog
|
||||||
v-model:open="apiPreviewOpen"
|
v-model:open="apiPreviewOpen"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t, translate } from '@/i18n'
|
import { t, translate } from '@/i18n'
|
||||||
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import CodeSnippet from '@/components/CodeSnippet.vue'
|
import CodeSnippet from '@/components/CodeSnippet.vue'
|
||||||
import CodeText from '@/components/CodeText.vue'
|
import CodeText from '@/components/CodeText.vue'
|
||||||
import DashboardShell from '@/components/DashboardShell.vue'
|
import DashboardShell from '@/components/DashboardShell.vue'
|
||||||
@@ -21,6 +22,7 @@ import { SECTION_TITLE } from '@/lib/apiReference'
|
|||||||
|
|
||||||
const SPY_OFFSET_PX = 120
|
const SPY_OFFSET_PX = 120
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const origin = computed(() => window.location.origin)
|
const origin = computed(() => window.location.origin)
|
||||||
|
|
||||||
const root = ref<HTMLElement | null>(null)
|
const root = ref<HTMLElement | null>(null)
|
||||||
@@ -151,6 +153,10 @@ let scroller: HTMLElement | null = null
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
scroller = root.value ? scrollParent(root.value) : null
|
scroller = root.value ? scrollParent(root.value) : null
|
||||||
scroller?.addEventListener('scroll', updateActive, { passive: true })
|
scroller?.addEventListener('scroll', updateActive, { passive: true })
|
||||||
|
const target = route.hash.slice(1)
|
||||||
|
if (sections.value.some((section) => section.id === target)) {
|
||||||
|
nextTick(() => goTo(target))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => scroller?.removeEventListener('scroll', updateActive))
|
onBeforeUnmount(() => scroller?.removeEventListener('scroll', updateActive))
|
||||||
@@ -354,6 +360,12 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
|
|||||||
<span class="text-pretty text-toned">{{
|
<span class="text-pretty text-toned">{{
|
||||||
quoteMessages(row.original.messages)
|
quoteMessages(row.original.messages)
|
||||||
}}</span>
|
}}</span>
|
||||||
|
<span
|
||||||
|
v-if="row.original.note"
|
||||||
|
class="text-pretty text-muted"
|
||||||
|
data-testid="error-note"
|
||||||
|
>{{ translate(row.original.note) }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -362,6 +374,12 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
|
|||||||
<span class="text-pretty">{{
|
<span class="text-pretty">{{
|
||||||
quoteMessages(row.original.messages)
|
quoteMessages(row.original.messages)
|
||||||
}}</span>
|
}}</span>
|
||||||
|
<span
|
||||||
|
v-if="row.original.note"
|
||||||
|
class="text-pretty text-muted"
|
||||||
|
data-testid="error-note"
|
||||||
|
>{{ translate(row.original.note) }}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</UTable>
|
</UTable>
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { useSessionStore } from '@/stores/session'
|
|||||||
import type { Article } from '@/composables/useArticles'
|
import type { Article } from '@/composables/useArticles'
|
||||||
import ArticleView from '../ArticleView.vue'
|
import ArticleView from '../ArticleView.vue'
|
||||||
import { oneSite } from './editorMetaKit'
|
import { oneSite } from './editorMetaKit'
|
||||||
import { articleEnvelope, articleListRoute } from './articleEnvelope'
|
import {
|
||||||
|
NEEDS_ATTENTION_ROUTE,
|
||||||
|
articleEnvelope,
|
||||||
|
articleListRoute,
|
||||||
|
needsAttentionCountPage,
|
||||||
|
} from './articleEnvelope'
|
||||||
|
|
||||||
const NOW = new Date('2026-08-26T10:00:00Z')
|
const NOW = new Date('2026-08-26T10:00:00Z')
|
||||||
const HOUR_MS = 60 * 60 * 1000
|
const HOUR_MS = 60 * 60 * 1000
|
||||||
@@ -139,7 +144,10 @@ async function mountArticles(handlers: Record<string, () => Response | Promise<R
|
|||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
session.session = { verified: true, username: 'marta' }
|
session.session = { verified: true, username: 'marta' }
|
||||||
session.status = 'ready'
|
session.status = 'ready'
|
||||||
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
|
global.fetch = fetchRouter({
|
||||||
|
[NEEDS_ATTENTION_ROUTE]: () => mockResponse(200, needsAttentionCountPage()),
|
||||||
|
...handlers,
|
||||||
|
}) as unknown as typeof fetch
|
||||||
await router.push('/article')
|
await router.push('/article')
|
||||||
const wrapper = mount(ArticleView, {
|
const wrapper = mount(ArticleView, {
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import type { Article } from '@/composables/useArticles'
|
|||||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||||
import ArticleView from '../ArticleView.vue'
|
import ArticleView from '../ArticleView.vue'
|
||||||
import { oneSite } from './editorMetaKit'
|
import { oneSite } from './editorMetaKit'
|
||||||
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
import {
|
||||||
|
articleEnvelope,
|
||||||
|
isNeedsAttentionCount,
|
||||||
|
needsAttentionCountPage,
|
||||||
|
parseListUrl,
|
||||||
|
type EnvelopeParams,
|
||||||
|
} from './articleEnvelope'
|
||||||
|
|
||||||
const KEY = 'renewcms-page-size-articles'
|
const KEY = 'renewcms-page-size-articles'
|
||||||
const TOTAL = 312
|
const TOTAL = 312
|
||||||
@@ -45,6 +51,9 @@ function installFetch() {
|
|||||||
json: async () => oneSite,
|
json: async () => oneSite,
|
||||||
} as unknown as Response)
|
} as unknown as Response)
|
||||||
}
|
}
|
||||||
|
if (method === 'GET' && isNeedsAttentionCount(url)) {
|
||||||
|
return Promise.resolve(mockResponse(200, needsAttentionCountPage()))
|
||||||
|
}
|
||||||
if (method === 'GET' && url.startsWith('/v1/article/?')) {
|
if (method === 'GET' && url.startsWith('/v1/article/?')) {
|
||||||
const params = parseListUrl(url)
|
const params = parseListUrl(url)
|
||||||
listCalls.push(params)
|
listCalls.push(params)
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import type { Article, ArticlePage } from '@/composables/useArticles'
|
|||||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||||
import ArticleView from '../ArticleView.vue'
|
import ArticleView from '../ArticleView.vue'
|
||||||
import { oneSite } from './editorMetaKit'
|
import { oneSite } from './editorMetaKit'
|
||||||
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
import {
|
||||||
|
articleEnvelope,
|
||||||
|
isNeedsAttentionCount,
|
||||||
|
needsAttentionCountPage,
|
||||||
|
parseListUrl,
|
||||||
|
type EnvelopeParams,
|
||||||
|
} from './articleEnvelope'
|
||||||
|
|
||||||
const TOTAL = 312
|
const TOTAL = 312
|
||||||
const PLASTER_EVERY = 20
|
const PLASTER_EVERY = 20
|
||||||
@@ -85,6 +91,9 @@ function installFetch() {
|
|||||||
if (method === 'GET' && url === '/v1/site/') {
|
if (method === 'GET' && url === '/v1/site/') {
|
||||||
return Promise.resolve(mockResponse(200, oneSite))
|
return Promise.resolve(mockResponse(200, oneSite))
|
||||||
}
|
}
|
||||||
|
if (method === 'GET' && isNeedsAttentionCount(url)) {
|
||||||
|
return Promise.resolve(mockResponse(200, needsAttentionCountPage()))
|
||||||
|
}
|
||||||
if (method === 'GET' && url.startsWith('/v1/article/?')) {
|
if (method === 'GET' && url.startsWith('/v1/article/?')) {
|
||||||
const params = parseListUrl(url)
|
const params = parseListUrl(url)
|
||||||
listCalls.push(params)
|
listCalls.push(params)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function siteSelect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function listCalls(backend: MetaBackend) {
|
function listCalls(backend: MetaBackend) {
|
||||||
return callsTo(backend, 'GET', '/v1/article/')
|
return callsTo(backend, 'GET', '/v1/article/').filter((call) => call.query.has('format'))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chooseSite(label: string) {
|
async function chooseSite(label: string) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const STUB_ORIGIN = 'https://cms.renew.example'
|
|||||||
const SECTION_LABELS = [
|
const SECTION_LABELS = [
|
||||||
'Authentication',
|
'Authentication',
|
||||||
'Articles',
|
'Articles',
|
||||||
|
'Search and sharing',
|
||||||
'Media',
|
'Media',
|
||||||
'Users and invites',
|
'Users and invites',
|
||||||
'Settings',
|
'Settings',
|
||||||
@@ -170,15 +171,17 @@ describe('Default, every card collapsed', () => {
|
|||||||
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
|
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('groups the cards as 9, 10, 6 and 1', async () => {
|
it('groups the cards as 8, 2, 10, 6 and 1', async () => {
|
||||||
const wrapper = await mountIntegration()
|
const wrapper = await mountIntegration()
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
['articles', 'media', 'users', 'settings'].map(
|
['articles', 'seo', 'media', 'users', 'settings'].map(
|
||||||
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
|
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
|
||||||
),
|
),
|
||||||
).toEqual([9, 10, 6, 1])
|
).toEqual([8, 2, 10, 6, 1])
|
||||||
expect(section(wrapper, 'articles').text()).toContain('9 routes')
|
expect(section(wrapper, 'articles').text()).toContain('8 routes')
|
||||||
|
expect(section(wrapper, 'seo').text()).toContain('2 routes')
|
||||||
|
expect(section(wrapper, 'sites').text()).toContain('1 route')
|
||||||
expect(section(wrapper, 'settings').text()).toContain('1 route')
|
expect(section(wrapper, 'settings').text()).toContain('1 route')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import router from '@/router'
|
||||||
|
import { setLocale } from '@/i18n'
|
||||||
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import type { Article } from '@/composables/useArticles'
|
||||||
|
import {
|
||||||
|
articleOf,
|
||||||
|
callsTo,
|
||||||
|
createMetaBackend,
|
||||||
|
failWith,
|
||||||
|
mountMeta,
|
||||||
|
networkFailure,
|
||||||
|
renewSites,
|
||||||
|
textOf,
|
||||||
|
typeInto,
|
||||||
|
unmountMeta,
|
||||||
|
type MetaBackend,
|
||||||
|
} from './metaTestKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
const SEARCH_WAIT_MS = 320
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
setLocale('en')
|
||||||
|
await unmountMeta()
|
||||||
|
})
|
||||||
|
|
||||||
|
function withSeo(
|
||||||
|
id: number,
|
||||||
|
status: string | undefined,
|
||||||
|
count: number | undefined,
|
||||||
|
overrides: Partial<Article> = {},
|
||||||
|
) {
|
||||||
|
return articleOf(id, `Article ${id}`, {
|
||||||
|
is_online: id % 2 === 0,
|
||||||
|
...(status === undefined ? {} : { seo_status: status }),
|
||||||
|
...(count === undefined ? {} : { seo_suggestions: count }),
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function boardArticles() {
|
||||||
|
return [
|
||||||
|
withSeo(1, 'ready', 0),
|
||||||
|
withSeo(2, 'suggestions', 2),
|
||||||
|
withSeo(3, 'hidden', 0),
|
||||||
|
withSeo(4, 'suggestions', 3),
|
||||||
|
withSeo(5, 'suggestions', 1),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function backendWith(articles: Article[], sites: 'one' | 'several' = 'one'): MetaBackend {
|
||||||
|
const backend = createMetaBackend({
|
||||||
|
sites: sites === 'one' ? renewSites().slice(0, 1) : renewSites(),
|
||||||
|
})
|
||||||
|
backend.articles = articles
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
return document.body.querySelector<HTMLElement>('[data-testid="needs-attention-toggle"]')!
|
||||||
|
}
|
||||||
|
|
||||||
|
function chips(root: ParentNode = document.body) {
|
||||||
|
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="seo-status-chip"]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function bodyRows() {
|
||||||
|
return Array.from(document.body.querySelectorAll<HTMLElement>('tbody tr'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function mainCalls(backend: MetaBackend) {
|
||||||
|
return callsTo(backend, 'GET', '/v1/article/').filter((call) => call.query.has('format'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function countCalls(backend: MetaBackend) {
|
||||||
|
return callsTo(backend, 'GET', '/v1/article/').filter((call) => !call.query.has('format'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentQuery() {
|
||||||
|
return { ...router.currentRoute.value.query }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickToggle() {
|
||||||
|
toggle().click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(value: string) {
|
||||||
|
await typeInto(document.body.querySelector<HTMLInputElement>('input[type="search"]')!, value)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS))
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickTab(label: string) {
|
||||||
|
const tab = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).find(
|
||||||
|
(item) => textOf(item).startsWith(label),
|
||||||
|
)!
|
||||||
|
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||||
|
tab.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SeoStatusChip: status table', () => {
|
||||||
|
it.each([
|
||||||
|
{ status: 'ready', count: 0, text: '', label: '' },
|
||||||
|
{ status: 'suggestions', count: 2, text: '2', label: '2 suggestions' },
|
||||||
|
{ status: 'suggestions', count: 1, text: '1', label: '1 suggestion' },
|
||||||
|
{ status: 'suggestions', count: 3, text: '3', label: '3 suggestions' },
|
||||||
|
{ status: 'hidden', count: 0, text: 'Hidden', label: 'Hidden from search engines' },
|
||||||
|
{ status: 'hidden', count: 2, text: 'Hidden', label: 'Hidden from search engines' },
|
||||||
|
])('$status with $count shows "$text"', async ({ status, count, text, label }) => {
|
||||||
|
const backend = backendWith([withSeo(1, status, count)])
|
||||||
|
await mountMeta('articles', '/article', { backend })
|
||||||
|
|
||||||
|
const found = chips(bodyRows()[0])
|
||||||
|
if (!label) {
|
||||||
|
expect(found).toHaveLength(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expect(found).toHaveLength(1)
|
||||||
|
const chip = found[0]!
|
||||||
|
expect(textOf(chip)).toBe(text)
|
||||||
|
expect(chip.getAttribute('title')).toBe(label)
|
||||||
|
expect(chip.getAttribute('aria-label')).toBe(label)
|
||||||
|
expect(chip.getAttribute('role')).toBe('img')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ item: 'without seo_status', article: withSeo(1, undefined, undefined) },
|
||||||
|
{ item: 'with suggestions and a count of 0', article: withSeo(1, 'suggestions', 0) },
|
||||||
|
{ item: 'with an unknown status', article: withSeo(1, 'surprise', 4) },
|
||||||
|
])('shows nothing for an item $item', async ({ article }) => {
|
||||||
|
await mountMeta('articles', '/article', { backend: backendWith([article]) })
|
||||||
|
|
||||||
|
expect(chips()).toHaveLength(0)
|
||||||
|
expect(bodyRows()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sits in the Status cell next to the badge on desktop and in the meta row on mobile', async () => {
|
||||||
|
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||||
|
const statusCell = bodyRows()[1]!.children[2] as HTMLElement
|
||||||
|
expect(chips(statusCell)).toHaveLength(1)
|
||||||
|
expect(textOf(statusCell)).toContain('Online')
|
||||||
|
expect(chips(document.body.querySelector('tbody')!)).toHaveLength(4)
|
||||||
|
await unmountMeta()
|
||||||
|
|
||||||
|
await mountMeta('articles', '/article', {
|
||||||
|
width: MOBILE_WIDTH,
|
||||||
|
backend: backendWith(boardArticles()),
|
||||||
|
})
|
||||||
|
const cards = Array.from(document.body.querySelectorAll<HTMLElement>('li'))
|
||||||
|
const second = cards.find((card) => textOf(card).includes('Article 2'))!
|
||||||
|
expect(chips(second)).toHaveLength(1)
|
||||||
|
expect(chips(second)[0]!.className).toContain('h-5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the chip in French', async () => {
|
||||||
|
setLocale('fr')
|
||||||
|
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||||
|
|
||||||
|
const labels = chips().map((chip) => chip.getAttribute('aria-label'))
|
||||||
|
expect(labels).toContain('2 suggestions')
|
||||||
|
expect(labels).toContain('1 suggestion')
|
||||||
|
expect(labels).toContain('Masqué des moteurs de recherche')
|
||||||
|
expect(textOf(toggle())).toContain('À traiter')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the chip when a row is published', async () => {
|
||||||
|
const backend = backendWith(boardArticles())
|
||||||
|
await mountMeta('articles', '/article', { backend })
|
||||||
|
|
||||||
|
document.body
|
||||||
|
.querySelector<HTMLElement>('button[aria-label="Actions for Article 3"]')!
|
||||||
|
.click()
|
||||||
|
await flushPromises()
|
||||||
|
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||||
|
.find((item) => textOf(item) === 'Publish')!
|
||||||
|
.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(chips(bodyRows()[2])).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Needs attention toggle', () => {
|
||||||
|
it('is a switch with the board count, left of the search on desktop', async () => {
|
||||||
|
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||||
|
|
||||||
|
expect(toggle().getAttribute('role')).toBe('switch')
|
||||||
|
expect(toggle().getAttribute('aria-checked')).toBe('false')
|
||||||
|
expect(textOf(toggle())).toBe('Needs attention 3')
|
||||||
|
expect(toggle().className).toContain('md:-order-1')
|
||||||
|
expect(toggle().className).toContain('md:h-8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is 44 px high on mobile', async () => {
|
||||||
|
await mountMeta('articles', '/article', {
|
||||||
|
width: MOBILE_WIDTH,
|
||||||
|
backend: backendWith(boardArticles()),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(toggle().className).toContain('h-11')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the sand fill when on', async () => {
|
||||||
|
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||||
|
await clickToggle()
|
||||||
|
|
||||||
|
expect(toggle().getAttribute('aria-checked')).toBe('true')
|
||||||
|
expect(toggle().className).toContain('bg-(--rc-sand)')
|
||||||
|
expect(toggle().className).toContain('text-(--rc-on-sand)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns on keeping q, status, sort, site and size, and resets the page', async () => {
|
||||||
|
const backend = backendWith(boardArticles(), 'several')
|
||||||
|
await mountMeta(
|
||||||
|
'articles',
|
||||||
|
'/article?page=1&size=50&q=Article&status=online&sort=title:asc&site=renew',
|
||||||
|
{ backend },
|
||||||
|
)
|
||||||
|
await clickToggle()
|
||||||
|
|
||||||
|
expect(currentQuery()).toEqual({
|
||||||
|
size: '50',
|
||||||
|
q: 'Article',
|
||||||
|
status: 'online',
|
||||||
|
sort: 'title:asc',
|
||||||
|
site: 'renew',
|
||||||
|
seo: 'suggestions',
|
||||||
|
})
|
||||||
|
const last = mainCalls(backend).at(-1)!
|
||||||
|
expect(last.query.get('seo')).toBe('suggestions')
|
||||||
|
expect(last.query.get('page')).toBe('1')
|
||||||
|
expect(last.query.get('size')).toBe('50')
|
||||||
|
expect(last.query.get('q')).toBe('Article')
|
||||||
|
expect(last.query.get('sort')).toBe('title:asc')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns off, drops seo, resets the page and requests the list without seo', async () => {
|
||||||
|
const backend = backendWith(boardArticles())
|
||||||
|
await mountMeta('articles', '/article?seo=suggestions&page=2&size=5', { backend })
|
||||||
|
await clickToggle()
|
||||||
|
|
||||||
|
expect(currentQuery()).toEqual({ size: '5' })
|
||||||
|
expect(mainCalls(backend).at(-1)!.query.has('seo')).toBe(false)
|
||||||
|
expect(mainCalls(backend).at(-1)!.query.get('page')).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens a URL with the filter already on', async () => {
|
||||||
|
const backend = backendWith(boardArticles())
|
||||||
|
await mountMeta('articles', '/article?seo=suggestions&page=1', { backend })
|
||||||
|
|
||||||
|
expect(toggle().getAttribute('aria-checked')).toBe('true')
|
||||||
|
expect(mainCalls(backend)[0]!.query.get('seo')).toBe('suggestions')
|
||||||
|
expect(bodyRows()).toHaveLength(3)
|
||||||
|
expect(textOf(document.body.querySelector('[role="tablist"]'))).toContain('All 5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'?seo=maybe',
|
||||||
|
'?seo=',
|
||||||
|
'?seo=READY',
|
||||||
|
'?seo=ready',
|
||||||
|
'?seo=hidden',
|
||||||
|
'?seo=suggestions,x',
|
||||||
|
])('drops %s silently', async (query) => {
|
||||||
|
const backend = backendWith(boardArticles())
|
||||||
|
await mountMeta('articles', `/article${query}`, { backend })
|
||||||
|
|
||||||
|
expect(currentQuery()).toEqual({})
|
||||||
|
expect(toggle().getAttribute('aria-checked')).toBe('false')
|
||||||
|
expect(mainCalls(backend).every((call) => !call.query.has('seo'))).toBe(true)
|
||||||
|
expect(document.body.querySelector('[role="alert"], [role="status"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ action: 'searches', act: () => search('Article') },
|
||||||
|
{ action: 'changes the tab', act: () => clickTab('Drafts') },
|
||||||
|
])('keeps ?seo=suggestions and resets the page when the user $action', async ({ act }) => {
|
||||||
|
const backend = backendWith(boardArticles())
|
||||||
|
await mountMeta('articles', '/article?seo=suggestions&page=2&size=5', { backend })
|
||||||
|
|
||||||
|
await act()
|
||||||
|
|
||||||
|
expect(currentQuery().seo).toBe('suggestions')
|
||||||
|
expect(currentQuery().page).toBeUndefined()
|
||||||
|
expect(mainCalls(backend).at(-1)!.query.get('seo')).toBe('suggestions')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('behaves the same on a single-site install', async () => {
|
||||||
|
const backend = backendWith(boardArticles(), 'one')
|
||||||
|
await mountMeta('articles', '/article', { backend })
|
||||||
|
|
||||||
|
expect(document.body.querySelector('button[aria-label="Site"]')).toBeNull()
|
||||||
|
await clickToggle()
|
||||||
|
expect(currentQuery()).toEqual({ seo: 'suggestions' })
|
||||||
|
expect(bodyRows()).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Needs attention count', () => {
|
||||||
|
it('reads the total of the extra request with the current q, status and site', async () => {
|
||||||
|
const backend = backendWith(boardArticles(), 'several')
|
||||||
|
await mountMeta('articles', '/article?q=Article&status=online&site=renew', { backend })
|
||||||
|
|
||||||
|
const calls = countCalls(backend)
|
||||||
|
expect(calls).toHaveLength(1)
|
||||||
|
expect(calls[0]!.raw).toBe(
|
||||||
|
'/v1/article/?seo=suggestions&page=1&size=5&q=Article&status=online&site=renew',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ total: 3, label: 'Needs attention 3' },
|
||||||
|
{ total: 0, label: 'Needs attention 0' },
|
||||||
|
])('shows $label', async ({ total, label }) => {
|
||||||
|
const backend = backendWith(boardArticles())
|
||||||
|
backend.responders['GET /v1/article/'] = (call) =>
|
||||||
|
call.query.has('format')
|
||||||
|
? {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
size: 25,
|
||||||
|
counts: { all: 0, online: 0, draft: 0 },
|
||||||
|
}
|
||||||
|
: { items: [], total, page: 1, size: 5, counts: { all: 0, online: 0, draft: 0 } }
|
||||||
|
await mountMeta('articles', '/article', { backend })
|
||||||
|
|
||||||
|
expect(textOf(toggle())).toBe(label)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ failure: 'status 500', responder: failWith(500) },
|
||||||
|
{ failure: 'a network error', responder: networkFailure },
|
||||||
|
])('shows no count and no notice on $failure', async ({ responder }) => {
|
||||||
|
const backend = backendWith(boardArticles())
|
||||||
|
const defaults = backend.responders
|
||||||
|
backend.responders['GET /v1/article/'] = (call) => {
|
||||||
|
if (!call.query.has('format')) {
|
||||||
|
return responder(call)
|
||||||
|
}
|
||||||
|
return defaults
|
||||||
|
? {
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
size: 25,
|
||||||
|
counts: { all: 5, online: 2, draft: 3 },
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
await mountMeta('articles', '/article', { backend })
|
||||||
|
|
||||||
|
expect(textOf(toggle())).toBe('Needs attention')
|
||||||
|
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||||
|
expect(currentQuery()).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes when q, tab or site change and never for page, size or sort', async () => {
|
||||||
|
const backend = backendWith(
|
||||||
|
Array.from({ length: 30 }, (_, index) => withSeo(index + 1, 'suggestions', 1)),
|
||||||
|
'several',
|
||||||
|
)
|
||||||
|
await mountMeta('articles', '/article?size=5', { backend })
|
||||||
|
expect(countCalls(backend)).toHaveLength(1)
|
||||||
|
|
||||||
|
document.body.querySelector<HTMLElement>('button[aria-label="Next page"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
Array.from(document.body.querySelectorAll<HTMLElement>('thead button'))
|
||||||
|
.find((item) => textOf(item).startsWith('Title'))!
|
||||||
|
.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(countCalls(backend)).toHaveLength(1)
|
||||||
|
|
||||||
|
await clickTab('Drafts')
|
||||||
|
expect(countCalls(backend)).toHaveLength(2)
|
||||||
|
await search('Article 1')
|
||||||
|
expect(countCalls(backend)).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the main response total while the filter is on, with no extra request', async () => {
|
||||||
|
const backend = backendWith(boardArticles())
|
||||||
|
await mountMeta('articles', '/article?seo=suggestions', { backend })
|
||||||
|
|
||||||
|
expect(countCalls(backend)).toHaveLength(0)
|
||||||
|
expect(textOf(toggle())).toBe('Needs attention 3')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Needs attention: empty states', () => {
|
||||||
|
it('shows Nothing needs attention and returns to the list with Show all articles', async () => {
|
||||||
|
const backend = backendWith([withSeo(1, 'ready', 0), withSeo(2, 'ready', 0)])
|
||||||
|
await mountMeta('articles', '/article?seo=suggestions&q=', { backend })
|
||||||
|
|
||||||
|
const text = textOf(document.body)
|
||||||
|
expect(text).toContain('Nothing needs attention')
|
||||||
|
expect(text).toContain(
|
||||||
|
'Every article has a clean check. Suggestions are optional, so this is just a bonus.',
|
||||||
|
)
|
||||||
|
expect(document.body.querySelector('[data-testid="paginator-summary"]')).toBeNull()
|
||||||
|
|
||||||
|
Array.from(document.body.querySelectorAll<HTMLElement>('button'))
|
||||||
|
.find((item) => textOf(item) === 'Show all articles')!
|
||||||
|
.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(currentQuery()).toEqual({})
|
||||||
|
expect(mainCalls(backend).at(-1)!.query.has('seo')).toBe(false)
|
||||||
|
expect(bodyRows()).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ query: 'q=tiles', title: 'No articles match "tiles"' },
|
||||||
|
{ query: 'status=draft', title: 'No drafts' },
|
||||||
|
{ query: 'status=online', title: 'Nothing is online yet' },
|
||||||
|
])('keeps the existing state for $query with the filter on', async ({ query, title }) => {
|
||||||
|
const backend = backendWith([withSeo(1, 'ready', 0)])
|
||||||
|
await mountMeta('articles', `/article?seo=suggestions&${query}`, { backend })
|
||||||
|
|
||||||
|
expect(textOf(document.body)).toContain(title)
|
||||||
|
expect(currentQuery().seo).toBe('suggestions')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers Nothing needs attention to the site state', async () => {
|
||||||
|
const backend = backendWith([withSeo(1, 'ready', 0)], 'several')
|
||||||
|
await mountMeta('articles', '/article?seo=suggestions&site=archive', { backend })
|
||||||
|
|
||||||
|
expect(textOf(document.body)).toContain('Nothing needs attention')
|
||||||
|
expect(textOf(document.body)).not.toContain('No articles on "Archive"')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import type { SuggestionCode } from '@/lib/seoTypes'
|
||||||
|
import { failWith, networkFailure, pending, textOf, unmountMeta } from './metaTestKit'
|
||||||
|
import {
|
||||||
|
checkAnswer,
|
||||||
|
checkCalls,
|
||||||
|
mountSeo,
|
||||||
|
openPanel,
|
||||||
|
openSection,
|
||||||
|
patchBodies,
|
||||||
|
sectionBody,
|
||||||
|
sectionSummary,
|
||||||
|
seoBackend,
|
||||||
|
suggestion,
|
||||||
|
} from './seoTestKit'
|
||||||
|
|
||||||
|
const emptyRectList = () => Object.assign([], { item: () => null }) as unknown as DOMRectList
|
||||||
|
|
||||||
|
Range.prototype.getClientRects = emptyRectList
|
||||||
|
Range.prototype.getBoundingClientRect = () => new DOMRect()
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(unmountMeta)
|
||||||
|
|
||||||
|
const TITLE_MESSAGE =
|
||||||
|
'The meta title is 74 characters. Search engines may cut it off after about 60.'
|
||||||
|
|
||||||
|
const ALL = [
|
||||||
|
suggestion('title_long', 'seo_title', { length: 74, max: 60 }),
|
||||||
|
suggestion('cover_alt', 'cover_image_alt'),
|
||||||
|
suggestion('no_h', 'body'),
|
||||||
|
]
|
||||||
|
|
||||||
|
function backendWithDismissals(saved: SuggestionCode[] = [], all = ALL) {
|
||||||
|
const backend = seoBackend({ article: { seo_dismissed: saved } })
|
||||||
|
const state = { saved }
|
||||||
|
backend.responders['POST /v1/article/12/seo/check'] = () =>
|
||||||
|
checkAnswer(all.filter((item) => !state.saved.includes(item.code)))
|
||||||
|
backend.responders['PATCH /v1/article/12'] = (call) => {
|
||||||
|
state.saved = [...new Set(call.body!.seo_dismissed as SuggestionCode[])]
|
||||||
|
return { ...backend.article, seo_dismissed: state.saved }
|
||||||
|
}
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissButtons(root: ParentNode = document.body) {
|
||||||
|
return Array.from(
|
||||||
|
root.querySelectorAll<HTMLButtonElement>('[data-testid="suggestion-dismiss"]'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelRows(root: ParentNode) {
|
||||||
|
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="suggestion"]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismiss(index: number) {
|
||||||
|
dismissButtons()[index]!.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toastText() {
|
||||||
|
return textOf(document.body.querySelector('[data-slot="viewport"]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Dismiss x: rendering', () => {
|
||||||
|
it('draws one button per panel row and a footnote, and none in the compact list', async () => {
|
||||||
|
const backend = backendWithDismissals()
|
||||||
|
await mountSeo(backend)
|
||||||
|
const compact = await openSection()
|
||||||
|
expect(dismissButtons(compact)).toHaveLength(0)
|
||||||
|
expect(textOf(compact)).not.toContain('Dismiss one and it stays hidden')
|
||||||
|
|
||||||
|
const panel = await openPanel()
|
||||||
|
expect(dismissButtons(panel)).toHaveLength(3)
|
||||||
|
expect(textOf(panel)).toContain(
|
||||||
|
'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('names the button with the message and orders it after the Go to link', async () => {
|
||||||
|
await mountSeo(backendWithDismissals())
|
||||||
|
const panel = await openPanel()
|
||||||
|
|
||||||
|
const first = dismissButtons(panel)[0]!
|
||||||
|
expect(first.getAttribute('aria-label')).toBe(`Dismiss suggestion: ${TITLE_MESSAGE}`)
|
||||||
|
expect(first.className).toContain('focus-visible:outline-2')
|
||||||
|
const row = panelRows(panel)[0]!
|
||||||
|
const link = Array.from(row.querySelectorAll('button')).find((button) =>
|
||||||
|
textOf(button).startsWith('Go to'),
|
||||||
|
)!
|
||||||
|
expect(link.compareDocumentPosition(first) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
|
||||||
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is 44 px on mobile and 24 px on desktop', async () => {
|
||||||
|
await mountSeo(backendWithDismissals(), { width: MOBILE_WIDTH })
|
||||||
|
const panel = await openPanel()
|
||||||
|
|
||||||
|
expect(dismissButtons(panel)[0]!.className).toContain('size-11')
|
||||||
|
expect(dismissButtons(panel)[0]!.className).toContain('md:size-6')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Dismiss x: behaviour', () => {
|
||||||
|
it('sends one PATCH with the saved list plus the code and nothing else', async () => {
|
||||||
|
const backend = backendWithDismissals(['no_au'])
|
||||||
|
await mountSeo(backend)
|
||||||
|
await openPanel()
|
||||||
|
const before = checkCalls(backend).length
|
||||||
|
await dismiss(0)
|
||||||
|
|
||||||
|
expect(patchBodies(backend)).toEqual([{ seo_dismissed: ['no_au', 'title_long'] }])
|
||||||
|
expect(checkCalls(backend)).toHaveLength(before + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the row and shows a busy button until the PATCH answers', async () => {
|
||||||
|
const backend = backendWithDismissals()
|
||||||
|
const hold = pending<unknown>()
|
||||||
|
const answer = backend.responders['PATCH /v1/article/12']!
|
||||||
|
backend.responders['PATCH /v1/article/12'] = async (call) => {
|
||||||
|
await hold.promise
|
||||||
|
return answer(call)
|
||||||
|
}
|
||||||
|
await mountSeo(backend)
|
||||||
|
const panel = await openPanel()
|
||||||
|
await dismiss(0)
|
||||||
|
|
||||||
|
expect(panelRows(panel)).toHaveLength(3)
|
||||||
|
expect(dismissButtons(panel)[0]!.disabled).toBe(true)
|
||||||
|
expect(dismissButtons(panel)[0]!.getAttribute('aria-busy')).toBe('true')
|
||||||
|
hold.resolve(undefined)
|
||||||
|
await flushPromises()
|
||||||
|
expect(panelRows(panel)).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes the row, keeps the order and updates the summary', async () => {
|
||||||
|
const backend = backendWithDismissals()
|
||||||
|
await mountSeo(backend)
|
||||||
|
const panel = await openPanel()
|
||||||
|
await dismiss(0)
|
||||||
|
|
||||||
|
const rows = panelRows(panel).map(textOf)
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
|
expect(rows[0]).toContain('The cover image has no alt text')
|
||||||
|
expect(rows[1]).toContain('The body has no headings')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows All good with the dismissed line when the last one goes, and focuses the link', async () => {
|
||||||
|
const backend = backendWithDismissals([], [ALL[0]!])
|
||||||
|
await mountSeo(backend)
|
||||||
|
const panel = await openPanel()
|
||||||
|
await dismiss(0)
|
||||||
|
|
||||||
|
const text = textOf(panel)
|
||||||
|
expect(text).toContain('All good')
|
||||||
|
expect(text).toContain('1 dismissed suggestion is hidden.')
|
||||||
|
const restore = panel.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')!
|
||||||
|
expect(textOf(restore)).toBe('Show dismissed')
|
||||||
|
expect(document.activeElement).toBe(restore)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ position: 'first', index: 0, target: 0 },
|
||||||
|
{ position: 'second', index: 1, target: 1 },
|
||||||
|
{ position: 'last', index: 2, target: 1 },
|
||||||
|
])('moves focus after dismissing the $position row', async ({ index, target }) => {
|
||||||
|
const backend = backendWithDismissals()
|
||||||
|
await mountSeo(backend)
|
||||||
|
const panel = await openPanel()
|
||||||
|
await dismiss(index)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(dismissButtons(panel)[target])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ failure: 'status 500', responder: failWith(500) },
|
||||||
|
{ failure: 'a network error', responder: networkFailure },
|
||||||
|
])('keeps the row and toasts on $failure', async ({ responder }) => {
|
||||||
|
const backend = backendWithDismissals()
|
||||||
|
backend.responders['PATCH /v1/article/12'] = responder
|
||||||
|
await mountSeo(backend)
|
||||||
|
const panel = await openPanel()
|
||||||
|
const before = checkCalls(backend).length
|
||||||
|
await dismiss(0)
|
||||||
|
|
||||||
|
expect(panelRows(panel)).toHaveLength(3)
|
||||||
|
expect(dismissButtons(panel)[0]!.disabled).toBe(false)
|
||||||
|
expect(toastText()).toContain("Couldn't dismiss the suggestion")
|
||||||
|
expect(toastText()).toContain('Try again in a moment.')
|
||||||
|
expect(checkCalls(backend)).toHaveLength(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the Not found state on a 404', async () => {
|
||||||
|
const backend = backendWithDismissals()
|
||||||
|
backend.responders['PATCH /v1/article/12'] = failWith(404)
|
||||||
|
await mountSeo(backend)
|
||||||
|
await openPanel()
|
||||||
|
await dismiss(0)
|
||||||
|
|
||||||
|
expect(textOf(document.body)).toContain("This article doesn't exist.")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows nothing and sends no check on a 401', async () => {
|
||||||
|
const backend = backendWithDismissals()
|
||||||
|
backend.responders['PATCH /v1/article/12'] = failWith(401)
|
||||||
|
await mountSeo(backend)
|
||||||
|
const panel = await openPanel()
|
||||||
|
const before = checkCalls(backend).length
|
||||||
|
const toastsBefore = toastText()
|
||||||
|
await dismiss(0)
|
||||||
|
|
||||||
|
expect(panelRows(panel)).toHaveLength(3)
|
||||||
|
expect(toastText()).toBe(toastsBefore)
|
||||||
|
expect(checkCalls(backend)).toHaveLength(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores every code with Show dismissed and checks again', async () => {
|
||||||
|
const backend = backendWithDismissals(['title_long', 'short'], [ALL[0]!])
|
||||||
|
await mountSeo(backend)
|
||||||
|
const compact = await openSection()
|
||||||
|
expect(textOf(compact)).toContain('2 dismissed suggestions are hidden.')
|
||||||
|
const link = compact.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')!
|
||||||
|
link.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(patchBodies(backend)).toEqual([{ seo_dismissed: [] }])
|
||||||
|
expect(sectionBody()!.querySelectorAll('[data-testid="suggestion"]')).toHaveLength(1)
|
||||||
|
expect(sectionSummary()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['owner', 'admin', 'verified'] as const)('is available to the %s', async (role) => {
|
||||||
|
const backend = backendWithDismissals()
|
||||||
|
backend.me.role = role === 'verified' ? undefined : role
|
||||||
|
await mountSeo(backend)
|
||||||
|
const panel = await openPanel()
|
||||||
|
|
||||||
|
expect(dismissButtons(panel)).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps Save enabled and does not touch the draft', async () => {
|
||||||
|
const backend = backendWithDismissals()
|
||||||
|
await mountSeo(backend)
|
||||||
|
await openPanel()
|
||||||
|
await dismiss(0)
|
||||||
|
|
||||||
|
expect(patchBodies(backend)).toHaveLength(1)
|
||||||
|
const save = document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')
|
||||||
|
expect(save?.getAttribute('data-state') ?? 'saved').not.toBe('error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Dismiss x: French', () => {
|
||||||
|
it('reads the name, footnote and plural in French', async () => {
|
||||||
|
const { setLocale } = await import('@/i18n')
|
||||||
|
setLocale('fr')
|
||||||
|
try {
|
||||||
|
await mountSeo(backendWithDismissals(['no_au', 'short'], ALL))
|
||||||
|
const panel = await openPanel()
|
||||||
|
|
||||||
|
expect(dismissButtons(panel)[0]!.getAttribute('aria-label')).toMatch(
|
||||||
|
/^Ignorer la suggestion : /,
|
||||||
|
)
|
||||||
|
expect(textOf(panel)).toContain(
|
||||||
|
'Ignorez-en une et elle reste masquée pour cet article.',
|
||||||
|
)
|
||||||
|
expect(textOf(panel)).toContain('2 suggestions ignorées sont masquées.')
|
||||||
|
} finally {
|
||||||
|
setLocale('en')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||||
|
import router from '@/router'
|
||||||
|
import { setLocale } from '@/i18n'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import EndpointCard from '@/components/EndpointCard.vue'
|
||||||
|
import { API_GROUPS, ERROR_ROWS } from '@/data/apiCatalogue'
|
||||||
|
import IntegrationView from '../IntegrationView.vue'
|
||||||
|
|
||||||
|
const ORIGIN = 'https://cms.renew.example'
|
||||||
|
const BODY_MISMATCH = 'The request cannot be processed due to a mismatch in the format of the body.'
|
||||||
|
const NOTE =
|
||||||
|
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.'
|
||||||
|
const GROUP_DESCRIPTION =
|
||||||
|
"RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs."
|
||||||
|
|
||||||
|
let currentWrapper: VueWrapper | null = null
|
||||||
|
let fetchSpy: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
async function mountIntegration(path = '/integration') {
|
||||||
|
vi.stubGlobal('location', { ...window.location, origin: ORIGIN })
|
||||||
|
fetchSpy = vi.fn()
|
||||||
|
vi.stubGlobal('fetch', fetchSpy)
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const session = useSessionStore()
|
||||||
|
session.session = { verified: true, username: 'marta' }
|
||||||
|
session.status = 'ready'
|
||||||
|
Element.prototype.scrollIntoView = vi.fn()
|
||||||
|
await router.push(path)
|
||||||
|
currentWrapper = mount(IntegrationView, {
|
||||||
|
attachTo: document.body,
|
||||||
|
global: { plugins: [router, uiPlugin] },
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
return currentWrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(wrapper: VueWrapper, id: string) {
|
||||||
|
return wrapper.get(`[data-anchor="${id}"]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardOf(wrapper: VueWrapper, route: string) {
|
||||||
|
const [method, path] = route.split(' ')
|
||||||
|
return wrapper
|
||||||
|
.findAllComponents(EndpointCard)
|
||||||
|
.find(
|
||||||
|
(item) =>
|
||||||
|
item.props('endpoint').method === method && item.props('endpoint').path === path,
|
||||||
|
)!
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expand(wrapper: VueWrapper, route: string) {
|
||||||
|
const found = wrapper
|
||||||
|
.findAll('button[aria-expanded]')
|
||||||
|
.find((button) => button.attributes('aria-label')?.startsWith(`${route}. `))!
|
||||||
|
await found.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
return cardOf(wrapper, route)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
currentWrapper?.unmount()
|
||||||
|
currentWrapper = null
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
setLocale('en')
|
||||||
|
await router.push('/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Search and sharing group', () => {
|
||||||
|
it('sits directly after Articles with its description and 2 routes', async () => {
|
||||||
|
const wrapper = await mountIntegration()
|
||||||
|
|
||||||
|
const keys = API_GROUPS.map((group) => group.key)
|
||||||
|
expect(keys.indexOf('seo')).toBe(keys.indexOf('articles') + 1)
|
||||||
|
const group = section(wrapper, 'seo')
|
||||||
|
expect(group.text()).toContain('Search and sharing')
|
||||||
|
expect(group.text()).toContain(GROUP_DESCRIPTION)
|
||||||
|
expect(group.text()).toContain('2 routes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('holds both cards, Public and API key OK, collapsed, with no duplicate route', async () => {
|
||||||
|
const wrapper = await mountIntegration()
|
||||||
|
|
||||||
|
const group = section(wrapper, 'seo')
|
||||||
|
const cards = group.findAllComponents(EndpointCard)
|
||||||
|
expect(
|
||||||
|
cards.map((item) => `${item.props('endpoint').method} ${item.props('endpoint').path}`),
|
||||||
|
).toEqual(['GET /v1/article/{id}/seo', 'GET /v1/site/{key}/sitemap'])
|
||||||
|
for (const item of cards) {
|
||||||
|
expect(item.text()).toContain('Public')
|
||||||
|
expect(item.text()).toContain('API key OK')
|
||||||
|
expect(item.find('button[aria-expanded]').attributes('aria-expanded')).toBe('false')
|
||||||
|
}
|
||||||
|
const all = wrapper
|
||||||
|
.findAllComponents(EndpointCard)
|
||||||
|
.map((item) => `${item.props('endpoint').method} ${item.props('endpoint').path}`)
|
||||||
|
expect(all.filter((route) => route.endsWith('/seo'))).toHaveLength(1)
|
||||||
|
expect(all.filter((route) => route.endsWith('/sitemap'))).toHaveLength(1)
|
||||||
|
expect(
|
||||||
|
section(wrapper, 'articles')
|
||||||
|
.findAllComponents(EndpointCard)
|
||||||
|
.map((item) => item.props('endpoint').path),
|
||||||
|
).toContain('/v1/article/{id}/seo/check')
|
||||||
|
expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
|
||||||
|
expect(section(wrapper, 'sites').text()).toContain('1 route')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('describes the resolved SEO object and shows the real error rows', async () => {
|
||||||
|
const wrapper = await mountIntegration()
|
||||||
|
const seo = await expand(wrapper, 'GET /v1/article/{id}/seo')
|
||||||
|
|
||||||
|
const text = seo.text()
|
||||||
|
expect(text).toContain('Drafts answer 404 to anonymous callers.')
|
||||||
|
for (const name of [
|
||||||
|
'canonical_url',
|
||||||
|
'robots',
|
||||||
|
'og',
|
||||||
|
'twitter',
|
||||||
|
'image',
|
||||||
|
'authors',
|
||||||
|
'published_at',
|
||||||
|
'modified_at',
|
||||||
|
'fallbacks',
|
||||||
|
'json_ld',
|
||||||
|
]) {
|
||||||
|
expect(text).toContain(name)
|
||||||
|
}
|
||||||
|
expect(text).toContain('No request body.')
|
||||||
|
for (const tab of ['fetch', 'curl', 'Response']) {
|
||||||
|
expect(seo.findAll('[role="tab"]').map((item) => item.text())).toContain(tab)
|
||||||
|
}
|
||||||
|
expect(text).toContain('Copy')
|
||||||
|
expect(text).toContain('400')
|
||||||
|
expect(text).toContain('401')
|
||||||
|
expect(text).toContain('404')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the sitemap query rows, item shape, total header and real error strings', async () => {
|
||||||
|
const wrapper = await mountIntegration()
|
||||||
|
const sitemap = await expand(wrapper, 'GET /v1/site/{key}/sitemap')
|
||||||
|
|
||||||
|
const text = sitemap.text()
|
||||||
|
expect(text).toContain('A whole number from 1. Default 1.')
|
||||||
|
expect(text).toContain('25, 50, 100 or 500. Default 100.')
|
||||||
|
expect(text).toContain('{ url, modified_at, noindex }')
|
||||||
|
expect(text).toContain('X-Total-Count')
|
||||||
|
expect(text).toContain('/v1/site/renew-blog/sitemap')
|
||||||
|
expect(text).toContain(BODY_MISMATCH)
|
||||||
|
expect(text).toContain('site_not_found')
|
||||||
|
expect(text).not.toContain('Invalid page or size.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists the index in order and scrolls to the group with the deep link #seo', async () => {
|
||||||
|
const wrapper = await mountIntegration('/integration#seo')
|
||||||
|
|
||||||
|
const labels = wrapper
|
||||||
|
.get('nav[aria-label="On this page"]')
|
||||||
|
.findAll('button')
|
||||||
|
.map((button) => button.text())
|
||||||
|
expect(labels).toEqual([
|
||||||
|
'Authentication',
|
||||||
|
'Articles',
|
||||||
|
'Search and sharing',
|
||||||
|
'Media',
|
||||||
|
'Users and invites',
|
||||||
|
'Settings',
|
||||||
|
'Sites',
|
||||||
|
'Authors',
|
||||||
|
'Images and variants',
|
||||||
|
'Session only',
|
||||||
|
'Errors',
|
||||||
|
'Notes',
|
||||||
|
])
|
||||||
|
expect(Element.prototype.scrollIntoView).toHaveBeenCalled()
|
||||||
|
const current = wrapper
|
||||||
|
.get('nav[aria-label="On this page"]')
|
||||||
|
.findAll('button[aria-current]')
|
||||||
|
.map((button) => button.text())
|
||||||
|
expect(current).toEqual(['Search and sharing'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds the 403 note under the messages and leaves them unchanged', async () => {
|
||||||
|
const wrapper = await mountIntegration()
|
||||||
|
|
||||||
|
const forbidden = ERROR_ROWS.find((row) => row.status === 403)!
|
||||||
|
expect(forbidden.messages).toHaveLength(3)
|
||||||
|
expect(forbidden.note).toBe(NOTE)
|
||||||
|
const notes = wrapper.findAll('[data-testid="error-note"]').map((item) => item.text())
|
||||||
|
expect(notes).toEqual([NOTE, NOTE])
|
||||||
|
expect(ERROR_ROWS.filter((row) => row.note)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('makes no /v1 request when opening and expanding the new cards', async () => {
|
||||||
|
const wrapper = await mountIntegration()
|
||||||
|
await expand(wrapper, 'GET /v1/article/{id}/seo')
|
||||||
|
await expand(wrapper, 'GET /v1/site/{key}/sitemap')
|
||||||
|
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads the group, the cards and the note in French', async () => {
|
||||||
|
setLocale('fr')
|
||||||
|
const wrapper = await mountIntegration()
|
||||||
|
|
||||||
|
const group = section(wrapper, 'seo')
|
||||||
|
expect(group.text()).toContain('Recherche et partage')
|
||||||
|
expect(group.text()).toContain('RenewCMS n’affiche pas de pages')
|
||||||
|
expect(group.text()).toContain('2 routes')
|
||||||
|
expect(wrapper.text()).toContain(
|
||||||
|
'Les valeurs SEO par défaut d’un site sont réservées au propriétaire.',
|
||||||
|
)
|
||||||
|
expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import {
|
||||||
|
blur,
|
||||||
|
button,
|
||||||
|
callsTo,
|
||||||
|
chooseMenuItem,
|
||||||
|
click,
|
||||||
|
createBackend,
|
||||||
|
dialog,
|
||||||
|
failWith,
|
||||||
|
field,
|
||||||
|
mountDirectory,
|
||||||
|
networkFailure,
|
||||||
|
openRowMenu,
|
||||||
|
textOf,
|
||||||
|
typeInto,
|
||||||
|
unmountDirectory,
|
||||||
|
} from './directoryTestKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
const TEMPLATE_ERROR = 'The template must contain %title%.'
|
||||||
|
const SAVE_FAILED = "Couldn't save the site. Your changes are still here. Try again in a moment."
|
||||||
|
const SITE_NAME_HELP = 'Shown in titles through the template, and sent as the Open Graph site name.'
|
||||||
|
const DESCRIPTION_HELP = 'Used when an article has no description, excerpt or body text.'
|
||||||
|
|
||||||
|
afterEach(unmountDirectory)
|
||||||
|
|
||||||
|
function box() {
|
||||||
|
return dialog()!
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateField() {
|
||||||
|
return box().querySelector<HTMLInputElement>('input[maxlength="200"]')!
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteNameField() {
|
||||||
|
return field('e.g. Renew')
|
||||||
|
}
|
||||||
|
|
||||||
|
function descriptionField() {
|
||||||
|
return field('One or two sentences about the site.') as unknown as HTMLTextAreaElement
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEdit(name = 'Renew', backend = createBackend()) {
|
||||||
|
await mountDirectory('sites', '/settings/sites', { backend })
|
||||||
|
await openRowMenu(name)
|
||||||
|
await chooseMenuItem('Edit')
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdd(backend = createBackend()) {
|
||||||
|
await mountDirectory('sites', '/settings/sites', { backend })
|
||||||
|
await click('Add site')
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(label = 'Save changes') {
|
||||||
|
return click(label, box())
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Sites SEO defaults: field order and copy', () => {
|
||||||
|
it('lists the fields in the board order, then the preview and the defaults block', async () => {
|
||||||
|
await openEdit()
|
||||||
|
|
||||||
|
const text = textOf(box())
|
||||||
|
const order = [
|
||||||
|
'Name',
|
||||||
|
'Key',
|
||||||
|
'Base URL',
|
||||||
|
'Default social image',
|
||||||
|
'Site name',
|
||||||
|
'Title template',
|
||||||
|
'Example',
|
||||||
|
'Default description',
|
||||||
|
'Default robots policy',
|
||||||
|
'Locale',
|
||||||
|
'Default site',
|
||||||
|
'Canonical URL preview',
|
||||||
|
'How articles use these defaults',
|
||||||
|
]
|
||||||
|
const positions = order.map((label) => text.indexOf(label))
|
||||||
|
expect(positions.every((position) => position >= 0)).toBe(true)
|
||||||
|
expect([...positions].sort((a, b) => a - b)).toEqual(positions)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edit: shows the saved values, helpers, example, counter and the defaults block', async () => {
|
||||||
|
await openEdit()
|
||||||
|
|
||||||
|
const text = textOf(box())
|
||||||
|
expect(siteNameField().value).toBe('Renew')
|
||||||
|
expect(text).toContain(SITE_NAME_HELP)
|
||||||
|
expect(text).not.toContain('Site name (optional)')
|
||||||
|
expect(templateField().value).toBe('%title% | %site%')
|
||||||
|
expect(text).toContain('Use %title% for the article title and %site% for the site name.')
|
||||||
|
expect(text).toContain('Example How we price a bathroom renovation | Renew')
|
||||||
|
expect(text).toContain('Default description (optional)')
|
||||||
|
expect(text).toContain('0 / 320')
|
||||||
|
expect(text).toContain(DESCRIPTION_HELP)
|
||||||
|
for (const copy of [
|
||||||
|
'Title Article title, then the template.',
|
||||||
|
"Description Used last, after the article's description, excerpt and body.",
|
||||||
|
'Image Used when an article has no cover image.',
|
||||||
|
'Robots Used unless an article is hidden from search engines.',
|
||||||
|
]) {
|
||||||
|
expect(text).toContain(copy)
|
||||||
|
}
|
||||||
|
expect(templateField().className).toContain('font-mono')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('add: starts empty with the default template', async () => {
|
||||||
|
await openAdd()
|
||||||
|
|
||||||
|
expect(siteNameField().value).toBe('')
|
||||||
|
expect(templateField().value).toBe('%title% | %site%')
|
||||||
|
expect(descriptionField().value).toBe('')
|
||||||
|
expect(textOf(box())).toContain('How articles use these defaults')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
['Journal', 'Journal', '%title% – %site%', 'How we price a bathroom renovation – Journal'],
|
||||||
|
['Journal', '', '%title% – %site%', 'How we price a bathroom renovation – Journal'],
|
||||||
|
['Journal', 'Le Journal', '%site%: %title%', 'Le Journal: How we price a bathroom renovation'],
|
||||||
|
['Journal', 'Journal', '%title%', 'How we price a bathroom renovation'],
|
||||||
|
['Journal', 'Journal', '%site% / %site%', 'Journal / Journal'],
|
||||||
|
['', '', '%title% | %site%', 'How we price a bathroom renovation | Renew'],
|
||||||
|
])(
|
||||||
|
'Sites SEO defaults: example for name "%s", site name "%s", template "%s"',
|
||||||
|
(name, siteName, template, example) => {
|
||||||
|
it('follows the typing and sends nothing', async () => {
|
||||||
|
const backend = await openAdd()
|
||||||
|
const before = backend.calls.length
|
||||||
|
await typeInto(field('e.g. Journal'), name)
|
||||||
|
await typeInto(siteNameField(), siteName)
|
||||||
|
await typeInto(templateField(), template)
|
||||||
|
|
||||||
|
expect(textOf(box())).toContain(`Example ${example}`)
|
||||||
|
expect(backend.calls).toHaveLength(before)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
describe.each(['just text', '%site%', '%TITLE%', ''])(
|
||||||
|
'Sites SEO defaults: template "%s"',
|
||||||
|
(template) => {
|
||||||
|
it('is refused on blur and on submit, with nothing sent', async () => {
|
||||||
|
const backend = await openEdit()
|
||||||
|
await typeInto(templateField(), template)
|
||||||
|
await blur(templateField())
|
||||||
|
|
||||||
|
expect(textOf(box())).toContain(TEMPLATE_ERROR)
|
||||||
|
expect(textOf(box())).not.toContain('Example')
|
||||||
|
|
||||||
|
await typeInto(templateField(), template)
|
||||||
|
await submit()
|
||||||
|
expect(callsTo(backend, 'PATCH', '/v1/site/1')).toHaveLength(0)
|
||||||
|
expect(dialog()).not.toBeNull()
|
||||||
|
expect(document.activeElement).toBe(templateField())
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('Sites SEO defaults: template errors', () => {
|
||||||
|
it('clears the error and shows the example again on a valid value', async () => {
|
||||||
|
await openEdit()
|
||||||
|
await typeInto(templateField(), 'nope')
|
||||||
|
await blur(templateField())
|
||||||
|
await typeInto(templateField(), '%title%')
|
||||||
|
|
||||||
|
expect(textOf(box())).not.toContain(TEMPLATE_ERROR)
|
||||||
|
expect(textOf(box())).toContain('Example')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the same message when the API answers 400 invalid_title_template', async () => {
|
||||||
|
const backend = createBackend()
|
||||||
|
backend.responders['PATCH /v1/site/1'] = failWith(400, 'invalid_title_template')
|
||||||
|
await openEdit('Renew', backend)
|
||||||
|
await typeInto(templateField(), '%title% – %site%')
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
expect(textOf(box())).toContain(TEMPLATE_ERROR)
|
||||||
|
expect(templateField().value).toBe('%title% – %site%')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Sites SEO defaults: limits and counter', () => {
|
||||||
|
it('stops the inputs at 100, 200 and 320 characters', async () => {
|
||||||
|
await openAdd()
|
||||||
|
|
||||||
|
expect(siteNameField().getAttribute('maxlength')).toBe('100')
|
||||||
|
expect(templateField().getAttribute('maxlength')).toBe('200')
|
||||||
|
expect(descriptionField().getAttribute('maxlength')).toBe('320')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([0, 45, 320])('counts %i characters without blocking', async (count) => {
|
||||||
|
await openAdd()
|
||||||
|
await typeInto(descriptionField(), 'a'.repeat(count))
|
||||||
|
|
||||||
|
expect(textOf(box())).toContain(`${count} / 320`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Sites SEO defaults: save payloads', () => {
|
||||||
|
it('sends only the changed template', async () => {
|
||||||
|
const backend = await openEdit()
|
||||||
|
await typeInto(templateField(), '%title% – %site%')
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
const patches = callsTo(backend, 'PATCH', '/v1/site/1')
|
||||||
|
expect(patches).toHaveLength(1)
|
||||||
|
expect(patches[0]!.body).toEqual({ title_template: '%title% – %site%' })
|
||||||
|
expect(dialog()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends site name, description and robots together, trimmed', async () => {
|
||||||
|
const backend = await openEdit()
|
||||||
|
await typeInto(siteNameField(), ' Renew Journal ')
|
||||||
|
await typeInto(descriptionField(), 'Notes on renewal work. ')
|
||||||
|
box().querySelector<HTMLElement>('[role="radio"][value="noindex"]')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
const patches = callsTo(backend, 'PATCH', '/v1/site/1')
|
||||||
|
expect(patches).toHaveLength(1)
|
||||||
|
expect(patches[0]!.body).toEqual({
|
||||||
|
site_name: 'Renew Journal',
|
||||||
|
default_description: 'Notes on renewal work.',
|
||||||
|
default_robots: 'noindex',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a site without the three fields when untouched', async () => {
|
||||||
|
const backend = await openAdd()
|
||||||
|
await typeInto(field('e.g. Journal'), 'Notes')
|
||||||
|
await typeInto(field('https://blog.example.com'), 'https://notes.example')
|
||||||
|
await submit('Add site')
|
||||||
|
|
||||||
|
const body = callsTo(backend, 'POST', '/v1/site/')[0]!.body!
|
||||||
|
expect(body).not.toHaveProperty('site_name')
|
||||||
|
expect(body).not.toHaveProperty('title_template')
|
||||||
|
expect(body).not.toHaveProperty('default_description')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a site with the SEO values', async () => {
|
||||||
|
const backend = await openAdd()
|
||||||
|
await typeInto(field('e.g. Journal'), 'Notes')
|
||||||
|
await typeInto(field('https://blog.example.com'), 'https://notes.example')
|
||||||
|
await typeInto(siteNameField(), 'Renew Shop')
|
||||||
|
await typeInto(templateField(), '%title% - %site%')
|
||||||
|
await typeInto(descriptionField(), 'A shop about renewal.')
|
||||||
|
await submit('Add site')
|
||||||
|
|
||||||
|
expect(callsTo(backend, 'POST', '/v1/site/')[0]!.body).toMatchObject({
|
||||||
|
site_name: 'Renew Shop',
|
||||||
|
title_template: '%title% - %site%',
|
||||||
|
default_description: 'A shop about renewal.',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens a site without the new fields on the defaults and sends nothing on save', async () => {
|
||||||
|
const backend = createBackend()
|
||||||
|
for (const site of backend.sites) {
|
||||||
|
delete site.site_name
|
||||||
|
delete site.title_template
|
||||||
|
delete site.default_description
|
||||||
|
}
|
||||||
|
await openEdit('Renew', backend)
|
||||||
|
|
||||||
|
expect(siteNameField().value).toBe('')
|
||||||
|
expect(descriptionField().value).toBe('')
|
||||||
|
expect(templateField().value).toBe('%title% | %site%')
|
||||||
|
await submit()
|
||||||
|
expect(callsTo(backend, 'PATCH', '/v1/site/1')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['site name', 'template', 'description'])(
|
||||||
|
'asks to discard a changed %s',
|
||||||
|
async (which) => {
|
||||||
|
await openEdit()
|
||||||
|
const target =
|
||||||
|
which === 'site name'
|
||||||
|
? siteNameField()
|
||||||
|
: which === 'template'
|
||||||
|
? templateField()
|
||||||
|
: descriptionField()
|
||||||
|
await typeInto(target, 'changed %title%')
|
||||||
|
await click('Cancel', box())
|
||||||
|
|
||||||
|
expect(textOf(document.body.querySelector('[role="alertdialog"]'))).toContain(
|
||||||
|
'Discard changes?',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
['status 500', failWith(500)],
|
||||||
|
['a network error', networkFailure],
|
||||||
|
])('Sites SEO defaults: save failed with %s', (_name, responder) => {
|
||||||
|
it('keeps the panel, the notice and the three values', async () => {
|
||||||
|
const backend = createBackend()
|
||||||
|
backend.responders['PATCH /v1/site/1'] = responder
|
||||||
|
await openEdit('Renew', backend)
|
||||||
|
await typeInto(siteNameField(), 'Renew Journal')
|
||||||
|
await typeInto(templateField(), '%title% – %site%')
|
||||||
|
await typeInto(descriptionField(), 'Notes.')
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
|
||||||
|
expect(siteNameField().value).toBe('Renew Journal')
|
||||||
|
expect(templateField().value).toBe('%title% – %site%')
|
||||||
|
expect(descriptionField().value).toBe('Notes.')
|
||||||
|
expect(button('Save changes', box())?.disabled).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Sites SEO defaults: sessions', () => {
|
||||||
|
it('shows the owner-only toast when the PATCH answers 403', async () => {
|
||||||
|
const backend = createBackend()
|
||||||
|
backend.responders['PATCH /v1/site/1'] = failWith(403, 'owner_only')
|
||||||
|
await openEdit('Renew', backend)
|
||||||
|
await typeInto(templateField(), '%title% – %site%')
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain('Only the owner can manage sites')
|
||||||
|
expect(document.body.textContent).toContain('Ask the owner of this site.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the 403 page to an admin', async () => {
|
||||||
|
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
|
||||||
|
|
||||||
|
expect(document.body.textContent).toContain("You don't have access to Sites")
|
||||||
|
expect(document.body.textContent).not.toContain('Title template')
|
||||||
|
expect(vi.mocked(useApi)).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,15 +6,25 @@ export interface EnvelopeParams {
|
|||||||
q?: string
|
q?: string
|
||||||
status?: ArticleStatus | ''
|
status?: ArticleStatus | ''
|
||||||
sort?: string
|
sort?: string
|
||||||
|
site?: string
|
||||||
|
seo?: '' | 'suggestions'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const NEEDS_ATTENTION_TOTAL = 3
|
||||||
|
export const NEEDS_ATTENTION_ROUTE = 'GET /v1/article/?seo=suggestions&page=1&size=5'
|
||||||
|
|
||||||
function fold(value: string) {
|
function fold(value: string) {
|
||||||
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): ArticlePage {
|
export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): ArticlePage {
|
||||||
const { page = 1, size = 25, q = '', status = '', sort = '' } = params
|
const { page = 1, size = 25, q = '', status = '', sort = '', seo = '' } = params
|
||||||
const filtered = all
|
const filtered = all
|
||||||
|
.filter(
|
||||||
|
(article) =>
|
||||||
|
!seo ||
|
||||||
|
(article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0),
|
||||||
|
)
|
||||||
.filter((article) => !status || article.is_online === (status === 'online'))
|
.filter((article) => !status || article.is_online === (status === 'online'))
|
||||||
.filter((article) => !q || fold(article.title).includes(fold(q)))
|
.filter((article) => !q || fold(article.title).includes(fold(q)))
|
||||||
if (sort) {
|
if (sort) {
|
||||||
@@ -35,8 +45,17 @@ export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): Ar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isNeedsAttentionCount(url: string) {
|
||||||
|
const query = new URL(url, 'http://localhost').searchParams
|
||||||
|
return query.get('seo') === 'suggestions' && query.get('size') === '5' && !query.has('format')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsAttentionCountPage(total = NEEDS_ATTENTION_TOTAL): ArticlePage {
|
||||||
|
return { items: [], total, page: 1, size: 5, counts: { all: 0, online: 0, draft: 0 } }
|
||||||
|
}
|
||||||
|
|
||||||
export function articleListUrl(params: EnvelopeParams = {}) {
|
export function articleListUrl(params: EnvelopeParams = {}) {
|
||||||
const { page = 1, size = 25, q = '', status = '', sort = '' } = params
|
const { page = 1, size = 25, q = '', status = '', sort = '', site = '', seo = '' } = params
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
format: 'html',
|
format: 'html',
|
||||||
page: String(page),
|
page: String(page),
|
||||||
@@ -51,6 +70,12 @@ export function articleListUrl(params: EnvelopeParams = {}) {
|
|||||||
if (sort) {
|
if (sort) {
|
||||||
query.set('sort', sort)
|
query.set('sort', sort)
|
||||||
}
|
}
|
||||||
|
if (site) {
|
||||||
|
query.set('site', site)
|
||||||
|
}
|
||||||
|
if (seo) {
|
||||||
|
query.set('seo', seo)
|
||||||
|
}
|
||||||
return `/v1/article/?${query.toString()}`
|
return `/v1/article/?${query.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,5 +91,7 @@ export function parseListUrl(url: string): EnvelopeParams {
|
|||||||
q: query.get('q') ?? '',
|
q: query.get('q') ?? '',
|
||||||
status: (query.get('status') ?? '') as ArticleStatus | '',
|
status: (query.get('status') ?? '') as ArticleStatus | '',
|
||||||
sort: query.get('sort') ?? '',
|
sort: query.get('sort') ?? '',
|
||||||
|
...(query.get('site') ? { site: query.get('site') ?? '' } : {}),
|
||||||
|
...(query.get('seo') ? { seo: 'suggestions' as const } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,12 +36,15 @@ export function sitesFixture(): Site[] {
|
|||||||
default_social_image_id: null,
|
default_social_image_id: null,
|
||||||
default_robots: 'index' as const,
|
default_robots: 'index' as const,
|
||||||
locale: null,
|
locale: null,
|
||||||
|
title_template: '%title% | %site%',
|
||||||
|
default_description: '',
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...base,
|
...base,
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Renew',
|
name: 'Renew',
|
||||||
|
site_name: 'Renew',
|
||||||
key: 'renew',
|
key: 'renew',
|
||||||
base_url: 'https://renew.example',
|
base_url: 'https://renew.example',
|
||||||
is_default: true,
|
is_default: true,
|
||||||
@@ -51,6 +54,7 @@ export function sitesFixture(): Site[] {
|
|||||||
...base,
|
...base,
|
||||||
id: 2,
|
id: 2,
|
||||||
name: 'Journal',
|
name: 'Journal',
|
||||||
|
site_name: 'Journal',
|
||||||
key: 'journal',
|
key: 'journal',
|
||||||
base_url: 'https://journal.example',
|
base_url: 'https://journal.example',
|
||||||
is_default: false,
|
is_default: false,
|
||||||
@@ -60,6 +64,7 @@ export function sitesFixture(): Site[] {
|
|||||||
...base,
|
...base,
|
||||||
id: 3,
|
id: 3,
|
||||||
name: 'Projects',
|
name: 'Projects',
|
||||||
|
site_name: 'Projects',
|
||||||
key: 'projects',
|
key: 'projects',
|
||||||
base_url: 'https://projects.example',
|
base_url: 'https://projects.example',
|
||||||
default_robots: 'noindex',
|
default_robots: 'noindex',
|
||||||
@@ -70,6 +75,7 @@ export function sitesFixture(): Site[] {
|
|||||||
...base,
|
...base,
|
||||||
id: 4,
|
id: 4,
|
||||||
name: 'Archive',
|
name: 'Archive',
|
||||||
|
site_name: 'Archive',
|
||||||
key: 'archive',
|
key: 'archive',
|
||||||
base_url: 'https://archive.example',
|
base_url: 'https://archive.example',
|
||||||
is_default: false,
|
is_default: false,
|
||||||
|
|||||||
@@ -174,6 +174,11 @@ function listArticles(backend: MetaBackend, query: URLSearchParams) {
|
|||||||
.filter((article) => !siteKey || article.site_id === siteId)
|
.filter((article) => !siteKey || article.site_id === siteId)
|
||||||
.filter((article) => !status || article.is_online === (status === 'online'))
|
.filter((article) => !status || article.is_online === (status === 'online'))
|
||||||
.filter((article) => !q || article.title.toLowerCase().includes(q))
|
.filter((article) => !q || article.title.toLowerCase().includes(q))
|
||||||
|
.filter(
|
||||||
|
(article) =>
|
||||||
|
query.get('seo') !== 'suggestions' ||
|
||||||
|
(article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0),
|
||||||
|
)
|
||||||
const online = backend.articles.filter((article) => article.is_online).length
|
const online = backend.articles.filter((article) => article.is_online).length
|
||||||
return {
|
return {
|
||||||
items: structuredClone(matching.slice((page - 1) * size, page * size)),
|
items: structuredClone(matching.slice((page - 1) * size, page * size)),
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ const extraSources: Record<string, Source[]> = {
|
|||||||
states: ['With articles', 'Choose destination', 'No articles'],
|
states: ['With articles', 'Choose destination', 'No articles'],
|
||||||
alias: {},
|
alias: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
board: officeBoard,
|
||||||
|
group: 'Sites: SEO defaults',
|
||||||
|
states: [
|
||||||
|
'Edit: SEO defaults',
|
||||||
|
'Add: defaults',
|
||||||
|
'Template: custom',
|
||||||
|
'Template: not valid',
|
||||||
|
'Save failed',
|
||||||
|
],
|
||||||
|
alias: {},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
authors: [
|
authors: [
|
||||||
{
|
{
|
||||||
@@ -195,6 +207,12 @@ const extraSources: Record<string, Source[]> = {
|
|||||||
states: ['Several sites', 'Site filter open', 'No articles on a site'],
|
states: ['Several sites', 'Site filter open', 'No articles on a site'],
|
||||||
alias: {},
|
alias: {},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
board: officeBoard,
|
||||||
|
group: 'Articles: search status',
|
||||||
|
states: ['Status chips', 'Needs attention on', 'Needs attention: none'],
|
||||||
|
alias: {},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
board: officeBoard,
|
board: officeBoard,
|
||||||
group: 'Articles: pagination',
|
group: 'Articles: pagination',
|
||||||
|
|||||||
+155
-119
@@ -837,6 +837,8 @@ function articlesFixture() {
|
|||||||
site_id: 1,
|
site_id: 1,
|
||||||
authors: boardBylines('Inès Duarte', 'Camille Roche'),
|
authors: boardBylines('Inès Duarte', 'Camille Roche'),
|
||||||
is_online: false,
|
is_online: false,
|
||||||
|
seo_status: 'suggestions',
|
||||||
|
seo_suggestions: 2,
|
||||||
created_at: '2026-09-09T09:00:00Z',
|
created_at: '2026-09-09T09:00:00Z',
|
||||||
updated_at: articlesFixtureIso(1 * DAY_MS),
|
updated_at: articlesFixtureIso(1 * DAY_MS),
|
||||||
},
|
},
|
||||||
@@ -848,6 +850,8 @@ function articlesFixture() {
|
|||||||
site_id: 3,
|
site_id: 3,
|
||||||
authors: boardBylines('Jonas Pereira'),
|
authors: boardBylines('Jonas Pereira'),
|
||||||
is_online: true,
|
is_online: true,
|
||||||
|
seo_status: 'hidden',
|
||||||
|
seo_suggestions: 0,
|
||||||
created_at: '2026-08-28T09:00:00Z',
|
created_at: '2026-08-28T09:00:00Z',
|
||||||
updated_at: articlesFixtureIso(3 * DAY_MS),
|
updated_at: articlesFixtureIso(3 * DAY_MS),
|
||||||
},
|
},
|
||||||
@@ -865,6 +869,8 @@ function articlesFixture() {
|
|||||||
'Léa Fontaine',
|
'Léa Fontaine',
|
||||||
),
|
),
|
||||||
is_online: false,
|
is_online: false,
|
||||||
|
seo_status: 'suggestions',
|
||||||
|
seo_suggestions: 3,
|
||||||
created_at: '2026-09-02T09:00:00Z',
|
created_at: '2026-09-02T09:00:00Z',
|
||||||
updated_at: articlesFixtureIso(5 * DAY_MS),
|
updated_at: articlesFixtureIso(5 * DAY_MS),
|
||||||
},
|
},
|
||||||
@@ -906,7 +912,22 @@ function foldTitle(value: string) {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
function articleEnvelopeFor(all: ListedArticle[], url: URL) {
|
const boardNeedsAttentionCount = 3
|
||||||
|
|
||||||
|
function articleEnvelopeFor(
|
||||||
|
all: ListedArticle[],
|
||||||
|
url: URL,
|
||||||
|
needsAttention = boardNeedsAttentionCount,
|
||||||
|
) {
|
||||||
|
if (url.searchParams.get('seo') === 'suggestions' && !url.searchParams.has('format')) {
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
total: needsAttention,
|
||||||
|
page: 1,
|
||||||
|
size: 5,
|
||||||
|
counts: { all: 0, online: 0, draft: 0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
const page = Number(url.searchParams.get('page') ?? 1)
|
const page = Number(url.searchParams.get('page') ?? 1)
|
||||||
const size = Number(url.searchParams.get('size') ?? 25)
|
const size = Number(url.searchParams.get('size') ?? 25)
|
||||||
const q = url.searchParams.get('q') ?? ''
|
const q = url.searchParams.get('q') ?? ''
|
||||||
@@ -927,8 +948,8 @@ function articleEnvelopeFor(all: ListedArticle[], url: URL) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function articleListMock(all: ListedArticle[]): ApiMock {
|
function articleListMock(all: ListedArticle[], needsAttention?: number): ApiMock {
|
||||||
return { status: 200, bodyFor: (url) => articleEnvelopeFor(all, url) }
|
return { status: 200, bodyFor: (url) => articleEnvelopeFor(all, url, needsAttention) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function articlesFixtureWithImages() {
|
function articlesFixtureWithImages() {
|
||||||
@@ -4715,6 +4736,9 @@ const directorySites = [
|
|||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Renew',
|
name: 'Renew',
|
||||||
|
site_name: 'Renew',
|
||||||
|
title_template: '%title% | %site%',
|
||||||
|
default_description: '',
|
||||||
key: 'renew',
|
key: 'renew',
|
||||||
base_url: 'https://renew.example',
|
base_url: 'https://renew.example',
|
||||||
default_social_image_id: 11,
|
default_social_image_id: 11,
|
||||||
@@ -4726,6 +4750,9 @@ const directorySites = [
|
|||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: 'Journal',
|
name: 'Journal',
|
||||||
|
site_name: 'Journal',
|
||||||
|
title_template: '%title% | %site%',
|
||||||
|
default_description: '',
|
||||||
key: 'journal',
|
key: 'journal',
|
||||||
base_url: 'https://journal.renew.example',
|
base_url: 'https://journal.renew.example',
|
||||||
default_social_image_id: null,
|
default_social_image_id: null,
|
||||||
@@ -4737,6 +4764,9 @@ const directorySites = [
|
|||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
name: 'Projects',
|
name: 'Projects',
|
||||||
|
site_name: 'Projects',
|
||||||
|
title_template: '%title% | %site%',
|
||||||
|
default_description: '',
|
||||||
key: 'projects',
|
key: 'projects',
|
||||||
base_url: 'https://projects.renew.example',
|
base_url: 'https://projects.renew.example',
|
||||||
default_social_image_id: 12,
|
default_social_image_id: 12,
|
||||||
@@ -4748,6 +4778,9 @@ const directorySites = [
|
|||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
name: 'Archive',
|
name: 'Archive',
|
||||||
|
site_name: 'Archive',
|
||||||
|
title_template: '%title% | %site%',
|
||||||
|
default_description: '',
|
||||||
key: 'archive',
|
key: 'archive',
|
||||||
base_url: 'https://archive.renew.example',
|
base_url: 'https://archive.renew.example',
|
||||||
default_social_image_id: null,
|
default_social_image_id: null,
|
||||||
@@ -4807,6 +4840,13 @@ function siteFormField(page: Page, placeholder: string) {
|
|||||||
return page.getByRole('dialog').getByPlaceholder(placeholder)
|
return page.getByRole('dialog').getByPlaceholder(placeholder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const siteSeoDescription =
|
||||||
|
'Practical advice on renovating old buildings, from the people who do the work.'
|
||||||
|
|
||||||
|
function siteTemplateField(page: Page) {
|
||||||
|
return page.getByRole('dialog').locator('input[maxlength="200"]')
|
||||||
|
}
|
||||||
|
|
||||||
async function scrollPanelToTop(page: Page) {
|
async function scrollPanelToTop(page: Page) {
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
document.querySelectorAll('[role="dialog"] [data-slot="body"]').forEach((body) => {
|
document.querySelectorAll('[role="dialog"] [data-slot="body"]').forEach((body) => {
|
||||||
@@ -4942,6 +4982,79 @@ const sitesStates: Record<string, VisualState> = {
|
|||||||
await page.mouse.move(0, 0)
|
await page.mouse.move(0, 0)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'Edit: SEO defaults': {
|
||||||
|
path: '/settings/sites',
|
||||||
|
api: sitesApi({
|
||||||
|
'GET /v1/site/': {
|
||||||
|
status: 200,
|
||||||
|
body: directorySites.map((site) =>
|
||||||
|
site.id === 1 ? { ...site, default_description: siteSeoDescription } : site,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
steps: async (page) => {
|
||||||
|
await openSiteEdit(page, 'Renew')
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Add: defaults': {
|
||||||
|
path: '/settings/sites',
|
||||||
|
api: sitesApi(),
|
||||||
|
steps: async (page) => {
|
||||||
|
await openDirectoryAdd(page, 'Add site')
|
||||||
|
await siteFormField(page, 'e.g. Journal').fill('Journal')
|
||||||
|
await siteFormField(page, 'https://blog.example.com').fill(
|
||||||
|
'https://journal.renew.example',
|
||||||
|
)
|
||||||
|
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Template: custom': {
|
||||||
|
path: '/settings/sites',
|
||||||
|
api: sitesApi({
|
||||||
|
'GET /v1/site/': {
|
||||||
|
status: 200,
|
||||||
|
body: directorySites.map((site) =>
|
||||||
|
site.id === 2 ? { ...site, title_template: '%title% – %site%' } : site,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
steps: async (page) => {
|
||||||
|
await openSiteEdit(page, 'Journal')
|
||||||
|
await siteTemplateField(page).focus()
|
||||||
|
await scrollPanelToTop(page)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Template: not valid': {
|
||||||
|
path: '/settings/sites',
|
||||||
|
api: sitesApi(),
|
||||||
|
steps: async (page) => {
|
||||||
|
await openSiteEdit(page, 'Journal')
|
||||||
|
await siteTemplateField(page).fill('%site%')
|
||||||
|
await siteTemplateField(page).blur()
|
||||||
|
await page.getByRole('dialog').getByText('The template must contain %title%.').waitFor()
|
||||||
|
await scrollPanelToTop(page)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Save failed': {
|
||||||
|
path: '/settings/sites',
|
||||||
|
api: sitesApi({
|
||||||
|
'GET /v1/site/': {
|
||||||
|
status: 200,
|
||||||
|
body: directorySites.map((site) =>
|
||||||
|
site.id === 1 ? { ...site, site_name: 'Ren' } : site,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
'PATCH /v1/site/1': { status: 500 },
|
||||||
|
}),
|
||||||
|
steps: async (page) => {
|
||||||
|
await openSiteEdit(page, 'Renew')
|
||||||
|
await siteFormField(page, 'e.g. Renew').fill('Renew')
|
||||||
|
await submitDirectoryPanel(page, 'Save changes')
|
||||||
|
await page.getByRole('dialog').getByText("Couldn't save the site").waitFor()
|
||||||
|
},
|
||||||
|
},
|
||||||
'With articles': {
|
'With articles': {
|
||||||
path: '/settings/sites',
|
path: '/settings/sites',
|
||||||
api: sitesApi(),
|
api: sitesApi(),
|
||||||
@@ -5694,8 +5807,6 @@ async function seoSaveFromPanel(page: Page) {
|
|||||||
.click()
|
.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
const dismissColumnReason =
|
|
||||||
'The board draws a dismiss column and its footnote in the suggestion list; dismissal is deferred to slice 4'
|
|
||||||
const boardOmitsMobileDialogReason =
|
const boardOmitsMobileDialogReason =
|
||||||
"The board's mobile frame omits the Create author dialog, so only the Details sheet is drawn"
|
"The board's mobile frame omits the Create author dialog, so only the Details sheet is drawn"
|
||||||
|
|
||||||
@@ -5731,23 +5842,15 @@ const editorSeoStates: Record<string, VisualState> = {
|
|||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
'Open: other site': seoSidebarState({ article: { site_id: 2 }, sites: [seoJournal()] }, true),
|
'Open: other site': seoSidebarState({ article: { site_id: 2 }, sites: [seoJournal()] }, true),
|
||||||
'Panel: defaults': withMobileException(
|
'Panel: defaults': seoPanelState({ check: seoCheck(['cover_none']) }),
|
||||||
seoPanelState({ check: seoCheck(['cover_none']) }),
|
|
||||||
26.5,
|
|
||||||
dismissColumnReason,
|
|
||||||
),
|
|
||||||
'Panel: filled in': seoPanelState({ article: seoFilled }),
|
'Panel: filled in': seoPanelState({ article: seoFilled }),
|
||||||
'Panel: outside the soft ranges': withMobileException(
|
'Panel: outside the soft ranges': seoPanelState({
|
||||||
seoPanelState({
|
article: {
|
||||||
article: {
|
seo_title: seoLongTitle,
|
||||||
seo_title: seoLongTitle,
|
seo_description: 'What a bathroom renovation costs.',
|
||||||
seo_description: 'What a bathroom renovation costs.',
|
},
|
||||||
},
|
check: seoCheck(['title_long', 'desc_short']),
|
||||||
check: seoCheck(['title_long', 'desc_short']),
|
}),
|
||||||
}),
|
|
||||||
15.5,
|
|
||||||
dismissColumnReason,
|
|
||||||
),
|
|
||||||
'Panel: slug edited': seoPanelState({ article: { slug: 'bathroom-costs' } }),
|
'Panel: slug edited': seoPanelState({ article: { slug: 'bathroom-costs' } }),
|
||||||
'Panel: slug taken': seoPanelState(
|
'Panel: slug taken': seoPanelState(
|
||||||
{
|
{
|
||||||
@@ -5765,16 +5868,12 @@ const editorSeoStates: Record<string, VisualState> = {
|
|||||||
await page.getByText('is already used on Renew.').waitFor()
|
await page.getByText('is already used on Renew.').waitFor()
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
'Panel: cover without alt': withMobileException(
|
'Panel: cover without alt': seoPanelState({
|
||||||
seoPanelState({
|
article: seoCoverNoAlt,
|
||||||
article: seoCoverNoAlt,
|
check: seoCheck(['cover_alt']),
|
||||||
check: seoCheck(['cover_alt']),
|
}),
|
||||||
}),
|
'Panel: choose cover': {
|
||||||
17,
|
...seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => {
|
||||||
dismissColumnReason,
|
|
||||||
),
|
|
||||||
'Panel: choose cover': withMobileException(
|
|
||||||
seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => {
|
|
||||||
await page
|
await page
|
||||||
.getByRole('dialog')
|
.getByRole('dialog')
|
||||||
.getByRole('button', { name: 'Choose image', exact: true })
|
.getByRole('button', { name: 'Choose image', exact: true })
|
||||||
@@ -5784,9 +5883,12 @@ const editorSeoStates: Record<string, VisualState> = {
|
|||||||
await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click()
|
await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click()
|
||||||
await page.mouse.move(0, 0)
|
await page.mouse.move(0, 0)
|
||||||
}),
|
}),
|
||||||
29,
|
exception: {
|
||||||
dismissColumnReason,
|
maxPercent: { desktop: 10, mobile: 29 },
|
||||||
),
|
reason: 'The shared media picker sheet is taller and shows more tiles than the board on mobile (28.29% light, 24.08% dark); the picker is not changed here',
|
||||||
|
approvedBy: 'fsylvain',
|
||||||
|
},
|
||||||
|
},
|
||||||
'Panel: social sharing open': seoPanelState(
|
'Panel: social sharing open': seoPanelState(
|
||||||
{ article: { cover_image_id: 1, cover_image_alt: seoCoverAlt } },
|
{ article: { cover_image_id: 1, cover_image_alt: seoCoverAlt } },
|
||||||
(page) => seoOpenDisclosure(page, 'Social sharing'),
|
(page) => seoOpenDisclosure(page, 'Social sharing'),
|
||||||
@@ -5813,19 +5915,12 @@ const editorSeoStates: Record<string, VisualState> = {
|
|||||||
async () => {},
|
async () => {},
|
||||||
700,
|
700,
|
||||||
),
|
),
|
||||||
'Panel: mobile search preview': withMobileException(
|
'Panel: mobile search preview': seoPanelState(
|
||||||
seoPanelState(
|
{ article: { seo_title: seoLongTitle }, check: seoCheck(['title_long']) },
|
||||||
{ article: { seo_title: seoLongTitle }, check: seoCheck(['title_long']) },
|
async (page) => {
|
||||||
async (page) => {
|
await page.getByRole('dialog').getByRole('tab', { name: 'Mobile', exact: true }).click()
|
||||||
await page
|
await page.mouse.move(0, 0)
|
||||||
.getByRole('dialog')
|
},
|
||||||
.getByRole('tab', { name: 'Mobile', exact: true })
|
|
||||||
.click()
|
|
||||||
await page.mouse.move(0, 0)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
17.5,
|
|
||||||
dismissColumnReason,
|
|
||||||
),
|
),
|
||||||
'Panel: checking': seoPanelState({ check: { status: 200, hang: true } }),
|
'Panel: checking': seoPanelState({ check: { status: 200, hang: true } }),
|
||||||
'Panel: saving': seoPanelState(
|
'Panel: saving': seoPanelState(
|
||||||
@@ -6282,9 +6377,9 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
'6. Errors': integrationState({
|
'6. Errors': integrationState({
|
||||||
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 60, mobile: 55 }),
|
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 60, mobile: 55 }),
|
||||||
exception: {
|
exception: {
|
||||||
maxPercent: { desktop: 12.5, mobile: 19 },
|
maxPercent: { desktop: 16, mobile: 22 },
|
||||||
reason: 'A 409 row is new, the typical-cause text uses the real server messages in place of the longer board prose, and the board changed (17.33 % mobile already at HEAD against the updated board)',
|
reason: 'The 403 note under the Errors table has no layout in the design (design gap) and pushes the following rows down; the 409 row is new and the board already differed at HEAD',
|
||||||
approvedBy: 'Florian Sylvain',
|
approvedBy: 'fsylvain',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
'I1 · Many variants': integrationImagesState(integrationManyVariants),
|
'I1 · Many variants': integrationImagesState(integrationManyVariants),
|
||||||
@@ -6357,6 +6452,15 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
},
|
},
|
||||||
articles: {
|
articles: {
|
||||||
...pagination,
|
...pagination,
|
||||||
|
'Status chips': clockSensitiveArticles('/article'),
|
||||||
|
'Needs attention on': clockSensitiveArticles('/article?seo=suggestions'),
|
||||||
|
'Needs attention: none': {
|
||||||
|
path: '/article?seo=suggestions',
|
||||||
|
api: articlesApi({ 'GET /v1/article/': articleListMock([], 0) }),
|
||||||
|
steps: async (page) => {
|
||||||
|
await page.getByText('Nothing needs attention').waitFor()
|
||||||
|
},
|
||||||
|
},
|
||||||
'Several sites': {
|
'Several sites': {
|
||||||
path: '/article',
|
path: '/article',
|
||||||
now: articlesNow,
|
now: articlesNow,
|
||||||
@@ -6380,11 +6484,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
path: '/article',
|
path: '/article',
|
||||||
now: articlesNow,
|
now: articlesNow,
|
||||||
api: articlesApi(),
|
api: articlesApi(),
|
||||||
exception: {
|
|
||||||
maxPercent: 3.7,
|
|
||||||
reason: 'UTable column boundaries land ~9-12px off the board grid (110/130/140/44px), unresolved by nth-child width overrides on table-fixed',
|
|
||||||
approvedBy: 'Florian Sylvain',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'Row menu open': {
|
'Row menu open': {
|
||||||
path: '/article',
|
path: '/article',
|
||||||
@@ -6393,11 +6492,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
steps: async (page) => {
|
steps: async (page) => {
|
||||||
await openArticleRowMenu(page, 'Winter checklist for timber windows')
|
await openArticleRowMenu(page, 'Winter checklist for timber windows')
|
||||||
},
|
},
|
||||||
exception: {
|
|
||||||
maxPercent: 7.5,
|
|
||||||
reason: 'Same UTable column-boundary offset as Default, plus the open row menu popover rendering',
|
|
||||||
approvedBy: 'Florian Sylvain',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Loading: {
|
Loading: {
|
||||||
path: '/article',
|
path: '/article',
|
||||||
@@ -6405,21 +6499,11 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
api: articlesApi({
|
api: articlesApi({
|
||||||
'GET /v1/article/': { ...articleListMock(articlesFixture()), hang: true },
|
'GET /v1/article/': { ...articleListMock(articlesFixture()), hang: true },
|
||||||
}),
|
}),
|
||||||
exception: {
|
|
||||||
maxPercent: 10.6,
|
|
||||||
reason: 'Skeleton placeholder block sizes/positions only approximate the board skeleton shape',
|
|
||||||
approvedBy: 'Florian Sylvain',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Empty: {
|
Empty: {
|
||||||
path: '/article',
|
path: '/article',
|
||||||
now: articlesNow,
|
now: articlesNow,
|
||||||
api: articlesApi({ 'GET /v1/article/': articleListMock([]) }),
|
api: articlesApi({ 'GET /v1/article/': articleListMock([]) }),
|
||||||
exception: {
|
|
||||||
maxPercent: 3.6,
|
|
||||||
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the empty layout',
|
|
||||||
approvedBy: 'Florian Sylvain',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'No results': {
|
'No results': {
|
||||||
path: '/article',
|
path: '/article',
|
||||||
@@ -6429,11 +6513,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
await page.getByPlaceholder('Search by title').fill('tiles')
|
await page.getByPlaceholder('Search by title').fill('tiles')
|
||||||
await page.waitForTimeout(200)
|
await page.waitForTimeout(200)
|
||||||
},
|
},
|
||||||
exception: {
|
|
||||||
maxPercent: 3.3,
|
|
||||||
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-results layout',
|
|
||||||
approvedBy: 'Florian Sylvain',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'No drafts': {
|
'No drafts': {
|
||||||
path: '/article',
|
path: '/article',
|
||||||
@@ -6449,11 +6528,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
await page.getByRole('tab', { name: /^Drafts/ }).focus()
|
await page.getByRole('tab', { name: /^Drafts/ }).focus()
|
||||||
await page.keyboard.press('Enter')
|
await page.keyboard.press('Enter')
|
||||||
},
|
},
|
||||||
exception: {
|
|
||||||
maxPercent: 2.7,
|
|
||||||
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-drafts layout',
|
|
||||||
approvedBy: 'Florian Sylvain',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'Action in progress': {
|
'Action in progress': {
|
||||||
path: '/article',
|
path: '/article',
|
||||||
@@ -6465,36 +6539,8 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
await openArticleRowMenu(page, 'Winter checklist for timber windows')
|
await openArticleRowMenu(page, 'Winter checklist for timber windows')
|
||||||
await chooseArticleRowMenuItem(page, 'Publish')
|
await chooseArticleRowMenuItem(page, 'Publish')
|
||||||
},
|
},
|
||||||
exception: {
|
|
||||||
maxPercent: 3.6,
|
|
||||||
reason: 'Same UTable column-boundary offset as Default, plus the busy badge/spinner rendering',
|
|
||||||
approvedBy: 'Florian Sylvain',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Published: {
|
|
||||||
path: '/article',
|
|
||||||
now: articlesNow,
|
|
||||||
api: articlesApi({
|
|
||||||
'PATCH /v1/article/4/publish': {
|
|
||||||
status: 200,
|
|
||||||
body: {
|
|
||||||
...articlesFixture()[3],
|
|
||||||
is_online: true,
|
|
||||||
updated_at: articlesNow,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
steps: async (page) => {
|
|
||||||
await openArticleRowMenu(page, 'Winter checklist for timber windows')
|
|
||||||
await chooseArticleRowMenuItem(page, 'Publish')
|
|
||||||
await page.getByText('Article published').first().waitFor()
|
|
||||||
},
|
|
||||||
exception: {
|
|
||||||
maxPercent: 4.6,
|
|
||||||
reason: 'Same UTable column-boundary offset as Default, plus the success toast rendering',
|
|
||||||
approvedBy: 'Florian Sylvain',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
Published: clockSensitiveArticles('/article'),
|
||||||
'Action failed': {
|
'Action failed': {
|
||||||
path: '/article',
|
path: '/article',
|
||||||
now: articlesNow,
|
now: articlesNow,
|
||||||
@@ -6504,11 +6550,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
await chooseArticleRowMenuItem(page, 'Unpublish')
|
await chooseArticleRowMenuItem(page, 'Unpublish')
|
||||||
await page.getByText("Couldn't unpublish").first().waitFor()
|
await page.getByText("Couldn't unpublish").first().waitFor()
|
||||||
},
|
},
|
||||||
exception: {
|
|
||||||
maxPercent: 5.7,
|
|
||||||
reason: 'Same UTable column-boundary offset as Default, plus the error toast with Retry rendering',
|
|
||||||
approvedBy: 'Florian Sylvain',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'Row vanished (404)': {
|
'Row vanished (404)': {
|
||||||
path: '/article',
|
path: '/article',
|
||||||
@@ -6519,11 +6560,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
await chooseArticleRowMenuItem(page, 'Publish')
|
await chooseArticleRowMenuItem(page, 'Publish')
|
||||||
await page.getByText('That article no longer exists').first().waitFor()
|
await page.getByText('That article no longer exists').first().waitFor()
|
||||||
},
|
},
|
||||||
exception: {
|
|
||||||
maxPercent: 4.3,
|
|
||||||
reason: 'Same UTable column-boundary offset as Default, plus the info toast after row removal',
|
|
||||||
approvedBy: 'Florian Sylvain',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'Delete: confirm': {
|
'Delete: confirm': {
|
||||||
path: '/article',
|
path: '/article',
|
||||||
|
|||||||
Reference in new issue
Block a user