feat(SPA): screens for Sites and Authors

This commit is contained in:
Florian Sylvain committed 2026-10-14 23:00:50 +02:00
1 parent b34a173d83
commit 3d81d2c717
82 files changed
+10676 -37

No files matched your search

+634
View File
@@ -0,0 +1,634 @@
Feature: Sites and Authors screens
As a RenewCMS owner or admin
I want /settings/sites and /authors to list, add, edit and delete sites and authors
So that every article belongs to a frontend and credits the right bylines
Background:
Given /settings/sites is a verified route rendered as SitesView and /authors is a verified route
rendered as AuthorsView, both inside DashboardShell, per docs/design/routes.md
And both screens are built from the shared directory pieces, defined once and not redrawn:
DirectoryList, DirectoryPanel (the slide-over), ReassignDialog, SlugField and MediaField
And the screens call, with relative URLs through useApi only, GET, POST, PATCH and DELETE on
/v1/site/ and /v1/author/, PATCH /v1/site/{id}/default and GET /v1/user/
And POST /v1/author/me is not exposed in the UI by this slice
And a site has the fields id, name, key, base_url, default_social_image_id, default_robots
("index" or "noindex"), locale, is_default and article_count
And an author has the fields id, name, slug, bio, avatar_id, website, social_links, user_id,
is_linked, article_count and, for owner and admin only, user_email
And an error answered by the API is a plain-text code, and the SPA maps the code to the copy
of docs/design/states.md, never showing the code itself
And the sites fixture is, in list order:
| name | key | base_url | articles | default |
| Renew | renew | https://renew.example | 268 | yes |
| Journal | journal | https://journal.example | 38 | no |
| Projects | projects | https://projects.example | 6 | no |
| Archive | archive | https://archive.example | 0 | no |
And the authors fixture includes "Inès Duarte" (slug ines-duarte, linked, 9 articles),
"Camille Roche" (slug camille-roche, guest, 3 articles), "Léa Fontaine" (slug lea-fontaine,
guest, 0 articles) and one author with 1 article
And every visible string goes through t(...) with the English text as the key and its French
entry in web/src/i18n/messages.ts
And the screens use Nuxt UI components and design tokens only, with no raw colors
And the board frames of docs/design/source/DirectoryFrame.dc.html are the layout tiebreaker
Rule: Sites list
Scenario: Sites: default (owner)
Given the session role is "owner"
And GET /v1/site/ answers 200 with the sites fixture
When the user navigates to /settings/sites
Then the page shows "Sites", "The frontends that display your content." and a primary
"Add site" button
And the columns are "Site", "Base URL" and "Articles"
And each row shows the name with its key and the base URL
And the counts read "268 articles", "38 articles", "6 articles" and "No articles"
And "Renew" carries a "Default" badge
And a site whose default_robots is "noindex" carries a "Noindex" badge
And below md the rows are cards and a full-width "Add site" button is pinned to the bottom
Scenario: Sites: only the default site
Given GET /v1/site/ answers 200 with only the default site
When the owner navigates to /settings/sites
Then the row is listed and the hint "Add another site if your content is shown on more than one
frontend. Each article belongs to one site." appears under the list
And no empty-state wall is drawn
Scenario: Sites: loading
Given GET /v1/site/ is pending
When the owner navigates to /settings/sites
Then no skeleton shows before 150 ms
And after 150 ms skeleton rows are drawn and the list has aria-busy "true"
Scenario Outline: Sites: error on load
Given GET /v1/site/ fails with <failure>
When the owner navigates to /settings/sites
Then an alert shows "Couldn't load sites", "Check your connection and try again." and a
"Try again" action
And no rows are drawn
When the user activates "Try again"
Then GET /v1/site/ is requested again and, on 200, the list replaces the alert
Examples:
| failure |
| status 500 |
| a network error |
Scenario: Sites: row menu
Given the sites list is shown
When the user activates the ellipsis button of "Journal", or presses Enter on it
Then a menu offers "Edit", "Set as default" and "Delete…"
And below md the menu is a bottom sheet titled "Journal"
Scenario: Sites: delete unavailable on the default site
Given the sites list is shown
When the user opens the menu of "Renew"
Then "Delete…" has aria-disabled "true" and stays focusable
And a tooltip on desktop, and text in the sheet on touch, reads "The default site can't be
deleted. Make another site the default first."
And activating it opens no dialog and sends no request
Scenario: Sites: set as default, confirm and success
Given the sites list is shown
When the user chooses "Set as default" on "Journal"
Then a dialog titled "Make Journal the default site?" reads "New articles start on this site.
Articles that are already on other sites stay where they are." with "Cancel" and
"Make default"
When the user activates "Make default"
Then exactly one PATCH /v1/site/2/default is sent with no body
And the button reads "Saving…" while it runs
And on 200 the "Default" badge moves to "Journal" and a toast shows "Default site changed" and
"\"Journal\" is now the default site."
Scenario Outline: Sites: set as default, failures
Given the "Make Journal the default site?" dialog is open
When PATCH /v1/site/2/default answers <status>
Then <outcome>
Examples:
| status | outcome |
| 500 | the dialog stays open with the notice "Couldn't save the site. Your changes are still here. Try again in a moment." |
| 404 | the dialog closes, the row leaves the list and an info toast shows "That site no longer exists" and "It was deleted somewhere else, so we removed it from the list." |
| 403 | the dialog closes and an error toast shows "Only the owner can manage sites" and "Ask the owner of this site." |
Rule: Sites access
Scenario: Sites: hidden entry for an admin
Given the session role is "admin"
When the user opens /settings
Then the sidebar has no "Publishing" group and no "Sites" entry
And the page has no "Publishing" card and no "Manage sites" row
Scenario: Sites: the owner sees the entry
Given the session role is "owner"
When the user opens /settings
Then the sidebar has a "Publishing" group with a "Sites" entry linking to /settings/sites
And Settings has a "Publishing" card with a "Manage sites" row linking to /settings/sites
Scenario Outline: Sites: forbidden (403 page)
Given the session role is "<role>"
When the user opens <path>
Then the page shows "You don't have access to Sites", "Only the owner can manage sites. Ask the
owner of this site if you need a change." and a "Back to Settings" button
And the user is not redirected, no slide-over opens and the sidebar has no Sites entry
When the user activates "Back to Settings"
Then the user lands on /settings
Examples:
| role | path |
| admin | /settings/sites |
| member | /settings/sites |
| admin | /settings/sites?edit=journal |
Scenario: Sites: the API answers 403 where the SPA expected the owner
Given the session role is "owner" but the API answers 403 "Only the owner can do this."
When the answer is for a page load
Then the 403 page shows
When the answer is for an action
Then an error toast shows "Only the owner can manage sites" and "Ask the owner of this site."
Rule: Sites form
Scenario: Sites form: add, empty
Given the sites list is shown and the default site is "Renew"
When the user activates "Add site" (the page header button, or the pinned bottom button below md)
Then a slide-over titled "Add site" opens, a 92 % bottom sheet below md
And it has the fields Name (placeholder "e.g. Journal"), Key (placeholder "Generated from the
name"), Base URL (placeholder "https://blog.example.com", help "The public address of the
frontend. Use https and no path."), "Default social image (optional)" with "Choose image"
and "Used when an article has no image of its own.", "Default robots policy" with "Index"
and "Noindex" and "Search engines may list this site's pages.", "Locale (optional)"
(placeholder "e.g. en or fr-CH", help "A language tag. Leave empty if the site has no
single language."), and the switch "Make this the default site" with "New articles start
here. The current default is Renew."
And a "Canonical URL preview" is shown, plus "Cancel" and "Add site"
And Name has focus
Scenario: Sites form: key from the name
Given the Add site slide-over is open
When the user types "Journal" in Name
Then Key shows "journal" with an "Auto" badge and the help "Lowercase letters, numbers and
hyphens. Used in API paths. It can't be changed after the site is added."
When the user activates the pencil
Then Key becomes editable and no longer follows the name
Scenario Outline: Sites form: base URL not valid
Given the Add site slide-over is open with a valid name and key
When the user enters base URL "<value>" and leaves the field or submits
Then no request is sent
And the Base URL error reads "<message>"
Examples:
| value | message |
| http://blog.example.com | Use https. For example https://blog.example.com. |
| https://blog.example.com/en | Remove the path. Use only the host, like https://blog.example.com. |
| blog | Enter a full URL, like https://blog.example.com. |
Scenario: Sites form: base URL refused by the server
Given the form passed the client checks
When POST or PATCH answers 400 invalid_site_url
Then Base URL shows "Enter a full URL, like https://blog.example.com."
Scenario Outline: Sites form: loopback hosts accept http
Given the Add site slide-over is open
When the user enters base URL "<value>"
Then no base URL error is shown
Examples:
| value |
| http://localhost:5173 |
| http://127.0.0.1:8081 |
| http://[::1]:5173 |
Scenario: Sites form: add succeeds
Given the Add site slide-over is filled with name "Journal", key "journal", base URL
"https://journal.example", robots "Noindex", empty locale and the default switch off
When the user activates "Add site"
Then exactly one POST /v1/site/ is sent with name, key, base_url, default_robots "noindex",
no locale, no default_social_image_id and is_default false
And the button reads "Saving…" while the request runs and the fields are disabled
And on 201 the slide-over closes and the new site is listed
Scenario: Sites form: duplicate key
Given the Add site slide-over has key "journal" and a site with that key is in the loaded list
When the user leaves Key, or submits and POST /v1/site/ answers 409 site_key_taken
Then Key shows "A site with the key \"journal\" already exists. Choose another key." and a
suggestion "Use journal-2"
And activating the suggestion sets Key to "journal-2"
Scenario Outline: Sites form: save failed
Given the Add site slide-over is filled
When the save fails with <failure>
Then a notice in the panel reads "Couldn't save the site. Your changes are still here. Try again in a moment."
And all the values are kept and the form is enabled again
Examples:
| failure |
| status 500 |
| a network error |
Scenario: Sites form: invalid locale from the server
Given the form has locale "english"
When the save answers 400 invalid_locale
Then Locale shows "Use a language tag like en or fr-CH." and the panel stays open
Scenario: Sites form: edit the default site
When the owner chooses "Edit" on "Renew"
Then a slide-over titled "Edit Renew" opens with the fields filled
And Key shows "renew" read-only with a lock and "Used in API paths, so it can't change."
And "Default site" is on and locked with "New articles start on the default site. To change it,
make another site the default."
And the submit reads "Save changes"
Scenario: Sites form: edit another site, noindex
When the owner chooses "Edit" on "Projects" whose robots is "noindex"
Then the slide-over is titled "Edit Projects", Key is read-only and locked, "Noindex" is
selected with "Asks search engines not to list this site's pages." and the switch
"Make this the default site" is available
When the user changes the name and activates "Save changes"
Then exactly one PATCH /v1/site/3 is sent without the key field
Scenario: Sites form: make another site the default from the form
Given the "Projects" edit form has the default switch turned on
When the user activates "Save changes"
Then the SPA sends PATCH /v1/site/3 for the changed fields, then PATCH /v1/site/3/default
And is_default is never sent in the first PATCH
Scenario: Sites form: discard changes
Given a slide-over is open with a changed field
When the user closes it with "Cancel", Escape or the backdrop
Then the existing "Discard changes?" dialog is asked
And an unchanged form closes without it
Scenario: Sites form: deep link
Given the session role is "owner"
When the user opens /settings/sites?edit=projects
Then the "Edit Projects" slide-over opens
When the user opens /settings/sites?edit=nope
Then an info toast shows "That site no longer exists" and the slide-over stays closed
Rule: Sites delete
Scenario: Sites delete: site with articles
Given the sites list is shown
When the owner chooses "Delete…" on "Journal"
Then a dialog titled "Delete this site?" reads "\"Journal\" will be deleted. 38 articles belong
to it, so choose a site to move them to. This can't be undone."
And "Move articles to" defaults to the default site "Renew" shown with "renew.example"
And the text "38 articles will move to Renew. Their canonical host changes to renew.example."
is shown, with "Cancel" and "Move 38 articles and delete"
Scenario: Sites delete: choose destination
Given the "Delete this site?" dialog is open for "Journal"
When the user opens the select
Then "Journal" is not offered and each option shows the name and the host
Scenario: Sites delete: with articles succeeds
Given the dialog is open for "Journal" (id 2) with destination "Renew" (id 1)
When the user activates "Move 38 articles and delete"
Then exactly one DELETE /v1/site/2?reassign_to=1 is sent
And the button reads "Moving articles…" while it runs
And on 204 the dialog closes, the row leaves the list and a toast shows "Site deleted" and
"38 articles moved to Renew."
Scenario: Sites delete: no articles
When the owner chooses "Delete…" on "Archive"
Then a dialog titled "Delete this site?" reads "\"Archive\" will be deleted. No articles belong
to it. This can't be undone." with "Cancel" and "Delete site" and no select
When the user activates "Delete site"
Then DELETE /v1/site/4 is sent with no reassign_to and, on 204, the row leaves the list
Scenario Outline: Sites delete: failures
Given the delete dialog is open for "Journal"
When DELETE answers <status>
Then <outcome>
Examples:
| status | outcome |
| 500 or network | the dialog stays open with the notice titled "Couldn't delete the site" and text "Nothing was changed. Try again in a moment." |
| 409 site_has_articles | the dialog stays open and shows "Choose where the articles go. This site has 38 articles." |
| 403 default_site | the dialog closes and an error toast shows "The default site can't be deleted. Make another site the default first." |
| 404 | the dialog closes, the row leaves the list and the info toast "That site no longer exists" shows |
Rule: Authors list
Scenario Outline: Authors: default
Given the session role is "<role>"
And GET /v1/author/ answers 200 with the authors fixture
When the user navigates to /authors
Then the sidebar entry "Authors" sits between "Media" and "Users" and carries aria-current "page"
And the page shows "Authors", "The names readers see on your articles. An author doesn't need
an account.", a primary "Add author" button and the search field "Search by name or slug"
And the columns are "Author", "Slug", "Account" and "Articles"
And each row shows the avatar and name, the slug, a "Linked" or "Guest" badge, and
"9 articles", "1 article" or "No articles"
And a long name is truncated and shown in full on hover
And the first request is GET /v1/author/?page=1&size=25
Examples:
| role |
| owner |
| admin |
Scenario: Authors: loading
Given GET /v1/author/ is pending
When the user navigates to /authors
Then skeleton rows are drawn and the list has aria-busy "true"
Scenario Outline: Authors: error on load
Given GET /v1/author/ fails with <failure>
When the user navigates to /authors
Then an alert shows "Couldn't load authors", "Check your connection and try again." and "Try again"
When the user activates "Try again"
Then the list is requested again
Examples:
| failure |
| status 500 |
| a network error |
Scenario Outline: Authors: empty, first run
Given GET /v1/author/ answers 200 with total 0 and no search
And the session role is "<role>"
When the user navigates to /authors
Then the toolbar is hidden and the empty state shows "No authors yet" and "Authors are the names
readers see on articles. They don't need an account."
And <button>
Examples:
| role | button |
| owner | an "Add author" button is shown |
| admin | an "Add author" button is shown |
| member | no "Add author" button is shown |
Scenario: Authors: no results
Given the user has searched "zoe" and GET /v1/author/?q=zoe answers 200 with total 0
Then the state shows "No authors match \"zoe\"", "Check the spelling, or search for a shorter
part of the name." and "Clear search"
When the user activates "Clear search"
Then q leaves the URL and the full list is requested
Scenario: Authors: search and pagination in the URL
When the user types "cam" in the search field
Then after the debounce GET /v1/author/?q=cam&page=1&size=25 is sent and the URL holds q=cam
When the user opens /authors?q=camille&page=2&size=50
Then GET /v1/author/?q=camille&page=2&size=50 is sent
And the Articles paginator is shown only when total exceeds 25, with page sizes 5, 25, 50, 100
Scenario Outline: Authors: invalid page or size in the URL
When the user opens /authors?<query>
Then the invalid value is replaced by its default with no notice and the API 400 never reaches the user
Examples:
| query |
| size=30 |
| page=abc |
| page=0 |
Scenario: Authors: row menu (owner or admin)
When the user activates the ellipsis of "Camille Roche" (a guest)
Then a menu offers "Edit", "Link user" and "Delete…"
And for "Inès Duarte" (linked) the menu offers "Edit" and "Delete…"
And below md the menu is a bottom sheet
Scenario: Authors: read-only list (other roles)
Given the session role is "member"
When the user navigates to /authors
Then the description reads "The names readers see on your articles. Only an owner or admin can
change authors."
And there is no "Add author" button, no pinned bottom button, no row menu and no slide-over form
And search still works and each row shows a chevron
And the "Authors" sidebar entry is visible
Scenario: Authors: read-only detail
Given the session role is "member"
When the user opens the row "Inès Duarte"
Then a panel of the same width as the form shows "Inès Duarte", "Linked", "9 articles", "Bio",
"Website" and "Only an owner or admin can change this author."
And no field is editable, the account email is not shown and "Close" is the only action
When the user opens /authors?edit=ines-duarte
Then the same read-only detail opens
Rule: Authors form
Scenario: Authors form: add, empty
Given the session role is "owner" or "admin"
When the user activates "Add author"
Then a slide-over titled "Add author" opens, a 92 % bottom sheet below md
And it shows "Avatar (optional)" with "Choose image", Name (placeholder "e.g. Camille Roche"),
Slug (placeholder "Generated from the name"), "Bio (optional)" (placeholder "A sentence or two
about the author.") with the counter "0 / 280", "Website (optional)" (placeholder
"https://example.com"), "Social links (optional)" with "Add link" and "0 of 5", "Linked user
(optional)" showing "No linked user" with "Link a CMS user if this author has an account.
Guest writers need no link.", and a "Byline preview" ("Author name", "The bio appears here.")
And the buttons are "Cancel" and "Add author"
Scenario: Authors form: slug from the name
When the user types "Camille Roche" in Name
Then Slug shows "camille-roche" with an "Auto" badge, an "Edit slug" pencil and the help
"Generated from the name. Used in public author URLs and API paths. Unique across all authors."
And the byline preview shows the typed name
Scenario: Authors form: add succeeds
Given the form has name "Camille Roche", the automatic slug, a bio, a website and one link
When the user activates "Add author"
Then exactly one POST /v1/author/ is sent with name, slug "camille-roche", bio, avatar_id,
website, social_links [{type,url}] and user_id
And the button reads "Saving…" while it runs
And on 201 the slide-over closes and the author is listed
Scenario: Authors form: slug collision
Given the form has slug "camille-roche" and POST answers 409 author_slug_taken
Then Slug shows "The slug \"camille-roche\" is already used by another author.", a suggestion
"Use camille-roche-2" and "Reset to match the name"
And activating a suggestion rewrites the slug and keeps the other values
Scenario Outline: Authors form: validation
Given the Add author slide-over is open
When the user submits with <input>
Then no request is sent and <message> is shown under <field>
Examples:
| input | field | message |
| an empty name | Name | Enter a name. |
| a bio of 291 characters | Bio | Shorten the bio to 280 characters. with the counter "291 / 280" in error color |
| website "example.com" | Website | Enter a full URL starting with https://. |
| a social link with url "http://x" | its row | Enter a full URL starting with https://. |
Scenario Outline: Authors form: server validation codes
When the save answers <status> <code>
Then <message> is shown under <field>
Examples:
| status | code | field | message |
| 422 | bio_too_long | Bio | Shorten the bio to 280 characters. |
| 422 | invalid_url | the field | Enter a full URL starting with https://. |
| 409 | user_already_linked | Linked user | That user already has an author. Choose another user. |
Scenario: Authors form: social links
When the user activates "Add link"
Then a row with a type select (X, LinkedIn, GitHub, Mastodon, Custom) and a URL field is added
And the placeholders per type are "https://x.com/username", "https://www.linkedin.com/in/username",
"https://github.com/username", "https://mastodon.social/@username" and "https://example.com"
And an invalid link shows "Enter a full URL starting with https://."
And after the fifth row "Add link" is disabled and "You can add up to 5 links." is shown
And the links are sent as social_links with the lowercase types x, linkedin, github, mastodon, custom
Scenario: Authors form: linked user, choose
Given the options are the users of GET /v1/user/ minus every user_id found by paging
GET /v1/author/?size=100
When the user opens the Linked user select
Then under the title "Choose a user" the group "Users without an author" lists "Marta Alves"
with "marta@renew.example" and "Rui Santos" with "rui@renew.example"
And when no user is left it reads "Every user already has an author."
Scenario: Authors form: edit a linked author
When the user chooses "Edit" on "Inès Duarte"
Then the slide-over is titled "Edit Inès Duarte" with the fields filled
And Linked user shows "ines.duarte@renew.example" with "Unlink" and "New articles by this user
start with this author."
And the submit reads "Save changes"
And saving sends one PATCH /v1/author/{id} with only the changed fields
Scenario: Authors form: unlink
Given the edit form of "Inès Duarte" is open (owner or admin)
When the user activates "Unlink"
Then a dialog titled "Unlink Inès Duarte?" reads "Inès Duarte will stay on their articles. New
articles by ines.duarte@renew.example will no longer start with this author." with "Cancel"
and "Unlink user"
When the user activates "Unlink user"
Then exactly one PATCH /v1/author/{id} is sent with {"user_id": null}
And on 200 the field returns to "No linked user" and a toast shows "User unlinked" and
"Inès Duarte is now a guest author."
Scenario: Authors form: link a guest
When the user chooses "Link user" on "Camille Roche", in the row menu or the form
Then a dialog titled "Link Camille Roche to a user?" reads "Only users without an author are
listed." with the same user list, "Cancel" and "Link user"
When the user picks a user and activates "Link user"
Then exactly one PATCH /v1/author/{id} is sent with that user_id and the row shows "Linked"
Scenario: Authors form: avatar picker
When the user activates "Choose image"
Then the existing media picker opens titled "Choose an image" with "The avatar for Camille Roche."
and "Shown as a square. Choose an image with the face or logo near the middle." and "Use image"
When the user picks an image and activates "Use image"
Then the avatar shows as a square and avatar_id is sent on save
And the Sites default social image uses this same picker and sends default_social_image_id
Scenario Outline: Authors form: save failed
When the save fails with <failure>
Then a notice in the panel reads "Couldn't save the author. Your changes are still here. Try again in a moment."
And the values are kept
Examples:
| failure |
| status 500 |
| a network error |
Scenario: Authors form: role changed while open
When the save answers 403 author_rights
Then an error toast shows "Only an owner or admin can change authors" and "You can still edit
your own author profile."
Scenario: Authors form: deep link
When an owner or admin opens /authors?edit=camille-roche
Then the "Edit Camille Roche" slide-over opens
When the slug is unknown
Then an info toast shows "That author no longer exists" and nothing opens
Scenario: Authors form: own author, any role, by deep link only
Given the session role is "member" and GET /v1/auth/me carries an author with slug "ines-duarte"
When the user opens /authors?edit=ines-duarte
Then the form opens with the linked user locked, no "Unlink", no "Link user" and no delete action
And saving sends PATCH /v1/author/{id} with only the changed fields
When the same member opens /authors?edit=camille-roche
Then the read-only detail opens
Rule: Authors delete
Scenario: Authors delete: reassign (recommended)
When an owner or admin chooses "Delete…" on "Camille Roche" (id 7, 3 articles)
Then a dialog titled "Delete this author?" reads "\"Camille Roche\" will be deleted and is
credited on 3 articles. This can't be undone."
And "Credit these articles to" is preselected on the first other author, with "Where an article
has several authors, Camille Roche is removed and the others stay."
And the options come from GET /v1/author/?size=100 without the deleted author
And the buttons are "Cancel" and "Delete and reassign 3 articles"
And for a linked author the line "Linked to ines.duarte@renew.example. The user account isn't
affected." is added
Scenario: Authors delete: reassign succeeds
Given the dialog is open with destination "Inès Duarte" (id 2)
When the user activates "Delete and reassign 3 articles"
Then exactly one DELETE /v1/author/7?reassign_to=2 is sent
And the button reads "Reassigning…" while it runs
And on 204 the row leaves the list and a toast shows "Author deleted" and "3 articles now credit
Inès Duarte."
Scenario: Authors delete: choose who
When the user opens the select
Then the deleted author is not listed and the last option is "Nobody" with "Remove the byline"
Scenario: Authors delete: nobody
When the user chooses "Nobody"
Then the text "The articles keep any other authors. Articles with only this author will have no
byline." is shown
When the user confirms
Then DELETE /v1/author/7?reassign_to=none is sent
Scenario Outline: Authors delete: failed (notice shared by site and author delete)
Given an author delete dialog is open
When DELETE fails with <failure>
Then the dialog stays open with "Couldn't delete the author" and "Nothing was changed. Try again in a moment."
And the buttons are enabled again
Examples:
| failure |
| status 500 |
| a network error |
Scenario: Authors delete: server fallback (409)
When DELETE answers 409 author_has_articles
Then a dialog titled "Can't delete this author" reads "\"Camille Roche\" is credited on 3
articles. Choose another author to credit instead, or remove Camille Roche from those
articles first."
And its buttons are "Close" and "Reassign articles"
And "Reassign articles" reopens the reassign dialog
Scenario: Authors delete: no articles
When the user chooses "Delete…" on "Léa Fontaine"
Then a dialog titled "Delete this author?" reads "\"Léa Fontaine\" will be deleted. No articles
credit this author. This can't be undone." with "Cancel" and "Delete author" and no select
When the user confirms
Then DELETE /v1/author/{id} is sent with no reassign_to and the row leaves the list
Scenario: Authors delete: author already gone
When DELETE answers 404 author_not_found
Then the dialog closes, the row leaves the list and the info toast "That author no longer
exists" shows
Rule: Cross-cutting
Scenario Outline: Session problems use the app-wide handling
Given a load of <path> answers <status>
Then <outcome>
Examples:
| path | status | outcome |
| /authors | 401 | the user lands on /login?redirect=/authors&reason=expired |
| /settings/sites | 401 | the user lands on /login?redirect=/settings/sites&reason=expired |
| /authors | 403 (unverified) | the user lands on /register/pending |
Scenario: Slide-over accessibility
When a slide-over or dialog is open
Then focus moves into it, is trapped, and returns to the opening control on close
And Escape closes it unless a request is running
Scenario: Language: every string has a French entry
Given the language is "fr"
When the user opens each state of both screens
Then every visible string is the French message and none falls back to English
Scenario: Existing screens are unchanged
Then the Editor, Articles and Profile views and their specs are not modified by this slice
+117
View File
@@ -0,0 +1,117 @@
# QA: Sites and Authors screens
SPA slice only. The Go API already exists (commits "Sites feature backend" and "Authors feature backend"), so no new Go test is requested. If a contract below turns out wrong, report it as a backend defect.
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` per case for GET/POST/PATCH/DELETE on /v1/site/ and /v1/author/, PATCH /v1/site/{id}/default and GET /v1/user/. Mount SitesView and AuthorsView inside DashboardShell with a resolved session and a test router. Roles: "owner" (Marta Alves), "admin", and "member". Mock matchMedia for desktop and below-md. Do not freeze the system date. Use fake timers only for the 150 ms skeleton delay and the search debounce. Un-run web tests are NOT RUN, never passing.
Shared pieces get their own specs, reused by both screens (`DirectoryList`, `DirectoryPanel`, `ReassignDialog`, `SlugField`, `MediaField`):
1. **DirectoryList** - rows, truncation, read-only chevron variant, below-md cards, loading (skeleton after 150 ms, aria-busy), error alert with "Try again".
2. **DirectoryPanel** - desktop slide-over and 92 % bottom sheet below md, focus trap and return, "Discard changes?" on a dirty close, Escape ignored while sending.
3. **SlugField** - auto from the name, "Auto" badge, pencil, "Reset to match the name", suggestion, locked variant with lock and "Used in API paths, so it can't change."
4. **MediaField** - opens the existing picker, square avatar variant, "Use image" returns the id.
5. **ReassignDialog** - select excludes the deleted item, default selection, count in the button, in-flight label, failed notice, 409 notice.
Sites (SitesView), one test per scenario in the feature:
6. Default list: columns, badges "Default" and "Noindex", counts including "No articles", mobile cards and pinned "Add site".
7. Only the default site hint; no empty-state wall.
8. Loading: nothing before 150 ms, skeleton after.
9. Error Examples-driven (500, network) and "Try again".
10. Row menu and the bottom-sheet title; Default site: "Delete…" aria-disabled, focusable, tooltip text, no request on activate.
11. Set as default: dialog copy, one PATCH /v1/site/2/default, "Saving…", badge moves, toast; failures 500/404/403.
12. Hidden entry (admin) and the owner's entry: sidebar Publishing group, Sites entry, Settings "Publishing" card and "Manage sites" row. Extend the existing `SettingsView.spec.ts` and the shell navigation spec for role-based visibility.
13. 403 page for admin and member on /settings/sites and on ?edit=journal; "Back to Settings"; no redirect; the API-403 page load versus action toast.
14. Form: Add empty (every label, placeholder, help); key from the name and pencil; URL validation Examples (3 messages, no request); server invalid_site_url; loopback http accepted; duplicate key (blur against the loaded list and 409) with the suggestion; save failed Examples; invalid_locale; Edit default site (lock, switch on and locked); Edit other site, noindex; PATCH has no key; default switch sends the two calls in order; discard; deep link and unknown key toast.
15. Delete: with articles (copy, default destination, one DELETE with reassign_to=1, "Moving articles…", toast); destination options (no deleted site, name and host); no articles (no select, no reassign_to); failures: 500/network with title "Couldn't delete the site" and "Nothing was changed. Try again in a moment.", 409, 403 default_site, 404.
Authors (AuthorsView):
16. Default list for owner and admin: columns, badges, "9 articles"/"1 article"/"No articles", first request `?page=1&size=25`; sidebar entry order and aria-current.
17. Loading, error Examples, empty (owner, admin with button; member without), no results and "Clear search".
18. Search and pagination: debounce, URL q/page/size, paginator above 25, invalid page/size replaced silently.
19. Row menu: guest has Edit, Link user, Delete…; linked has Edit, Delete…; mobile sheet.
20. Read-only list and detail for member (no button, no menu, no bottom button, search works, no email); `?edit=` opens the read-only detail.
21. Form: Add empty copy; slug from the name; add succeeds with the exact POST body; slug collision and suggestions; validation Examples; server codes Examples (bio_too_long, invalid_url, user_already_linked); social links (types, placeholders, limit of 5, bad link); linked user options computed from GET /v1/user/ minus user_ids paged from GET /v1/author/?size=100, and "Every user already has an author."; edit linked; unlink (PATCH user_id null, toast); re-link; avatar picker; save failed Examples; 403 author_rights toast; deep link and unknown slug toast.
22. Own author by deep link only: member opens `/authors?edit=<own slug>` (own author from GET /v1/auth/me), form with locked link and no delete, PATCH on save; another slug opens the read-only detail. No test calls POST /v1/author/me, because the UI does not expose it in this slice.
23. Delete: reassign (copy, linked line, first other author preselected, one DELETE ?reassign_to=2, "Reassigning…", toast); choose who (no deleted author, "Nobody" last); nobody sends `reassign_to=none`; failed Examples; 409 fallback dialog and "Reassign articles"; no articles; 404.
24. 401 and 403 app-wide redirects on both routes.
25. French: `setLocale('fr')`, walk the list, form, delete and 403 page of each screen, no English source string remains.
26. Messages: every new `t()` key has a French entry (extend the i18n spec). Router: /settings/sites and /authors are registered and verified.
27. Regression: the existing Editor, Articles, Profile specs pass unchanged.
## Visual (`task visual:check -- <screen>`, desktop and mobile, light and dark)
Reference: docs/design/screens/sites.md and authors.md, board frames in `docs/design/source/DirectoryFrame.dc.html` (list, panel, dialogs) and `AppFrame.dc.html` (sidebar, Settings card). Add each state to `web/visual/states.ts` as the groups `sites`, `sites-form`, `sites-delete`, `authors`, `authors-form`, `authors-delete`, with mocked /v1 calls and the board's own sample data. Hard gate: every capture within 10 %. No state is clock-sensitive, so none gets `skip`. Confirm in the frame script before skipping anything. Sample data: the sites fixture (268, 38, 6 and 0 articles), the authors fixture, Camille Roche (3 articles), Inès Duarte (ines.duarte@renew.example, 9 articles), Léa Fontaine, Marta Alves (marta@renew.example), Rui Santos (rui@renew.example), the 291-character bio for validation, "zoe" for no results. Read the host and row order from the frame.
Sites (9):
- [ ] Default
- [ ] Only the default site
- [ ] Loading (setup holds the request)
- [ ] Error
- [ ] Row menu
- [ ] Default site: delete unavailable
- [ ] Set as default: confirm
- [ ] Hidden entry (admin) (path /settings, admin session)
- [ ] Forbidden (403)
Sites: form (7):
- [ ] Add: empty
- [ ] Add: key from the name
- [ ] Add: URL not valid
- [ ] Add: duplicate key
- [ ] Add: save failed
- [ ] Edit: default site
- [ ] Edit: other site, noindex
Sites: delete (3):
- [ ] With articles
- [ ] Choose destination
- [ ] No articles
Authors (8):
- [ ] Default
- [ ] Loading
- [ ] Error
- [ ] Empty, first run
- [ ] No results
- [ ] Row menu
- [ ] Read-only list (other roles)
- [ ] Read-only detail
Authors: form (11):
- [ ] Add: empty
- [ ] Slug from the name
- [ ] Slug collision
- [ ] Validation
- [ ] Social links
- [ ] Linked user: choose
- [ ] Edit: linked author
- [ ] Avatar: picker
- [ ] Save failed
- [ ] Unlink: confirm
- [ ] Re-link
Authors: delete (6):
- [ ] Reassign (recommended)
- [ ] Choose who
- [ ] Nobody
- [ ] Failed
- [ ] Server fallback (409)
- [ ] No articles
Layout from the frame, not components.md: row height as in Users; slide-over width and the 92 % bottom sheet at 390 px; bottom sheet row menu titled with the site name; pinned mobile "Add site"/"Add author" button.
Not drawn on the board, Vitest only: in-flight labels ("Saving…", "Moving articles…", "Reassigning…"), the 403 and 404 toasts, the 401 redirect, the pagination states of the Authors list, Set-as-default failures, `invalid_locale`, the loopback rule and the discard dialog.
Risk: the sidebar gains "Authors" for every role and "Publishing > Sites" for the owner, and Settings gains a Publishing card for the owner. This can push existing captures of other screens over 10 %. Re-run `task visual:check` for the Settings, Users, Media and API keys screens after the setups pass. A failure needs the user's decision (an `approvedBy` exception or a design update).
Manual checks screenshots cannot cover:
- Keyboard: Tab order through "Add", search, rows and the ellipsis buttons; Enter opens the menu; focus is trapped in the panel and dialogs and returns to the opener; the disabled "Delete…" stays focusable; the select options are reachable by keyboard.
- Hover and motion: truncated name tooltip, row hover, slide-over and sheet motion, skeleton pulse stopped under reduced motion.
## Manual verification (real `task dev`)
- Owner: add a site with the loopback URL http://localhost:5173, set it default, delete a site with articles and check the articles moved. Admin: no Sites entry, a direct link shows the 403 page.
- Authors: owner creates, links and unlinks an author; member sees the read-only list and detail; member with an author opens `/authors?edit=<own slug>` and cannot delete.
## French strings for web/src/i18n/messages.ts
Keyed by the English text, with the English as in the design. `coder` completes them for every string listed in the two screen files. Proposed French for the main ones: Sites = Sites; Add site = Ajouter un site; Add author = Ajouter un auteur; Authors = Auteurs; Default = Par défaut; Noindex = Noindex; Set as default = Définir par défaut; Delete… = Supprimer… ; Edit = Modifier; Link user = Lier un utilisateur; Cancel = Annuler; Save changes = Enregistrer les modifications; Saving… = Enregistrement…; Moving articles… = Déplacement des articles…; Reassigning… = Réattribution…; Guest = Invité; Linked = Lié; Try again = Réessayer; Couldn't load sites = Impossible de charger les sites; Couldn't load authors = Impossible de charger les auteurs; Couldn't delete the site = Impossible de supprimer le site; You don't have access to Sites = Vous n'avez pas accès aux sites; Back to Settings = Retour aux réglages; Nobody = Personne; Unlink user = Dissocier l'utilisateur.
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from 'vue'
import { initialsOf } from '@/lib/directory'
const TONES = ['bg-(--rc-sand)', 'bg-(--ui-bg-accented)', 'bg-(--ui-bg-elevated)'] as const
const props = withDefaults(
defineProps<{
name: string
size?: number
ring?: boolean
}>(),
{ size: 32, ring: false },
)
const tone = computed(() => TONES[props.name.length % TONES.length])
const fontSize = computed(() => `${Math.round(props.size * 0.36)}px`)
</script>
<template>
<UAvatar
:alt="name"
:text="initialsOf(name)"
aria-hidden="true"
:style="{ width: `${size}px`, height: `${size}px`, fontSize }"
:class="[tone, ring ? 'border-2 border-(--ui-bg)' : 'border border-default']"
:ui="{
root: 'shrink-0 rounded-none text-highlighted',
fallback: 'font-semibold leading-none text-highlighted',
}"
/>
</template>
+536
View File
@@ -0,0 +1,536 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, reactive, ref } from 'vue'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DirectoryField from '@/components/DirectoryField.vue'
import DirectoryPanel from '@/components/DirectoryPanel.vue'
import MediaField from '@/components/MediaField.vue'
import SlugField from '@/components/SlugField.vue'
import SocialLinksRepeater from '@/components/SocialLinksRepeater.vue'
import UserSelect from '@/components/UserSelect.vue'
import {
useAuthors,
type Author,
type AuthorPatch,
type SocialLink,
} from '@/composables/useAuthors'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useSlugField } from '@/composables/useSlugField'
import type { MediaItem } from '@/composables/useMedia'
import { useLocalizedOverlay } from '@/i18n/presentation'
import { AUTHOR_SLUG_MAX, characterCount, suggestFreeSlug } from '@/lib/directory'
import { COPY, copy } from '@/lib/directoryCopy'
import { classifyFailure, isSessionError } from '@/lib/directoryErrors'
import {
BIO_MAX,
authorSlugProblem,
bioProblem,
nameProblem,
optionalSecureUrlProblem,
secureUrlProblem,
} from '@/lib/directoryRules'
import type { LinkableUser, DirectoryNotice } from '@/lib/directoryTypes'
import type { SocialLinkRow } from '@/lib/socialLinks'
const props = withDefaults(
defineProps<{
author?: Author
ownProfile?: boolean
ownEmail?: string
knownSlugs?: string[]
}>(),
{ author: undefined, ownProfile: false, ownEmail: '', knownSlugs: () => [] },
)
const emit = defineEmits<{
close: []
saved: [author: Author]
updated: [author: Author]
gone: [id: number]
}>()
const { createAuthor, updateAuthor, listLinkableUsers } = useAuthors()
const toasts = useDirectoryToasts()
const overlay = useLocalizedOverlay()
const editing = computed(() => props.author !== undefined)
const initialLinks: SocialLinkRow[] = (props.author?.social_links ?? []).map(
(link: SocialLink, index) => ({ key: index + 1, type: link.type, url: link.url }),
)
const values = reactive({
name: props.author?.name ?? '',
bio: props.author?.bio ?? '',
website: props.author?.website ?? '',
avatarId: props.author?.avatar_id ?? null,
userId: null as number | null,
})
const links = ref<SocialLinkRow[]>(initialLinks)
const avatarItem = ref<MediaItem | null>(null)
const baseUserId = ref<number | null>(props.author?.user_id ?? null)
const linkedEmail = ref(props.author?.user_email ?? props.ownEmail)
const slug = useSlugField(AUTHOR_SLUG_MAX, props.author?.slug)
slug.setSource(values.name)
const errors = reactive({
name: '',
slug: '',
suggestion: '',
bio: '',
website: '',
user: '',
})
const linkErrors = ref<Record<number, string>>({})
const saving = ref(false)
const notice = ref<DirectoryNotice | null>(null)
const panel = ref<{ focusFirstInvalid: () => void } | null>(null)
const rejectedSlugs = new Set<string>()
const userStatus = ref<'idle' | 'loading' | 'ready' | 'error'>('idle')
const users = ref<LinkableUser[]>([])
const userMenuOpen = ref(false)
function otherFields() {
return JSON.stringify([
values.name,
slug.value.value,
values.bio,
values.website,
values.avatarId,
links.value.map((link) => [link.type, link.url]),
])
}
const initial = otherFields()
const dirty = computed(() => otherFields() !== initial || values.userId !== null)
const bioCount = computed(() => characterCount(values.bio))
const title = computed(() => (props.author ? t('Edit {0}', [props.author.name]) : t('Add author')))
const submitLabel = computed(() => (editing.value ? t('Save changes') : t('Add author')))
const showLinkedBox = computed(() => baseUserId.value !== null && !props.ownProfile)
const picker = computed(() => ({
title: t('Choose an image'),
description: t('The avatar for {0}.', [values.name || t('this author')]),
hint: t('Shown as a square. Choose an image with the face or logo near the middle.'),
confirmLabel: t('Use image'),
}))
function retryUsers() {
userStatus.value = 'idle'
loadUsers()
}
async function loadUsers() {
if (userStatus.value === 'loading' || userStatus.value === 'ready') {
return
}
userStatus.value = 'loading'
try {
users.value = await listLinkableUsers()
userStatus.value = 'ready'
} catch {
userStatus.value = 'error'
}
}
function onName(value: string) {
values.name = value
slug.setSource(value)
errors.name = ''
clearSlugError()
}
function clearSlugError() {
errors.slug = ''
errors.suggestion = ''
}
function onSlug(value: string) {
slug.setValue(value)
clearSlugError()
}
function resetSlug() {
slug.reset()
clearSlugError()
}
function checkName() {
errors.name = nameProblem(values.name)
return errors.name === ''
}
function checkBio() {
errors.bio = bioProblem(values.bio)
return errors.bio === ''
}
function checkWebsite() {
errors.website = optionalSecureUrlProblem(values.website)
return errors.website === ''
}
function checkLink(key: number) {
const row = links.value.find((candidate) => candidate.key === key)
if (!row) {
return true
}
const problem = secureUrlProblem(row.url)
linkErrors.value = { ...linkErrors.value, [key]: problem }
return problem === ''
}
function checkLinks() {
return links.value.map((row) => checkLink(row.key)).every(Boolean)
}
function checkSlug() {
errors.slug = authorSlugProblem(slug.value.value)
return errors.slug === ''
}
function validate() {
const results = [checkName(), checkSlug(), checkBio(), checkWebsite(), checkLinks()]
return results.every(Boolean)
}
function onLinksChange(rows: SocialLinkRow[]) {
links.value = rows
}
function socialPayload(): SocialLink[] {
return links.value.map((row) => ({ type: row.type, url: row.url.trim() }))
}
function changedFields(author: Author) {
const patch: AuthorPatch = {}
if (values.name.trim() !== author.name) {
patch.name = values.name.trim()
}
if (slug.value.value !== author.slug) {
patch.slug = slug.value.value
}
if (values.bio !== author.bio) {
patch.bio = values.bio
}
if (values.avatarId !== author.avatar_id) {
patch.avatar_id = values.avatarId
}
if ((values.website.trim() || null) !== author.website) {
patch.website = values.website.trim() || null
}
if (JSON.stringify(socialPayload()) !== JSON.stringify(author.social_links)) {
patch.social_links = socialPayload()
}
if (values.userId !== null) {
patch.user_id = values.userId
}
return patch
}
async function submit() {
if (saving.value) {
return
}
notice.value = null
if (!validate()) {
panel.value?.focusFirstInvalid()
return
}
const author = props.author
if (author && !dirty.value) {
emit('close')
return
}
saving.value = true
try {
const saved = author
? await updateAuthor(author.id, changedFields(author))
: await createAuthor({
name: values.name.trim(),
slug: slug.value.value,
bio: values.bio,
avatar_id: values.avatarId,
website: values.website.trim() || null,
social_links: socialPayload(),
user_id: values.userId,
})
emit('saved', saved)
} catch (error) {
handleFailure(error)
} finally {
saving.value = false
}
}
function showSlugTaken() {
const taken = slug.value.value
if (slug.mode.value === 'auto') {
slug.unlock()
}
rejectedSlugs.add(taken)
errors.slug = t('The slug "{0}" is already used by another author.', [taken])
errors.suggestion = suggestFreeSlug(
taken,
(candidate) => rejectedSlugs.has(candidate) || props.knownSlugs.includes(candidate),
AUTHOR_SLUG_MAX,
)
}
function showInvalidUrl() {
const first = links.value[0]
if (values.website.trim() || !first) {
errors.website = copy('urlHttps')
return
}
linkErrors.value = { ...linkErrors.value, [first.key]: copy('urlHttps') }
}
function handleFailure(error: unknown) {
const kind = classifyFailure(error, 'author')
if (kind === 'session') {
return
}
if (kind === 'slugTaken') {
showSlugTaken()
return
}
if (kind === 'userLinked') {
errors.user = copy('userLinked')
return
}
if (kind === 'bioTooLong') {
errors.bio = copy('shortenBio')
return
}
if (kind === 'invalidUrl') {
showInvalidUrl()
return
}
if (toasts.report(kind, 'author')) {
if (kind === 'gone' && props.author) {
emit('gone', props.author.id)
}
return
}
notice.value = { description: copy('authorSaveFailed') }
}
async function confirmUnlink() {
const author = props.author
if (!author) {
return
}
const email = linkedEmail.value
await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Unlink {0}?', [author.name])
},
get description() {
return t(
'{0} will stay on their articles. New articles by {1} will no longer start with this author.',
[author.name, email],
)
},
get confirmLabel() {
return t('Unlink user')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Unlinking…')
},
tone: 'neutral',
action: async () => {
try {
const updated = await updateAuthor(author.id, { user_id: null })
baseUserId.value = null
linkedEmail.value = ''
emit('updated', updated)
toasts.userUnlinked(author.name)
} catch (error) {
if (isSessionError(error)) {
return false
}
throw new Error(COPY.nothingChanged)
}
},
},
})
.open()
}
</script>
<template>
<DirectoryPanel
ref="panel"
:title="title"
:submit-label="submitLabel"
:saving="saving"
:dirty="dirty"
:notice="notice"
@close="emit('close')"
@submit="submit"
>
<MediaField
v-model="values.avatarId"
v-model:item="avatarItem"
:label="t('Avatar')"
square
:avatar-name="values.name || 'Author'"
:picker="picker"
/>
<DirectoryField :label="t('Name')" :error="errors.name">
<UInput
:model-value="values.name"
:placeholder="t('e.g. Camille Roche')"
:color="errors.name ? 'error' : undefined"
:data-autofocus="editing ? undefined : ''"
autocomplete="off"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
@update:model-value="onName(String($event))"
@blur="checkName"
/>
</DirectoryField>
<SlugField
:label="t('Slug')"
:mode="slug.mode.value"
:value="slug.value.value"
:error="errors.slug"
:suggestion="errors.suggestion"
:help="
t(
'Generated from the name. Used in public author URLs and API paths. Unique across all authors.',
)
"
:manual-help="t('Lowercase letters, numbers and hyphens. Unique across all authors.')"
@update:value="onSlug"
@unlock="slug.unlock"
@reset="resetSlug"
@suggest="onSlug"
/>
<DirectoryField :label="t('Bio')" optional :error="errors.bio">
<template #hint>
<CharacterCounter :value="bioCount" :max="BIO_MAX" variant="field" />
</template>
<UTextarea
v-model="values.bio"
:placeholder="t('A sentence or two about the author.')"
:color="errors.bio ? 'error' : undefined"
class="w-full"
:ui="{ base: 'min-h-30 text-lg md:min-h-24 md:text-sm' }"
@update:model-value="errors.bio = ''"
@blur="checkBio"
/>
</DirectoryField>
<DirectoryField :label="t('Website')" optional :error="errors.website">
<UInput
v-model="values.website"
placeholder="https://example.com"
:color="errors.website ? 'error' : undefined"
inputmode="url"
autocomplete="off"
class="w-full"
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
@update:model-value="errors.website = ''"
@blur="checkWebsite"
/>
</DirectoryField>
<SocialLinksRepeater
:model-value="links"
:errors="linkErrors"
:disabled="saving"
@update:model-value="onLinksChange"
@blur="checkLink"
/>
<DirectoryField
v-if="ownProfile"
:label="t('Linked user')"
:help="t('This is your author profile. Only an owner or admin can change the link.')"
>
<div
class="flex min-h-11 items-center gap-2.5 border border-default bg-elevated px-2.5 md:min-h-10"
>
<UIcon name="i-lucide-link" class="size-3.5 shrink-0 text-info" />
<span class="min-w-0 flex-1 truncate text-highlighted">{{ linkedEmail }}</span>
<UIcon name="i-lucide-lock" class="size-3.5 shrink-0 text-muted" />
</div>
</DirectoryField>
<DirectoryField
v-else-if="showLinkedBox"
:label="t('Linked user')"
optional
:help="t('New articles by this user start with this author.')"
>
<div
class="flex min-h-11 items-center gap-2.5 border border-accented py-0 pr-1.5 pl-2.5 md:min-h-10"
>
<UIcon name="i-lucide-link" class="size-3.5 shrink-0 text-info" />
<span class="min-w-0 flex-1 truncate text-highlighted">{{ linkedEmail }}</span>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
@click="confirmUnlink"
>
{{ t('Unlink') }}
</UButton>
</div>
</DirectoryField>
<DirectoryField
v-else
:label="t('Linked user')"
optional
:error="errors.user"
:help="
userMenuOpen
? ''
: t(
'Link a CMS user if this author has an account. Guest writers need no link.',
)
"
>
<UserSelect
v-model="values.userId"
v-model:open="userMenuOpen"
:users="users"
:status="userStatus === 'idle' ? 'ready' : userStatus"
:idle-placeholder="t('No linked user')"
:invalid="errors.user !== ''"
clearable
@open="loadUsers"
@retry="retryUsers"
@update:model-value="errors.user = ''"
/>
</DirectoryField>
<template #preview>
<div class="flex items-start gap-3 bg-elevated p-3">
<AuthorAvatar :name="values.name || 'Author'" :size="40" />
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="text-xs text-muted">{{ t('Byline preview') }}</span>
<span class="truncate font-semibold text-highlighted">{{
values.name || t('Author name')
}}</span>
<span class="line-clamp-2 text-xs/4.5 text-toned">{{
values.bio || t('The bio appears here.')
}}</span>
</div>
</div>
</template>
</DirectoryPanel>
</template>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { t } from '@/i18n'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import DirectoryBadge from '@/components/DirectoryBadge.vue'
import DirectoryField from '@/components/DirectoryField.vue'
import DirectoryPanel from '@/components/DirectoryPanel.vue'
import type { Author } from '@/composables/useAuthors'
import { pluralArticles } from '@/lib/directory'
defineProps<{ author: Author }>()
const emit = defineEmits<{ close: [] }>()
</script>
<template>
<DirectoryPanel :title="author.name" read-only @close="emit('close')">
<div class="flex items-center gap-3.5">
<AuthorAvatar :name="author.name" :size="64" />
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="truncate font-mono text-xs/4.5 text-muted">{{ author.slug }}</span>
<div class="flex items-center gap-2 text-xs text-muted">
<DirectoryBadge :kind="author.is_linked ? 'linked' : 'guest'" />
<span>{{ pluralArticles(author.article_count) }}</span>
</div>
</div>
</div>
<DirectoryField v-if="author.bio" :label="t('Bio')">
<p class="text-pretty text-toned">{{ author.bio }}</p>
</DirectoryField>
<DirectoryField v-if="author.website" :label="t('Website')">
<ULink
:to="author.website"
target="_blank"
rel="noopener noreferrer"
class="font-mono text-code text-highlighted underline underline-offset-3"
>{{ author.website }}</ULink
>
</DirectoryField>
<div class="flex gap-2.5 bg-elevated p-3 text-toned">
<UIcon name="i-lucide-lock" class="mt-0.75 size-3.5 shrink-0" />
<span>{{ t('Only an owner or admin can change this author.') }}</span>
</div>
</DirectoryPanel>
</template>
+18 -4
View File
@@ -64,9 +64,15 @@ const session = useSessionStore()
const update = useUpdateStore()
const api = useApi()
const canManageMedia = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const publishingChildren = computed(() => [
{ label: t('Publishing'), type: 'label' as const },
{
label: t('Sites'),
to: '/settings/sites',
icon: 'i-lucide-globe',
active: !props.noCurrentNav && route.path === '/settings/sites',
},
])
const settingsChildren = computed(() => [
{ label: t('Media'), type: 'label' as const },
@@ -82,6 +88,7 @@ const settingsChildren = computed(() => [
icon: 'i-lucide-database',
active: !props.noCurrentNav && route.path === '/settings/media/cache',
},
...(session.isOwner ? publishingChildren.value : []),
])
const navItems = computed(() =>
@@ -107,6 +114,13 @@ const navItems = computed(() =>
to: '/media',
icon: 'i-lucide-images',
},
{
get label() {
return t('Authors')
},
to: '/authors',
icon: 'i-lucide-feather',
},
{
get label() {
return t('Users')
@@ -144,7 +158,7 @@ const navItems = computed(() =>
return {
...item,
active: !props.noCurrentNav && parentActive,
...(isSettings && canManageMedia.value
...(isSettings && session.canManageContent
? {
children: settingsChildren.value,
defaultOpen: route.path.startsWith(item.to),
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
defineProps<{ pinned?: boolean }>()
</script>
<template>
<UButton
icon="i-lucide-plus"
:class="
pinned
? 'fixed inset-x-4 bottom-4 h-12 justify-center text-md shadow-(--shadow-popover)'
: 'h-8 px-3'
"
:ui="pinned ? { base: 'gap-2', leadingIcon: 'size-4.5' } : undefined"
>
<slot />
</UButton>
</template>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{
kind: 'default' | 'noindex' | 'linked' | 'guest'
}>()
const badges = {
default: {
get label() {
return t('Default')
},
icon: 'i-lucide-star',
class: 'border border-(--rc-sand) bg-(--rc-sand) text-(--rc-on-sand)',
},
noindex: {
get label() {
return t('Noindex')
},
icon: 'i-lucide-eye-off',
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
},
linked: {
get label() {
return t('Linked')
},
icon: 'i-lucide-link',
class: 'border border-(--rc-info-soft) bg-(--rc-info-soft) text-info',
},
guest: {
get label() {
return t('Guest')
},
icon: 'i-lucide-user',
class: 'border border-accented bg-transparent text-toned',
},
} as const
const badge = computed(() => badges[props.kind])
</script>
<template>
<UBadge
color="neutral"
variant="soft"
:icon="badge.icon"
:class="badge.class"
:ui="{
base: 'h-5 shrink-0 gap-1 px-1.5 py-0 text-xs whitespace-nowrap',
leadingIcon: 'size-3',
}"
>
{{ badge.label }}
</UBadge>
</template>
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { SHEET_CONTENT_CLASS, SHEET_FOOTER_CLASS } from '@/components/sheetClasses'
import { t } from '@/i18n'
import { computed } from 'vue'
import type { DirectoryNotice } from '@/lib/directoryTypes'
const props = withDefaults(
defineProps<{
title: string
description: string
confirmLabel: string
pendingLabel: string
cancelLabel?: string
tone?: 'danger' | 'neutral'
pending?: boolean
notice?: DirectoryNotice | null
closable?: boolean
confirmDisabled?: boolean
}>(),
{
cancelLabel: '',
tone: 'danger',
pending: false,
notice: null,
closable: false,
confirmDisabled: false,
},
)
const emit = defineEmits<{ confirm: []; close: [] }>()
const open = defineModel<boolean>('open', { default: true })
const confirmText = computed(() => (props.pending ? props.pendingLabel : props.confirmLabel))
const closeButton = computed(() =>
props.closable && !props.pending
? { class: 'size-7 justify-center text-muted max-md:size-11' }
: false,
)
function onOpenChange(value: boolean) {
if (!value && props.pending) {
return
}
open.value = value
if (!value) {
emit('close')
}
}
function onCancel() {
onOpenChange(false)
}
</script>
<template>
<UModal
:open="open"
:title="title"
:description="description"
:dismissible="!pending"
:close="closeButton"
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
:ui="{
content: `${SHEET_CONTENT_CLASS} sm:top-35 sm:max-w-115`,
header: 'min-h-0 px-6 pt-5 pb-0 sm:px-6',
title: 'text-lg/6 font-semibold',
description: 'mt-1 text-sm text-muted text-pretty',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: SHEET_FOOTER_CLASS,
}"
@update:open="onOpenChange"
>
<template #body>
<div class="flex flex-col gap-4">
<UAlert
v-if="notice"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:title="notice.title"
:description="notice.description"
:ui="{
root: 'items-start gap-2.5 px-3.5 py-3',
title: 'font-semibold',
description: 'text-toned',
}"
/>
<slot />
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
color="neutral"
variant="outline"
block
class="h-11 justify-center text-sm text-highlighted disabled:text-dimmed disabled:opacity-100 sm:h-8 sm:w-auto sm:px-3"
:disabled="pending"
@click="onCancel"
>
{{ cancelLabel || t('Cancel') }}
</UButton>
<UButton
:color="tone === 'danger' ? 'error' : 'primary'"
:loading="pending"
:disabled="pending || confirmDisabled"
block
class="h-11 justify-center text-sm sm:h-8 sm:w-auto sm:px-3"
@click="emit('confirm')"
>
{{ confirmText }}
</UButton>
</div>
</template>
</UModal>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { t } from '@/i18n'
withDefaults(
defineProps<{
label?: string
optional?: boolean
error?: string
help?: string
name?: string
}>(),
{ label: '', optional: false, error: '', help: '', name: undefined },
)
</script>
<template>
<UFormField
:name="name"
:error="error || undefined"
:ui="{
root: 'min-w-0',
wrapper: 'w-full',
labelWrapper: 'w-full justify-between gap-2',
label: 'font-medium text-highlighted',
hint: 'font-mono text-xs/4 font-normal text-muted',
container: 'mt-1.5',
help: 'mt-1.5 text-xs/4.5 text-pretty text-muted',
error: 'mt-1.5 text-xs/4.5 text-error',
}"
>
<template v-if="label" #label>
{{ label }}
<span v-if="optional" class="font-normal text-muted"> {{ t('(optional)') }}</span>
</template>
<template v-if="$slots.hint" #hint>
<slot name="hint" />
</template>
<slot />
<template v-if="error" #error>
<span class="flex items-start gap-1.5" role="alert">
<UIcon name="i-lucide-circle-alert" class="mt-0.5 size-3.5 shrink-0" />
<span>{{ error }}</span>
</span>
</template>
<template v-if="help && !error" #help>
{{ help }}
</template>
</UFormField>
</template>
+151
View File
@@ -0,0 +1,151 @@
<script setup lang="ts" generic="T extends { id: number }">
import { t } from '@/i18n'
import { ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import DirectoryRowMenu from '@/components/DirectoryRowMenu.vue'
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
const DESKTOP_QUERY = '(min-width: 48rem)'
const DEFAULT_SKELETON_ROWS = 3
const props = withDefaults(
defineProps<{
items: T[]
columns: string
headings: string[]
loading?: boolean
skeletonRows?: number
readOnly?: boolean
rowTitle: (item: T) => string
menuItems?: (item: T) => DirectoryMenuItem[]
label: string
}>(),
{
loading: false,
skeletonRows: DEFAULT_SKELETON_ROWS,
readOnly: false,
menuItems: () => [],
},
)
const emit = defineEmits<{ open: [item: T] }>()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const openMenuId = ref<number | null>(null)
function setMenuOpen(item: T, isOpen: boolean) {
if (isOpen) {
openMenuId.value = item.id
} else if (openMenuId.value === item.id) {
openMenuId.value = null
}
}
function rowClass(item: T) {
return [
openMenuId.value === item.id ? 'bg-elevated' : 'bg-default',
props.readOnly ? 'cursor-pointer hover:bg-elevated' : '',
]
}
function activate(item: T) {
if (props.readOnly) {
emit('open', item)
}
}
</script>
<template>
<div
role="group"
:aria-label="label"
:aria-busy="loading ? 'true' : undefined"
class="border border-default bg-default max-md:-mx-4 max-md:border-x-0"
>
<template v-if="loading">
<span role="status" class="sr-only">{{ t('Loading') }}</span>
<div
v-for="n in skeletonRows"
:key="n"
data-testid="directory-skeleton-row"
class="flex h-13 items-center gap-3 border-b border-muted px-4 max-md:h-18"
>
<USkeleton class="size-7 shrink-0 rounded-none bg-accented max-md:size-8" />
<div class="flex flex-1 flex-col gap-2">
<USkeleton class="h-3.25 w-2/5 rounded-none bg-accented" />
<USkeleton class="h-2.75 w-3/5 rounded-none bg-accented" />
</div>
</div>
</template>
<template v-else-if="isDesktop">
<div
class="grid h-9 items-center gap-3 bg-elevated px-3 font-medium text-muted"
:style="{ gridTemplateColumns: columns }"
>
<span v-for="(heading, index) in headings" :key="index">{{ heading }}</span>
</div>
<div
v-for="item in items"
:key="item.id"
data-testid="directory-row"
class="grid min-h-13 items-center gap-3 border-t border-default px-3"
:class="rowClass(item)"
:style="{ gridTemplateColumns: columns }"
@click="activate(item)"
>
<slot name="cells" :item="item" />
<span class="flex justify-center">
<UButton
v-if="readOnly"
icon="i-lucide-chevron-right"
color="neutral"
variant="ghost"
:aria-label="t('View {0}', [rowTitle(item)])"
class="size-8 justify-center text-muted"
:ui="{ leadingIcon: 'size-3.5' }"
@click.stop="emit('open', item)"
/>
<DirectoryRowMenu
v-else
:items="menuItems(item)"
:title="rowTitle(item)"
:desktop="true"
@update:open="setMenuOpen(item, $event)"
/>
</span>
</div>
</template>
<template v-else>
<div
v-for="item in items"
:key="item.id"
data-testid="directory-card"
class="flex min-h-19 items-center gap-2 border-b border-default py-3 pr-1 pl-4"
:class="rowClass(item)"
@click="activate(item)"
>
<div class="flex min-w-0 flex-1 items-center gap-3">
<slot name="card" :item="item" />
</div>
<UButton
v-if="readOnly"
icon="i-lucide-chevron-right"
color="neutral"
variant="ghost"
:aria-label="t('View {0}', [rowTitle(item)])"
class="size-11 justify-center text-muted"
:ui="{ leadingIcon: 'size-3.5' }"
@click.stop="emit('open', item)"
/>
<DirectoryRowMenu
v-else
:items="menuItems(item)"
:title="rowTitle(item)"
:desktop="false"
@update:open="setMenuOpen(item, $event)"
/>
</div>
</template>
</div>
</template>
+189
View File
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, useId } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import type { DirectoryNotice } from '@/lib/directoryTypes'
const DESKTOP_QUERY = '(min-width: 48rem)'
const AUTOFOCUS_SELECTOR = '[data-autofocus]'
const INVALID_SELECTOR = '[aria-invalid="true"]'
const props = withDefaults(
defineProps<{
title: string
submitLabel?: string
saving?: boolean
dirty?: boolean
readOnly?: boolean
notice?: DirectoryNotice | null
}>(),
{ submitLabel: '', saving: false, dirty: false, readOnly: false, notice: null },
)
const emit = defineEmits<{ close: []; submit: [] }>()
const open = defineModel<boolean>('open', { default: true })
const overlay = useLocalizedOverlay()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const formId = useId()
const form = ref<{ $el: HTMLElement } | null>(null)
const side = computed(() => (isDesktop.value ? 'right' : 'bottom'))
const primaryLabel = computed(() => (props.saving ? t('Saving…') : props.submitLabel))
let discardOpen = false
const closeButton = computed(() => ({
icon: 'i-lucide-x',
color: 'neutral' as const,
variant: 'ghost' as const,
'aria-label': t('Close'),
class: 'size-7 justify-center text-muted max-md:top-1.5 max-md:end-1.5 max-md:size-11',
ui: { leadingIcon: 'size-4' },
}))
const overlayUi = computed(() => ({
content: isDesktop.value
? 'w-full max-w-120'
: 'h-23/25 max-h-none pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
header: 'h-14 min-h-14 gap-3 px-4 py-0 sm:px-4 md:px-5',
wrapper: 'min-w-0 flex-1',
title: 'truncate text-lg font-semibold',
description: 'sr-only',
body: 'flex min-h-0 flex-1 flex-col gap-4.5 overflow-y-auto p-4 sm:p-4 md:p-5',
footer: `gap-2 px-4 py-3 sm:px-4 max-md:flex-col-reverse md:px-5 ${props.readOnly ? 'md:justify-end' : 'md:justify-between'}`,
}))
function focusOnOpen(event: Event) {
const container = event.target instanceof HTMLElement ? event.target : null
const target = container?.querySelector<HTMLElement>(AUTOFOCUS_SELECTOR)
if (!target) {
return
}
event.preventDefault()
nextTick(() => target.focus())
}
function focusFirstInvalid() {
nextTick(() => form.value?.$el.querySelector<HTMLElement>(INVALID_SELECTOR)?.focus())
}
defineExpose({ focusFirstInvalid })
async function confirmDiscard() {
if (discardOpen) {
return
}
discardOpen = true
const discard = await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Discard changes?')
},
get description() {
return t("You have unsaved changes. If you close now, they're lost.")
},
get confirmLabel() {
return t('Discard changes')
},
get cancelLabel() {
return t('Keep editing')
},
get pendingLabel() {
return t('Discard changes')
},
tone: 'danger',
action: async () => undefined,
},
})
.open()
discardOpen = false
if (discard) {
emit('close')
}
}
function requestClose() {
if (props.saving) {
return
}
if (!props.dirty || props.readOnly) {
emit('close')
return
}
confirmDiscard()
}
function onOpenChange(isOpen: boolean) {
if (!isOpen) {
requestClose()
}
}
</script>
<template>
<USlideover
:open="open"
:side="side"
:title="title"
:description="title"
:close="closeButton"
:dismissible="!saving"
:content="{ onOpenAutoFocus: focusOnOpen }"
:ui="overlayUi"
@update:open="onOpenChange"
>
<template #body>
<UAlert
v-if="notice"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:title="notice.title"
:description="notice.description"
:ui="{
root: 'shrink-0 items-start gap-2.5 px-3.5 py-3',
description: 'text-toned',
}"
/>
<UForm
:id="formId"
ref="form"
:state="{}"
:validate-on="[]"
:disabled="saving"
class="flex flex-col gap-4.5"
@submit="emit('submit')"
>
<slot />
</UForm>
<slot name="preview" />
</template>
<template #footer>
<UButton
color="neutral"
variant="outline"
:disabled="saving"
class="h-11 w-full justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:h-8 md:w-auto"
@click="requestClose"
>
{{ readOnly ? t('Close') : t('Cancel') }}
</UButton>
<UButton
v-if="!readOnly"
type="submit"
:form="formId"
:loading="saving"
:disabled="saving"
class="h-11 w-full justify-center px-4 text-sm md:h-8 md:w-auto"
>
{{ primaryLabel }}
</UButton>
</template>
</USlideover>
</template>
+149
View File
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, useId } from 'vue'
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
const props = defineProps<{
items: DirectoryMenuItem[]
title: string
desktop: boolean
}>()
const open = defineModel<boolean>('open', { default: false })
const hintId = useId()
const trigger = ref<{ $el: HTMLElement } | null>(null)
const label = computed(() => t('Actions for {0}', [props.title]))
const hint = computed(() => props.items.find((item) => item.unavailable)?.hint ?? '')
const groups = computed(() => {
const result: DirectoryMenuItem[][] = []
for (const item of props.items) {
if (item.separated || result.length === 0) {
result.push([])
}
result[result.length - 1]!.push(item)
}
return result
})
function select(item: DirectoryMenuItem, event?: Event) {
if (item.unavailable) {
event?.preventDefault()
return
}
open.value = false
item.onSelect(trigger.value?.$el ?? null)
}
const menuItems = computed(() =>
groups.value.map((group) =>
group.map((item) => ({
label: item.label,
icon: item.icon,
color: item.destructive ? ('error' as const) : undefined,
slot: item.unavailable ? ('unavailable' as const) : undefined,
'aria-disabled': item.unavailable ? 'true' : undefined,
'aria-describedby': item.unavailable ? hintId : undefined,
class: item.unavailable ? 'opacity-40' : undefined,
onSelect: (event: Event) => select(item, event),
})),
),
)
const TRIGGER_CLASS =
'size-8 shrink-0 justify-center text-muted data-[state=open]:outline-2 data-[state=open]:outline-offset-2 data-[state=open]:outline-primary max-md:size-11'
</script>
<template>
<UDropdownMenu
v-if="desktop"
v-model:open="open"
:items="menuItems"
:content="{ align: 'end' }"
:ui="{
content: 'w-55 p-1',
group: 'p-0',
separator: 'my-1',
item: 'h-8 gap-2 px-2 text-sm',
itemLeadingIcon: 'size-4',
}"
>
<UButton
ref="trigger"
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
:aria-label="label"
:class="TRIGGER_CLASS"
@click.stop
/>
<template #unavailable="{ item }">
<UTooltip :text="hint" :content="{ side: 'left' }">
<span class="flex items-center gap-2">
<UIcon :name="item.icon" class="size-4 shrink-0 text-error" />
<span class="truncate text-error">{{ item.label }}</span>
<span :id="hintId" aria-hidden="true" class="sr-only">{{ hint }}</span>
</span>
</UTooltip>
</template>
</UDropdownMenu>
<template v-else>
<UButton
ref="trigger"
icon="i-lucide-ellipsis-vertical"
color="neutral"
variant="ghost"
:aria-label="label"
:class="TRIGGER_CLASS"
:ui="{ leadingIcon: 'size-4.5' }"
@click.stop="open = true"
/>
<USlideover
v-model:open="open"
side="bottom"
:title="title"
:description="title"
:ui="{
content:
'max-h-none pt-3 pb-6 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
header: 'min-h-11 border-0 px-4 py-0 sm:px-4',
title: 'text-md font-semibold text-highlighted',
description: 'sr-only',
close: 'top-0 end-1.5 size-11 justify-center p-0',
body: 'p-0 sm:p-0',
}"
>
<template #body>
<div role="menu" :aria-label="title" class="flex flex-col">
<template v-for="item in items" :key="item.key">
<USeparator v-if="item.separated" class="my-1" />
<UButton
role="menuitem"
color="neutral"
variant="ghost"
block
:icon="item.icon"
:aria-disabled="item.unavailable ? 'true' : undefined"
:aria-describedby="item.unavailable ? hintId : undefined"
class="min-h-12 justify-start gap-3 px-4 text-start text-md"
:class="[
item.destructive ? 'text-error' : 'text-highlighted',
item.unavailable ? 'opacity-40' : '',
]"
:ui="{ leadingIcon: 'size-4.5' }"
@click="select(item, $event)"
>
{{ item.label }}
</UButton>
</template>
<p v-if="hint" :id="hintId" class="px-4 pt-1 pl-11.5 text-xs/4.5 text-muted">
{{ hint }}
</p>
</div>
</template>
</USlideover>
</template>
</template>
+15 -5
View File
@@ -49,13 +49,15 @@ import {
type Corner = 'tl' | 'tr' | 'bl' | 'br'
import { useImageUpload } from '@/composables/useImageUpload'
import type { ImageChoice } from '@/lib/imageChoice'
const props = withDefaults(
defineProps<{
initialFile?: File | null
inArticleIds?: number[]
choose?: ImageChoice
}>(),
{ initialFile: null, inArticleIds: () => [] },
{ initialFile: null, inArticleIds: () => [], choose: undefined },
)
const emit = defineEmits<{
@@ -186,11 +188,17 @@ const showCropActions = computed(() => !onLibraryTab.value && phase.value === 'c
const wideModal = computed(() => showTabs.value || phase.value === 'crop')
const modalTitle = computed(() =>
phase.value === 'crop' ? t('Crop and resize') : t('Add an image'),
)
const modalTitle = computed(() => {
if (phase.value === 'crop') {
return t('Crop and resize')
}
return props.choose?.title ?? t('Add an image')
})
const modalDescription = computed(() => {
if (props.choose && phase.value !== 'crop') {
return props.choose.description
}
if (phase.value !== 'crop' || !chosenFile.value) {
return undefined
}
@@ -805,6 +813,8 @@ onUnmounted(() => {
:first-new-id="library.firstNewId.value"
:in-article-ids="inArticleIds"
:selected="selected"
:plain="choose !== undefined"
:hint="choose?.hint"
@search="searchLibrary"
@select="selectItem"
@load-more="library.loadMore"
@@ -1123,7 +1133,7 @@ onUnmounted(() => {
:disabled="!selected"
@click="insertSelected"
>
{{ t('Insert image') }}
{{ choose?.confirmLabel ?? t('Insert image') }}
</UButton>
<UButton
v-if="showRejectedActions"
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { onMounted, ref } from 'vue'
import DirectoryDialog from '@/components/DirectoryDialog.vue'
import type { DirectoryNotice, LinkableUser } from '@/lib/directoryTypes'
import DirectoryField from '@/components/DirectoryField.vue'
import UserSelect from '@/components/UserSelect.vue'
import { useAuthors, type Author } from '@/composables/useAuthors'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { copy } from '@/lib/directoryCopy'
import { classifyFailure } from '@/lib/directoryErrors'
const props = defineProps<{ author: Author }>()
const emit = defineEmits<{ close: []; linked: [author: Author]; gone: [id: number] }>()
const { listLinkableUsers, updateAuthor } = useAuthors()
const toasts = useDirectoryToasts()
const userId = ref<number | null>(null)
const users = ref<LinkableUser[]>([])
const status = ref<'loading' | 'ready' | 'error'>('loading')
const pending = ref(false)
const fieldError = ref('')
const notice = ref<DirectoryNotice | null>(null)
async function loadUsers() {
status.value = 'loading'
try {
users.value = await listLinkableUsers()
status.value = 'ready'
} catch {
status.value = 'error'
}
}
onMounted(loadUsers)
function fail(error: unknown) {
const kind = classifyFailure(error, 'author')
if (kind === 'session') {
return
}
if (kind === 'userLinked') {
fieldError.value = copy('userLinked')
return
}
if (toasts.report(kind, 'author')) {
if (kind === 'gone') {
emit('gone', props.author.id)
} else {
emit('close')
}
return
}
notice.value = {
title: copy('authorSaveFailedTitle'),
description: copy('nothingChanged'),
}
}
async function confirm() {
if (userId.value === null || pending.value) {
return
}
pending.value = true
notice.value = null
fieldError.value = ''
try {
const updated = await updateAuthor(props.author.id, { user_id: userId.value })
emit('linked', updated)
} catch (error) {
fail(error)
} finally {
pending.value = false
}
}
</script>
<template>
<DirectoryDialog
:title="t('Link {0} to a user?', [author.name])"
:description="t('Only users without an author are listed.')"
:confirm-label="t('Link user')"
:pending-label="t('Saving…')"
tone="neutral"
closable
:pending="pending"
:notice="notice"
:confirm-disabled="userId === null"
@confirm="confirm"
@close="emit('close')"
>
<DirectoryField :label="t('Linked user')" :error="fieldError">
<UserSelect
v-model="userId"
:users="users"
:status="status"
:disabled="pending"
:invalid="fieldError !== ''"
@retry="loadUsers"
@update:model-value="fieldError = ''"
/>
</DirectoryField>
</DirectoryDialog>
</template>
+114
View File
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import DirectoryField from '@/components/DirectoryField.vue'
import ImageUploader from '@/components/ImageUploader.vue'
import MediaThumb from '@/components/MediaThumb.vue'
import type { MediaItem } from '@/composables/useMedia'
import type { ImageChoice } from '@/lib/imageChoice'
const props = withDefaults(
defineProps<{
label: string
help?: string
picker: ImageChoice
avatarName?: string
square?: boolean
disabled?: boolean
}>(),
{ help: '', avatarName: '', square: false, disabled: false },
)
const imageId = defineModel<number | null>({ default: null })
const item = defineModel<MediaItem | null>('item', { default: null })
const pickerOpen = ref(false)
const BUTTON_CLASS =
'h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7'
const hasImage = computed(() => imageId.value !== null)
const chooseLabel = computed(() => (hasImage.value ? t('Change image') : t('Choose image')))
const caption = computed(() => {
if (item.value) {
return item.value.filename
}
return imageId.value === null ? '' : t('Image #{0}', [imageId.value])
})
function choose(picked: MediaItem) {
item.value = picked
imageId.value = picked.id
}
function remove() {
item.value = null
imageId.value = null
}
</script>
<template>
<DirectoryField :label="label" optional :help="help">
<div class="flex items-center gap-3">
<template v-if="square">
<MediaThumb
v-if="item"
:item="item"
size="md"
class="size-16 border border-default"
/>
<AuthorAvatar v-else :name="avatarName" :size="64" />
</template>
<div
v-else
class="flex h-12.5 w-24 shrink-0 items-center justify-center overflow-hidden border text-muted"
:class="
hasImage
? 'border-accented bg-(--rc-sand)'
: 'border-dashed border-accented bg-default'
"
>
<MediaThumb v-if="item" :item="item" size="sm" class="size-full" />
<UIcon v-else name="i-lucide-image" class="size-4.5" />
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<span
v-if="caption"
class="truncate font-mono text-xs text-highlighted"
data-testid="media-field-caption"
>{{ caption }}</span
>
<div class="flex flex-wrap gap-2">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-images"
:disabled="disabled"
:class="BUTTON_CLASS"
:ui="{ leadingIcon: 'size-3.5' }"
@click="pickerOpen = true"
>
{{ chooseLabel }}
</UButton>
<UButton
v-if="hasImage"
color="neutral"
variant="outline"
:disabled="disabled"
:class="BUTTON_CLASS"
@click="remove"
>
{{ t('Remove') }}
</UButton>
</div>
</div>
</div>
<ImageUploader
v-model:open="pickerOpen"
:choose="props.picker"
@inserted="choose"
@uploaded="choose"
/>
</DirectoryField>
</template>
+22 -5
View File
@@ -38,6 +38,8 @@ const props = defineProps<{
selected: MediaItem | null
variants?: ImageVariant[]
variantsReady?: boolean
plain?: boolean
hint?: string
}>()
const emit = defineEmits<{
@@ -64,7 +66,12 @@ const searching = computed(() => props.query !== '')
const summary = computed(() =>
searching.value ? t('Newest first') : t('Newest first · {0} images', [props.total]),
)
const scrollHeight = computed(() => (props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'))
const scrollHeight = computed(() => {
if (props.plain) {
return 'max-h-71 md:max-h-89'
}
return props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'
})
const showEmptyLibrary = computed(
() => props.status === 'ready' && props.items.length === 0 && !searching.value,
)
@@ -72,7 +79,9 @@ const showNoResults = computed(
() => props.status === 'ready' && props.items.length === 0 && searching.value,
)
const showGrid = computed(() => props.status === 'ready' && props.items.length > 0)
const showLoadMore = computed(() => showGrid.value && props.hasMore && !props.selected)
const showLoadMore = computed(
() => showGrid.value && props.hasMore && (props.plain || !props.selected),
)
const noResultsTitle = computed(() => t('No images match \u0022{0}\u0022', [props.query]))
function clearDebounce() {
@@ -239,9 +248,15 @@ onBeforeUnmount(clearDebounce)
</div>
<template v-else-if="showGrid">
<span data-testid="picker-summary" class="text-xs/5 text-muted">{{ summary }}</span>
<span v-if="!plain" data-testid="picker-summary" class="text-xs/5 text-muted">{{
summary
}}</span>
<div class="-m-1 min-h-0 overflow-y-auto p-1" :class="scrollHeight">
<div ref="grid" class="grid grid-cols-3 gap-x-2 gap-y-3 md:grid-cols-4 md:gap-x-3">
<div
ref="grid"
class="grid grid-cols-3 md:grid-cols-4"
:class="plain ? 'gap-2.5' : 'gap-x-2 gap-y-3 md:gap-x-3'"
>
<button
v-for="item in items"
:key="item.id"
@@ -311,8 +326,10 @@ onBeforeUnmount(clearDebounce)
</div>
</template>
<p v-if="hint" class="text-xs/5 text-muted">{{ hint }}</p>
<div
v-if="selected"
v-if="selected && !plain"
data-testid="picker-panel"
class="flex flex-col gap-3 border-t border-default pt-3"
>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { t } from '@/i18n'
</script>
<template>
<section
aria-labelledby="publishing-links-h"
data-testid="publishing-links-card"
class="flex flex-col gap-3 border border-default bg-default p-4 lg:p-5"
>
<div class="flex flex-col gap-0.5">
<h2 id="publishing-links-h" class="text-md/6 font-semibold text-highlighted">
{{ t('Publishing') }}
</h2>
<p class="text-pretty text-muted">{{ t('Where your content is shown.') }}</p>
</div>
<div class="flex flex-col">
<RouterLink
to="/settings/sites"
class="flex min-h-11 items-center gap-3 border-t border-default py-2 text-highlighted focus-visible:outline-2 focus-visible:outline-primary"
>
<UIcon name="i-lucide-globe" class="size-4 shrink-0" />
<span class="flex min-w-0 flex-1 flex-col">
<span class="font-medium">{{ t('Manage sites') }}</span>
<span class="text-muted">{{
t('Base URLs, default image and robots policy')
}}</span>
</span>
<UIcon name="i-lucide-chevron-right" class="size-3.5 shrink-0" />
</RouterLink>
</div>
</section>
</template>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import DirectoryDialog from '@/components/DirectoryDialog.vue'
import type { DirectoryNotice } from '@/lib/directoryTypes'
export interface ReassignOption {
value: string | number
label: string
description: string
display: string
avatarName?: string
none?: boolean
}
const props = withDefaults(
defineProps<{
title: string
description: string
confirmLabel: string
pendingLabel: string
selectLabel?: string
options?: ReassignOption[]
summary?: string
pending?: boolean
notice?: DirectoryNotice | null
closable?: boolean
}>(),
{
selectLabel: '',
options: () => [],
summary: '',
pending: false,
notice: null,
closable: false,
},
)
const emit = defineEmits<{ confirm: []; close: [] }>()
const open = defineModel<boolean>('open', { default: true })
const selected = defineModel<string | number | undefined>('selected', { default: undefined })
const selectOpen = ref(false)
const current = computed(() => props.options.find((option) => option.value === selected.value))
</script>
<template>
<DirectoryDialog
v-model:open="open"
:title="title"
:description="description"
:confirm-label="confirmLabel"
:pending-label="pendingLabel"
:pending="pending"
:notice="notice"
:closable="closable"
@confirm="emit('confirm')"
@close="emit('close')"
>
<UFormField
v-if="options.length"
:label="selectLabel"
:ui="{
label: 'font-medium text-highlighted',
container: 'mt-1.5',
help: 'mt-1.5 text-xs/4.5 text-pretty text-muted',
}"
>
<USelectMenu
v-model="selected"
v-model:open="selectOpen"
:portal="false"
:content="{ position: 'inline' }"
:items="options"
value-key="value"
:search-input="false"
:disabled="pending"
class="w-full"
:ui="{
base: 'h-11 rounded-none text-lg ring-(--ui-border-accented) data-[state=open]:ring-2 data-[state=open]:ring-primary md:h-10 md:text-sm',
trailingIcon: 'size-3.5 text-muted',
content:
'mt-1.5 max-h-63 rounded-none border border-default shadow-(--shadow-popover)',
itemTrailingIcon: 'size-3.5 text-highlighted',
itemLabel: 'font-medium',
item: 'min-h-12 gap-2 px-2 py-1 data-[state=checked]:bg-elevated md:min-h-9',
itemDescription: 'truncate text-xs text-muted',
}"
>
<template #default>
<span class="truncate">{{ current?.display }}</span>
</template>
<template #item-leading="{ item }">
<span
v-if="item.none"
class="flex size-6 items-center justify-center border border-dashed border-accented text-muted"
>
<UIcon name="i-lucide-user-x" class="size-3.5" />
</span>
<AuthorAvatar v-else-if="item.avatarName" :name="item.avatarName" :size="24" />
</template>
</USelectMenu>
<template v-if="summary && !selectOpen" #help>
{{ summary }}
</template>
</UFormField>
<slot />
</DirectoryDialog>
</template>
+429
View File
@@ -0,0 +1,429 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, reactive, ref } from 'vue'
import DirectoryField from '@/components/DirectoryField.vue'
import DirectoryPanel from '@/components/DirectoryPanel.vue'
import MediaField from '@/components/MediaField.vue'
import SlugField from '@/components/SlugField.vue'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useSites, type Site, type SitePatch, type SiteRobots } from '@/composables/useSites'
import { useSlugField } from '@/composables/useSlugField'
import type { MediaItem } from '@/composables/useMedia'
import {
SITE_KEY_MAX,
canonicalPreview,
siteUrlMessage,
siteUrlProblem,
suggestFreeSlug,
} from '@/lib/directory'
import { copy } from '@/lib/directoryCopy'
import { classifyFailure } from '@/lib/directoryErrors'
import { baseUrlProblem, nameProblem, siteKeyProblem } from '@/lib/directoryRules'
import type { DirectoryNotice } from '@/lib/directoryTypes'
const PREVIEW_FALLBACK_URL = 'https://journal.example.com'
const props = defineProps<{
site?: Site
sites: Site[]
}>()
const emit = defineEmits<{
close: []
saved: [site: Site]
updated: [site: Site]
defaulted: [site: Site]
gone: [id: number]
}>()
const { createSite, updateSite, setDefaultSite } = useSites()
const toasts = useDirectoryToasts()
const editing = computed(() => props.site !== undefined)
const isCurrentDefault = computed(() => props.site?.is_default === true)
const currentDefault = computed(() => props.sites.find((site) => site.is_default))
const values = reactive({
name: props.site?.name ?? '',
baseUrl: props.site?.base_url ?? '',
imageId: props.site?.default_social_image_id ?? null,
robots: (props.site?.default_robots ?? 'index') as SiteRobots,
locale: props.site?.locale ?? '',
makeDefault: props.site?.is_default ?? false,
})
const imageItem = ref<MediaItem | null>(null)
const slug = useSlugField(SITE_KEY_MAX, props.site?.key)
slug.setSource(values.name)
const errors = reactive({ name: '', key: '', suggestion: '', url: '', locale: '' })
const saving = ref(false)
const notice = ref<DirectoryNotice | null>(null)
const panel = ref<{ focusFirstInvalid: () => void } | null>(null)
function snapshot() {
return JSON.stringify([
values.name,
slug.value.value,
values.baseUrl,
values.imageId,
values.robots,
values.locale,
values.makeDefault,
])
}
const initial = snapshot()
const dirty = computed(() => snapshot() !== initial)
const title = computed(() => (props.site ? t('Edit {0}', [props.site.name]) : t('Add site')))
const submitLabel = computed(() => (editing.value ? t('Save changes') : t('Add site')))
const robotsItems = computed(() => [
{ label: t('Index'), value: 'index' },
{ label: t('Noindex'), value: 'noindex' },
])
const robotsHelp = computed(() =>
values.robots === 'index'
? t("Search engines may list this site's pages.")
: t("Asks search engines not to list this site's pages."),
)
const switchLabel = computed(() =>
isCurrentDefault.value ? t('Default site') : t('Make this the default site'),
)
const switchHelp = computed(() =>
isCurrentDefault.value
? t('New articles start on the default site. To change it, make another site the default.')
: t('New articles start here. The current default is {0}.', [
currentDefault.value?.name ?? '',
]),
)
const urlError = computed(() => errors.url)
const previewUrl = computed(() =>
values.baseUrl.trim() && !urlError.value && siteUrlProblem(values.baseUrl) === null
? canonicalPreview(values.baseUrl)
: canonicalPreview(PREVIEW_FALLBACK_URL),
)
const picker = computed(() => ({
title: t('Choose an image'),
description: t('The default social image for {0}.', [values.name || t('this site')]),
confirmLabel: t('Use image'),
}))
function takenKeys() {
return props.sites.filter((site) => site.id !== props.site?.id).map((site) => site.key)
}
function checkKey() {
if (editing.value) {
return true
}
const key = slug.value.value
if (!key) {
return true
}
if (takenKeys().includes(key)) {
showKeyTaken(key)
return false
}
return true
}
function showKeyTaken(key: string) {
if (slug.mode.value === 'auto') {
slug.unlock()
}
errors.key = t('A site with the key "{0}" already exists. Choose another key.', [key])
errors.suggestion = suggestFreeSlug(
key,
(candidate) => takenKeys().includes(candidate),
SITE_KEY_MAX,
)
}
function clearKeyError() {
errors.key = ''
errors.suggestion = ''
}
function onName(value: string) {
values.name = value
slug.setSource(value)
errors.name = ''
clearKeyError()
}
function onKey(value: string) {
slug.setValue(value)
clearKeyError()
}
function resetKey() {
slug.reset()
clearKeyError()
}
function onBaseUrl(value: string) {
values.baseUrl = value
errors.url = ''
}
function checkUrl() {
errors.url = baseUrlProblem(values.baseUrl)
return errors.url === ''
}
function checkName() {
errors.name = nameProblem(values.name)
return errors.name === ''
}
function checkKeyShape() {
if (editing.value || !values.name.trim()) {
return true
}
errors.key = siteKeyProblem(slug.value.value)
return errors.key === ''
}
function validate() {
const nameOk = checkName()
const keyOk = nameOk ? checkKeyShape() : true
const urlOk = checkUrl()
return nameOk && keyOk && urlOk
}
function changedFields(site: Site) {
const patch: SitePatch = {}
if (values.name.trim() !== site.name) {
patch.name = values.name.trim()
}
if (values.baseUrl.trim() !== site.base_url) {
patch.base_url = values.baseUrl.trim()
}
if (values.robots !== site.default_robots) {
patch.default_robots = values.robots
}
if (values.locale.trim() !== (site.locale ?? '')) {
patch.locale = values.locale.trim() === '' ? null : values.locale.trim()
}
if (values.imageId !== site.default_social_image_id) {
patch.default_social_image_id = values.imageId
}
return patch
}
async function create() {
const created = await createSite({
name: values.name.trim(),
key: slug.value.value,
base_url: values.baseUrl.trim(),
default_robots: values.robots,
...(values.locale.trim() ? { locale: values.locale.trim() } : {}),
...(values.imageId !== null ? { default_social_image_id: values.imageId } : {}),
is_default: values.makeDefault,
})
emit('saved', created)
}
async function update(site: Site) {
const patch = changedFields(site)
let latest = site
if (Object.keys(patch).length > 0) {
latest = await updateSite(site.id, patch)
emit('updated', latest)
}
if (values.makeDefault && !site.is_default) {
latest = await setDefaultSite(site.id)
emit('defaulted', latest)
}
emit('saved', latest)
}
function handleFailure(error: unknown) {
const kind = classifyFailure(error, 'site')
if (kind === 'session') {
return
}
if (kind === 'keyTaken') {
showKeyTaken(slug.value.value)
return
}
if (kind === 'invalidSiteUrl') {
errors.url = siteUrlMessage('invalid')
return
}
if (kind === 'invalidLocale') {
errors.locale = t('Use a language tag like en or fr-CH.')
return
}
if (toasts.report(kind, 'site')) {
if (kind === 'gone' && props.site) {
emit('gone', props.site.id)
}
return
}
notice.value = { description: copy('siteSaveFailed') }
}
async function submit() {
if (saving.value) {
return
}
notice.value = null
if (!validate()) {
panel.value?.focusFirstInvalid()
return
}
if (props.site && !dirty.value) {
emit('close')
return
}
saving.value = true
try {
if (props.site) {
await update(props.site)
} else {
await create()
}
} catch (error) {
handleFailure(error)
} finally {
saving.value = false
}
}
</script>
<template>
<DirectoryPanel
ref="panel"
:title="title"
:submit-label="submitLabel"
:saving="saving"
:dirty="dirty"
:notice="notice"
@close="emit('close')"
@submit="submit"
>
<DirectoryField :label="t('Name')" :error="errors.name">
<UInput
:model-value="values.name"
:placeholder="t('e.g. Journal')"
:color="errors.name ? 'error' : undefined"
:data-autofocus="editing ? undefined : ''"
autocomplete="off"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
@update:model-value="onName(String($event))"
@blur="checkName"
/>
</DirectoryField>
<SlugField
:label="t('Key')"
:mode="slug.mode.value"
:value="slug.value.value"
:error="errors.key"
:suggestion="errors.suggestion"
:locked="editing"
:help="
t(
'Lowercase letters, numbers and hyphens. Used in API paths. It can\'t be changed after the site is added.',
)
"
:locked-help="t('Used in API paths, so it can\'t change.')"
:edit-label="t('Edit key')"
@update:value="onKey"
@unlock="slug.unlock"
@reset="resetKey"
@suggest="onKey"
@blur="checkKey"
/>
<DirectoryField
:label="t('Base URL')"
:error="errors.url"
:help="t('The public address of the frontend. Use https and no path.')"
>
<UInput
:model-value="values.baseUrl"
placeholder="https://blog.example.com"
:color="errors.url ? 'error' : undefined"
inputmode="url"
autocomplete="off"
class="w-full"
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
@update:model-value="onBaseUrl(String($event))"
@blur="checkUrl"
/>
</DirectoryField>
<MediaField
v-model="values.imageId"
v-model:item="imageItem"
:label="t('Default social image')"
:help="t('Used when an article has no image of its own.')"
:picker="picker"
/>
<DirectoryField :label="t('Default robots policy')" :help="robotsHelp">
<URadioGroup
v-model="values.robots"
:items="robotsItems"
orientation="horizontal"
variant="card"
indicator="hidden"
:legend="t('Default robots policy')"
:ui="{
root: 'w-full',
legend: 'sr-only',
fieldset: 'w-full gap-0',
item: 'flex-1 rounded-none border-accented p-0 text-toned has-data-[state=checked]:border-accented has-data-[state=checked]:bg-(--rc-sand) has-data-[state=checked]:text-(--rc-on-sand)',
label: 'flex h-11 w-full items-center justify-center text-sm font-normal text-inherit md:h-8',
wrapper: 'w-full',
}"
/>
</DirectoryField>
<DirectoryField
:label="t('Locale')"
optional
:error="errors.locale"
:help="t('A language tag. Leave empty if the site has no single language.')"
>
<UInput
v-model="values.locale"
:placeholder="t('e.g. en or fr-CH')"
:color="errors.locale ? 'error' : undefined"
autocomplete="off"
class="w-full"
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
@update:model-value="errors.locale = ''"
/>
</DirectoryField>
<USwitch
v-model="values.makeDefault"
:disabled="isCurrentDefault"
:label="switchLabel"
:description="switchHelp"
:ui="{
root: 'items-start gap-3',
base: 'mt-0.5 h-4.5 w-8',
thumb: 'size-3.5',
label: 'font-medium text-highlighted',
description: 'text-xs/4.5 text-pretty text-muted',
}"
/>
<template #preview>
<div class="flex flex-col gap-1 bg-elevated p-3">
<span class="text-xs text-muted">{{ t('Canonical URL preview') }}</span>
<span class="font-mono text-xs/4.5 break-all text-highlighted">{{
previewUrl
}}</span>
<span v-if="errors.url" class="text-xs text-muted">{{
t('Shown when the base URL is valid.')
}}</span>
</div>
</template>
</DirectoryPanel>
</template>
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { t } from '@/i18n'
const emit = defineEmits<{ back: [] }>()
</script>
<template>
<UEmpty
variant="naked"
icon="i-lucide-lock"
:title="t('You don\'t have access to Sites')"
:description="
t('Only the owner can manage sites. Ask the owner of this site if you need a change.')
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-85 text-pretty text-muted',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-sm text-highlighted ring-0 sm:h-8"
@click="emit('back')"
>
{{ t('Back to Settings') }}
</UButton>
</template>
</UEmpty>
</template>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import DirectoryField from '@/components/DirectoryField.vue'
import type { SlugMode } from '@/composables/useSlugField'
const props = withDefaults(
defineProps<{
label: string
mode: SlugMode
value: string
name?: string
error?: string
suggestion?: string
help?: string
manualHelp?: string
locked?: boolean
lockedHelp?: string
editLabel?: string
}>(),
{
name: undefined,
error: '',
suggestion: '',
help: '',
manualHelp: '',
locked: false,
lockedHelp: '',
editLabel: '',
},
)
const emit = defineEmits<{
'update:value': [value: string]
unlock: []
reset: []
suggest: [value: string]
blur: []
}>()
const readonly = computed(() => props.locked || props.mode === 'empty' || props.mode === 'auto')
const showAuto = computed(() => props.mode === 'auto' && !props.locked)
const inputUi = computed(() => ({
base: `h-11 font-mono text-base md:h-10 md:text-code ${readonly.value ? 'bg-elevated' : ''} ${showAuto.value ? 'max-md:h-11.5' : ''}`,
}))
const showReset = computed(
() => !props.locked && (props.mode === 'manual' || (props.mode === 'saved' && props.error)),
)
const shownHelp = computed(() => {
if (props.locked) {
return props.lockedHelp
}
if (props.mode === 'manual' || props.mode === 'saved') {
return props.manualHelp || props.help
}
return props.help
})
const pencilLabel = computed(() => props.editLabel || t('Edit slug'))
</script>
<template>
<div class="flex min-w-0 flex-col gap-1.5">
<DirectoryField :label="label" :name="name" :error="error" :help="shownHelp">
<UInput
:model-value="value"
:readonly="readonly"
:placeholder="t('Generated from the name')"
:color="error ? 'error' : undefined"
:highlight="mode === 'manual' && !error"
class="w-full"
:ui="inputUi"
@update:model-value="emit('update:value', String($event))"
@blur="emit('blur')"
>
<template v-if="locked" #trailing>
<UIcon
name="i-lucide-lock"
data-testid="slug-lock"
class="size-3.5 text-muted"
/>
</template>
<template v-else-if="showAuto" #trailing>
<span class="flex items-center gap-1.5">
<span
class="inline-flex h-5 items-center border border-dashed border-accented px-1.5 font-mono text-2xs font-medium text-toned"
>{{ t('Auto') }}</span
>
<UButton
icon="i-lucide-pencil"
color="neutral"
variant="ghost"
:aria-label="pencilLabel"
class="size-7 justify-center text-highlighted max-md:size-11"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('unlock')"
/>
</span>
</template>
</UInput>
</DirectoryField>
<div v-if="suggestion" class="flex flex-wrap items-center gap-2">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-wand-sparkles"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3' }"
@click="emit('suggest', suggestion)"
>
{{ t('Use {0}', [suggestion]) }}
</UButton>
</div>
<UButton
v-if="showReset"
color="neutral"
variant="link"
icon="i-lucide-rotate-ccw"
class="min-h-11 gap-1 self-start p-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-5"
:ui="{ leadingIcon: 'size-3' }"
@click="emit('reset')"
>
{{ t('Reset to match the name') }}
</UButton>
</div>
</template>
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import DirectoryField from '@/components/DirectoryField.vue'
import {
MAX_SOCIAL_LINKS,
SOCIAL_PLACEHOLDERS,
SOCIAL_TYPES,
type SocialLinkRow,
type SocialType,
} from '@/lib/socialLinks'
defineProps<{
errors: Record<number, string>
disabled?: boolean
}>()
const emit = defineEmits<{ blur: [key: number] }>()
const rows = defineModel<SocialLinkRow[]>({ default: () => [] })
const typeItems = computed(() => [
{ label: 'X', value: 'x' },
{ label: 'LinkedIn', value: 'linkedin' },
{ label: 'GitHub', value: 'github' },
{ label: 'Mastodon', value: 'mastodon' },
{ label: t('Custom'), value: 'custom' },
])
const full = computed(() => rows.value.length >= MAX_SOCIAL_LINKS)
const counter = computed(() => t('{0} of {1}', [rows.value.length, MAX_SOCIAL_LINKS]))
function nextKey() {
return rows.value.reduce((highest, row) => Math.max(highest, row.key), 0) + 1
}
function add() {
if (full.value) {
return
}
rows.value = [...rows.value, { key: nextKey(), type: SOCIAL_TYPES[0], url: '' }]
}
function remove(key: number) {
rows.value = rows.value.filter((row) => row.key !== key)
}
function update(key: number, patch: Partial<SocialLinkRow>) {
rows.value = rows.value.map((row) => (row.key === key ? { ...row, ...patch } : row))
}
</script>
<template>
<div class="flex flex-col gap-2">
<span class="font-medium text-highlighted"
>{{ t('Social links') }}
<span class="font-normal text-muted"> {{ t('(optional)') }}</span></span
>
<DirectoryField v-for="(row, index) in rows" :key="row.key" :error="errors[row.key]">
<div class="flex items-start gap-2" data-testid="social-link-row">
<USelect
:model-value="row.type"
:items="typeItems"
:disabled="disabled"
:aria-label="t('Link type {0}', [index + 1])"
class="w-27 shrink-0 max-md:w-28"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
@update:model-value="update(row.key, { type: $event as SocialType })"
/>
<UInput
:model-value="row.url"
:placeholder="SOCIAL_PLACEHOLDERS[row.type]"
:aria-label="t('Link URL {0}', [index + 1])"
:color="errors[row.key] ? 'error' : undefined"
:disabled="disabled"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 font-mono text-base md:h-10 md:text-code' }"
@update:model-value="update(row.key, { url: String($event) })"
@blur="emit('blur', row.key)"
/>
<UButton
icon="i-lucide-trash-2"
color="neutral"
variant="outline"
:disabled="disabled"
:aria-label="t('Remove link {0}', [index + 1])"
class="size-11 shrink-0 justify-center border border-accented bg-default text-muted ring-0 md:size-10"
@click="remove(row.key)"
/>
</div>
</DirectoryField>
<div class="flex items-center gap-3">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-plus"
:disabled="disabled || full"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="add"
>
{{ t('Add link') }}
</UButton>
<span v-if="full" class="text-xs text-muted">{{
t('You can add up to {0} links.', [MAX_SOCIAL_LINKS])
}}</span>
<span class="text-xs text-muted">{{ counter }}</span>
</div>
</div>
</template>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import type { LinkableUser } from '@/lib/directoryTypes'
const props = withDefaults(
defineProps<{
users: LinkableUser[]
status: 'loading' | 'ready' | 'error'
idlePlaceholder?: string
clearable?: boolean
disabled?: boolean
invalid?: boolean
}>(),
{ idlePlaceholder: '', clearable: false, disabled: false, invalid: false },
)
const emit = defineEmits<{ retry: []; open: [] }>()
const menuOpen = defineModel<boolean>('open', { default: false })
const userId = defineModel<number | null>({ default: null })
const items = computed(() =>
props.users.length === 0
? []
: [
{ type: 'label' as const, label: t('Users without an author') },
...props.users.map((user) => ({
label: user.label,
description: user.email,
value: user.id,
avatarName: user.label,
})),
],
)
const placeholder = computed(() =>
menuOpen.value || !props.idlePlaceholder ? t('Choose a user') : props.idlePlaceholder,
)
const chosen = computed(() => props.users.find((user) => user.id === userId.value))
function onOpen(value: boolean) {
if (value) {
emit('open')
}
}
</script>
<template>
<USelectMenu
v-model="userId"
v-model:open="menuOpen"
:portal="false"
:content="{ position: 'inline' }"
:items="items"
value-key="value"
:search-input="false"
:placeholder="placeholder"
:loading="status === 'loading'"
:disabled="disabled"
:color="invalid ? 'error' : undefined"
:highlight="invalid"
:clear="clearable && userId !== null"
class="w-full"
:ui="{
base: 'h-11 rounded-none text-lg ring-(--ui-border-accented) md:h-10 md:text-sm',
trailingIcon: 'size-3.5 text-muted',
content: 'rounded-none shadow-(--shadow-popover)',
label: 'px-2 py-1.5 text-xs text-muted',
item: 'min-h-12 gap-2 px-2 py-1 data-[state=checked]:bg-elevated md:min-h-9',
itemDescription: 'truncate text-xs text-muted',
placeholder: 'text-muted',
}"
@update:open="onOpen"
>
<template #default>
<span v-if="chosen" class="truncate text-highlighted">{{ chosen.label }}</span>
<span v-else class="truncate text-muted">{{ placeholder }}</span>
</template>
<template #item-leading="{ item }">
<AuthorAvatar v-if="'avatarName' in item" :name="String(item.avatarName)" :size="24" />
</template>
<template #empty>
<div v-if="status === 'error'" class="flex flex-col items-start gap-2 p-3">
<span class="text-error">{{ t("Couldn't load users") }}</span>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry')"
>
{{ t('Try again') }}
</UButton>
</div>
<div
v-else-if="status === 'loading'"
class="flex flex-col gap-2.5 p-3"
aria-busy="true"
>
<USkeleton
v-for="n in 3"
:key="n"
class="h-3 rounded-none bg-accented"
:class="n === 3 ? 'w-3/5' : 'w-4/5'"
/>
</div>
<div v-else class="p-3 text-muted">{{ t('Every user already has an author.') }}</div>
</template>
</USelectMenu>
</template>
@@ -62,8 +62,8 @@ afterEach(async () => {
document.body.innerHTML = ''
})
describe('DashboardShell renders the seven navigation destinations', () => {
it('lists Home, Articles, Media, Users, Activity, Integration and Settings in order, with only Articles carrying aria-current', async () => {
describe('DashboardShell renders the eight navigation destinations', () => {
it('lists Home, Articles, Media, Authors, Users, Activity, Integration and Settings in order, with only Articles carrying aria-current', async () => {
const wrapper = await mountShell('/article')
const hrefs = wrapper
@@ -73,6 +73,7 @@ describe('DashboardShell renders the seven navigation destinations', () => {
'/home',
'/article',
'/media',
'/authors',
'/users',
'/activity',
'/integration',
@@ -90,7 +91,7 @@ describe('DashboardShell renders the seven navigation destinations', () => {
expect(integration.attributes('aria-current')).toBeUndefined()
})
it('lists the same seven destinations in the mobile drawer and Settings current', async () => {
it('lists the same eight destinations in the mobile drawer and Settings current', async () => {
await mountShell('/settings', { width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
@@ -104,13 +105,14 @@ describe('DashboardShell renders the seven navigation destinations', () => {
'/home',
'/article',
'/media',
'/authors',
'/users',
'/activity',
'/integration',
'/settings',
])
expect(links[6].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[6].getAttribute('aria-current')).toBe('page')
expect(links[7].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[7].getAttribute('aria-current')).toBe('page')
})
it('marks Media with aria-current on /media, in the sidebar and in the mobile drawer', async () => {
@@ -269,11 +271,11 @@ describe('The account chip shows the display name and no item is current on /pro
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
})
it('marks none of the seven navigation items current on /profile', async () => {
it('marks none of the eight navigation items current on /profile', async () => {
const wrapper = await mountShell('/profile')
const links = wrapper.findAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(7)
expect(links).toHaveLength(8)
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
})
})
@@ -747,7 +749,7 @@ describe('DashboardShell mobile drawer rows', () => {
const drawer = await openDrawer('/settings')
const links = Array.from(drawer.querySelectorAll('nav[aria-label="Main"] a'))
expect(links).toHaveLength(9)
expect(links).toHaveLength(11)
for (const link of links) {
expect(classesOf(link)).toContain('max-lg:h-11')
}
@@ -0,0 +1,115 @@
import { afterEach, describe, expect, it } from 'vitest'
import { h } from 'vue'
import { flushPromises } from '@vue/test-utils'
import DirectoryList from '../DirectoryList.vue'
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
import { MOBILE_WIDTH } from './viewport'
import { byText, mountInApp, text, unmountApp } from './directoryKit'
interface Row {
id: number
name: string
}
const ROWS: Row[] = [
{ id: 1, name: 'First' },
{
id: 2,
name: 'A very long name that must be truncated in the row and shown in full on hover',
},
]
function menu(row: Row): DirectoryMenuItem[] {
return [
{ key: 'edit', label: 'Edit', icon: 'i-lucide-pencil', onSelect: () => undefined },
{
key: 'delete',
label: 'Delete…',
icon: 'i-lucide-trash-2',
destructive: true,
separated: true,
unavailable: row.id === 1,
hint: 'Not on the first row.',
onSelect: () => undefined,
},
]
}
function props(extra: Record<string, unknown> = {}) {
return {
items: ROWS,
columns: 'minmax(0,1fr) 44px',
headings: ['Name', ''],
label: 'Rows',
rowTitle: (row: Row) => row.name,
menuItems: menu,
...extra,
}
}
const slots = {
cells: ({ item }: { item: Row }) =>
h('span', { class: 'truncate', title: item.name }, item.name),
card: ({ item }: { item: Row }) =>
h('span', { class: 'truncate', title: item.name }, item.name),
}
afterEach(unmountApp)
describe('DirectoryList', () => {
it('draws a header and one row per item with a menu button on desktop', async () => {
await mountInApp(DirectoryList, props(), slots)
expect(
text(document.querySelector('[data-testid="directory-row"]')!.previousElementSibling),
).toBe('Name')
expect(document.querySelectorAll('[data-testid="directory-row"]')).toHaveLength(2)
expect(byText('button', 'Actions for First')).toBeTruthy()
})
it('keeps the full name available on hover for a truncated row', async () => {
await mountInApp(DirectoryList, props(), slots)
const long = document.querySelectorAll<HTMLElement>('span.truncate')[1]!
expect(long.getAttribute('title')).toBe(ROWS[1]!.name)
})
it('draws cards below md', async () => {
await mountInApp(DirectoryList, props(), slots, MOBILE_WIDTH)
expect(document.querySelectorAll('[data-testid="directory-card"]')).toHaveLength(2)
expect(document.querySelector('[data-testid="directory-row"]')).toBeNull()
})
it('draws skeleton rows with aria-busy while loading', async () => {
await mountInApp(DirectoryList, props({ loading: true, skeletonRows: 4 }), slots)
expect(document.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(document.querySelectorAll('[data-testid="directory-skeleton-row"]')).toHaveLength(4)
expect(document.querySelector('[data-testid="directory-row"]')).toBeNull()
})
it('swaps the menu for a chevron and emits open in the read-only variant', async () => {
const wrapper = await mountInApp(DirectoryList, props({ readOnly: true }), slots)
expect(document.querySelector('button[aria-label^="Actions for"]')).toBeNull()
byText('button', 'View First')!.click()
await flushPromises()
const list = wrapper.findComponent(DirectoryList)
expect(list.emitted('open')![0]).toEqual([ROWS[0]])
})
it('keeps an unavailable item focusable with aria-disabled and its hint', async () => {
await mountInApp(DirectoryList, props(), slots)
byText('button', 'Actions for First')!.click()
await flushPromises()
const item = byText('[role="menuitem"]', /^Delete…/)!
expect(item.getAttribute('aria-disabled')).toBe('true')
expect(item.hasAttribute('disabled')).toBe(false)
expect(text(document.getElementById(item.getAttribute('aria-describedby')!))).toBe(
'Not on the first row.',
)
})
})
@@ -0,0 +1,122 @@
import { afterEach, describe, expect, it } from 'vitest'
import { h } from 'vue'
import { flushPromises } from '@vue/test-utils'
import DirectoryPanel from '../DirectoryPanel.vue'
import { MOBILE_WIDTH } from './viewport'
import { byText, mountInApp, text, unmountApp } from './directoryKit'
function body() {
return h('input', { 'data-autofocus': '', placeholder: 'Name' })
}
function content() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')!
}
function escape() {
content().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
return flushPromises()
}
afterEach(unmountApp)
describe('DirectoryPanel', () => {
it('opens a right slide-over on desktop with a title, a Cancel and a submit button', async () => {
await mountInApp(
DirectoryPanel,
{ title: 'Add thing', submitLabel: 'Add thing' },
{ default: body },
)
expect(text(content().querySelector('h2'))).toBe('Add thing')
expect(content().className).toContain('max-w-120')
expect(byText('button', 'Cancel', content())).toBeTruthy()
expect(byText('button', 'Add thing', content())).toBeTruthy()
})
it('opens a 92 percent bottom sheet below md', async () => {
await mountInApp(
DirectoryPanel,
{ title: 'Add thing', submitLabel: 'Add thing' },
{ default: body },
MOBILE_WIDTH,
)
expect(content().className).toContain('h-23/25')
expect(content().className).toContain('before:w-9')
})
it('moves focus into the panel', async () => {
await mountInApp(
DirectoryPanel,
{ title: 'Add thing', submitLabel: 'Add' },
{ default: body },
)
await flushPromises()
expect(content().contains(document.activeElement)).toBe(true)
})
it('closes straight away when nothing changed', async () => {
const wrapper = await mountInApp(
DirectoryPanel,
{ title: 'T', submitLabel: 'Add' },
{ default: body },
)
await escape()
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toHaveLength(1)
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
})
it('asks Discard changes? when the form is dirty and closes only on confirmation', async () => {
const wrapper = await mountInApp(
DirectoryPanel,
{ title: 'T', submitLabel: 'Add', dirty: true },
{ default: body },
)
await escape()
const ask = document.body.querySelector<HTMLElement>('[role="alertdialog"]')!
expect(text(ask)).toContain('Discard changes?')
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toBeUndefined()
byText('button', 'Discard changes', ask)!.click()
await flushPromises()
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toHaveLength(1)
})
it('ignores Escape while a request is running and labels the button Saving…', async () => {
const wrapper = await mountInApp(
DirectoryPanel,
{ title: 'T', submitLabel: 'Add', saving: true, dirty: true },
{ default: body },
)
await escape()
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toBeUndefined()
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
expect(byText('button', 'Saving…', content())).toBeTruthy()
expect(byText('button', 'Cancel', content())!.hasAttribute('disabled')).toBe(true)
})
it('shows the failure notice inside the panel', async () => {
await mountInApp(
DirectoryPanel,
{ title: 'T', submitLabel: 'Add', notice: { description: 'Could not save.' } },
{ default: body },
)
expect(text(content().querySelector('[role="alert"]'))).toBe('Could not save.')
})
it('offers Close as the only action in the read-only variant', async () => {
await mountInApp(DirectoryPanel, { title: 'Detail', readOnly: true }, { default: body })
const labels = Array.from(content().querySelectorAll('button'))
.map((button) => text(button) || button.getAttribute('aria-label'))
.filter(Boolean)
expect(labels.filter((label) => label !== 'Close')).toEqual([])
expect(byText('button', 'Cancel', content())).toBeUndefined()
})
})
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import MediaField from '../MediaField.vue'
import { useApi } from '@/composables/useApi'
import { byText, mountInApp, text, unmountApp } from './directoryKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const ITEM = {
id: 55,
filename: 'face.jpg',
url: '/media/face.jpg',
size: 1000,
content_type: 'image/jpeg',
width: 400,
height: 400,
sha256: '0abc',
uploaded_by: 'Marta Alves',
created_at: '2026-09-12T12:00:00Z',
used_in: 0,
duplicate: false,
variant_count: 0,
variant_size: 0,
}
const PICKER = {
title: 'Choose an image',
description: 'The default social image for Journal.',
hint: 'Shown as a square.',
confirmLabel: 'Use image',
}
beforeEach(() => {
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: vi.fn().mockResolvedValue({
items: [ITEM],
total: 1,
page: 1,
size: 25,
counts: { all: 1, unused: 1, duplicates: 0 },
}),
} as unknown as ReturnType<typeof useApi>)
})
afterEach(unmountApp)
describe('MediaField', () => {
it('shows an optional label, its help and a Choose image button when empty', async () => {
await mountInApp(MediaField, {
label: 'Default social image',
help: 'Used when an article has no image of its own.',
picker: PICKER,
})
const content = text(document.body)
expect(content).toContain('Default social image')
expect(content).toContain('(optional)')
expect(content).toContain('Used when an article has no image of its own.')
expect(byText('button', 'Choose image')).toBeTruthy()
expect(byText('button', 'Remove')).toBeUndefined()
})
it('opens the existing picker with the given title, description, hint and confirm label', async () => {
await mountInApp(MediaField, {
label: 'Avatar',
picker: PICKER,
square: true,
avatarName: 'Camille',
})
byText('button', 'Choose image')!.click()
await flushPromises()
const picker = document.body.querySelector<HTMLElement>('[role="dialog"]')!
expect(text(picker)).toContain('Choose an image')
expect(text(picker)).toContain('The default social image for Journal.')
expect(text(picker)).toContain('Shown as a square.')
expect(byText('button', 'Use image', picker)!.hasAttribute('disabled')).toBe(true)
})
it('returns the picked image id through Use image and offers Change image and Remove', async () => {
const wrapper = await mountInApp(MediaField, { label: 'Avatar', picker: PICKER })
byText('button', 'Choose image')!.click()
await flushPromises()
const picker = document.body.querySelector<HTMLElement>('[role="dialog"]')!
picker.querySelector<HTMLElement>('[data-picker-tile]')!.click()
await flushPromises()
byText('button', 'Use image', picker)!.click()
await flushPromises()
const field = wrapper.findComponent(MediaField)
expect(field.emitted('update:modelValue')![0]).toEqual([55])
expect(byText('button', 'Change image')).toBeTruthy()
expect(byText('button', 'Remove')).toBeTruthy()
expect(text(document.body)).toContain('face.jpg')
})
it('shows the chosen filename, Change image and Remove, and Remove clears the id', async () => {
const wrapper = await mountInApp(MediaField, {
label: 'Avatar',
picker: PICKER,
modelValue: 55,
item: ITEM,
})
expect(text(document.body)).toContain('face.jpg')
expect(byText('button', 'Change image')).toBeTruthy()
byText('button', 'Remove')!.click()
await flushPromises()
expect(wrapper.findComponent(MediaField).emitted('update:modelValue')![0]).toEqual([null])
})
it('draws the avatar variant as a square with initials when no image is chosen', async () => {
await mountInApp(MediaField, {
label: 'Avatar',
picker: PICKER,
square: true,
avatarName: 'Camille Roche',
})
const avatar = document.body.querySelector<HTMLElement>(
'[data-slot="root"] [style*="64px"]',
)
expect(avatar ?? document.body.querySelector('[style*="width: 64px"]')).not.toBeNull()
expect(text(document.body)).toContain('CR')
})
})
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import ReassignDialog from '../ReassignDialog.vue'
import { byText, mountInApp, text, unmountApp } from './directoryKit'
const OPTIONS = [
{ value: 1, label: 'Renew', description: 'renew.example', display: 'Renew · renew.example' },
{
value: 3,
label: 'Projects',
description: 'projects.example',
display: 'Projects · projects.example',
},
]
function props(extra: Record<string, unknown> = {}) {
return {
title: 'Delete this thing?',
description: 'It will be deleted.',
confirmLabel: 'Move 5 and delete',
pendingLabel: 'Moving…',
selectLabel: 'Move to',
options: OPTIONS,
selected: 1,
summary: '5 will move to Renew.',
...extra,
}
}
function dialog() {
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')!
}
afterEach(unmountApp)
describe('ReassignDialog', () => {
it('shows the copy, the selected option, the summary and both buttons', async () => {
await mountInApp(ReassignDialog, props())
const content = text(dialog())
expect(content).toContain('Delete this thing?')
expect(content).toContain('It will be deleted.')
expect(content).toContain('Move to')
expect(text(dialog().querySelector('button[aria-haspopup="listbox"]'))).toBe(
'Renew · renew.example',
)
expect(content).toContain('5 will move to Renew.')
expect(byText('button', 'Cancel', dialog())).toBeTruthy()
expect(byText('button', 'Move 5 and delete', dialog())).toBeTruthy()
})
it('lists the options it is given with a name and a description', async () => {
await mountInApp(ReassignDialog, props())
dialog().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
await flushPromises()
const labels = Array.from(dialog().querySelectorAll('[role="option"]')).map(text)
expect(labels).toEqual(['Renew renew.example', 'Projects projects.example'])
})
it('draws no select when there are no options', async () => {
await mountInApp(ReassignDialog, props({ options: [], summary: '' }))
expect(dialog().querySelector('button[aria-haspopup="listbox"]')).toBeNull()
})
it('switches the confirm label and disables the buttons while pending', async () => {
await mountInApp(ReassignDialog, props({ pending: true }))
expect(byText('button', 'Moving…', dialog())).toBeTruthy()
expect(byText('button', 'Cancel', dialog())!.hasAttribute('disabled')).toBe(true)
expect(dialog().getAttribute('aria-busy')).toBe('true')
})
it('shows the failure notice with its title and text', async () => {
await mountInApp(
ReassignDialog,
props({ notice: { title: 'Failed', description: 'Nothing was changed.' } }),
)
expect(text(dialog().querySelector('[role="alert"]'))).toBe('Failed Nothing was changed.')
})
it('emits confirm from the primary button', async () => {
const wrapper = await mountInApp(ReassignDialog, props())
byText('button', 'Move 5 and delete', dialog())!.click()
await flushPromises()
expect(wrapper.findComponent(ReassignDialog).emitted('confirm')).toHaveLength(1)
})
})
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import SlugField from '../SlugField.vue'
import { byText, mountInApp, text, unmountApp } from './directoryKit'
function props(extra: Record<string, unknown> = {}) {
return {
label: 'Slug',
mode: 'empty',
value: '',
help: 'Generated from the name.',
manualHelp: 'Lowercase letters.',
...extra,
}
}
function input() {
return document.body.querySelector<HTMLInputElement>('input')!
}
afterEach(unmountApp)
describe('SlugField', () => {
it('shows a read-only placeholder when there is no name yet', async () => {
await mountInApp(SlugField, props())
expect(input().readOnly).toBe(true)
expect(input().placeholder).toBe('Generated from the name')
expect(text(document.body)).toContain('Generated from the name.')
})
it('shows the automatic value with the Auto badge and a pencil that unlocks it', async () => {
const wrapper = await mountInApp(SlugField, props({ mode: 'auto', value: 'camille-roche' }))
expect(input().value).toBe('camille-roche')
expect(input().readOnly).toBe(true)
expect(text(document.body)).toContain('Auto')
byText('button', 'Edit slug')!.click()
await flushPromises()
expect(wrapper.findComponent(SlugField).emitted('unlock')).toHaveLength(1)
})
it('is editable in manual mode and offers Reset to match the name', async () => {
const wrapper = await mountInApp(SlugField, props({ mode: 'manual', value: 'custom' }))
const field = wrapper.findComponent(SlugField)
expect(input().readOnly).toBe(false)
expect(text(document.body)).toContain('Lowercase letters.')
input().value = 'custom-2'
input().dispatchEvent(new Event('input', { bubbles: true }))
expect(field.emitted('update:value')![0]).toEqual(['custom-2'])
byText('button', 'Reset to match the name')!.click()
await flushPromises()
expect(field.emitted('reset')).toHaveLength(1)
})
it('shows the error and a suggestion that emits its value', async () => {
const wrapper = await mountInApp(
SlugField,
props({
mode: 'manual',
value: 'camille-roche',
error: 'Already used.',
suggestion: 'camille-roche-2',
}),
)
expect(text(document.body)).toContain('Already used.')
byText('button', 'Use camille-roche-2')!.click()
await flushPromises()
expect(wrapper.findComponent(SlugField).emitted('suggest')![0]).toEqual(['camille-roche-2'])
})
it('draws the locked variant with a lock and its own help, with no pencil', async () => {
await mountInApp(
SlugField,
props({ mode: 'saved', value: 'renew', locked: true, lockedHelp: "Can't change." }),
)
expect(input().readOnly).toBe(true)
expect(document.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
expect(text(document.body)).toContain("Can't change.")
expect(byText('button', 'Edit slug')).toBeUndefined()
})
})
@@ -0,0 +1,60 @@
import { defineComponent, h, type Component } from 'vue'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { DESKTOP_WIDTH, setViewportWidth } from './viewport'
export const holder: { wrapper: VueWrapper | null } = { wrapper: null }
export async function mountInApp(
component: Component,
props: Record<string, unknown> = {},
slots: Record<string, () => unknown> = {},
width = DESKTOP_WIDTH,
) {
setViewportWidth(width)
Element.prototype.scrollIntoView = () => undefined
const pinia = createPinia()
setActivePinia(pinia)
holder.wrapper = mount(
defineComponent({
render: () =>
h(UApp, null, {
default: () => h(component, props, slots as never),
}),
}),
{ attachTo: document.body, global: { plugins: [router, uiPlugin, pinia] } },
)
await flushPromises()
return holder.wrapper
}
export function unmountApp() {
holder.wrapper?.unmount()
holder.wrapper = null
document.body.innerHTML = ''
}
export function text(element: Element | null | undefined) {
if (!element) {
return ''
}
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
const parts: string[] = []
while (walker.nextNode()) {
const value = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim()
if (value) {
parts.push(value)
}
}
return parts.join(' ')
}
export function byText(selector: string, label: string | RegExp, root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLElement>(selector)).find((element) => {
const value = element.getAttribute('aria-label') || text(element)
return typeof label === 'string' ? value === label : label.test(value)
})
}
@@ -0,0 +1,219 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { ApiError } from '@/composables/useApi'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
import { useDirectoryView } from '@/composables/useDirectoryView'
import { useSlugField } from '@/composables/useSlugField'
import router from '@/router'
let wrapper: VueWrapper | null = null
function inComponent<T>(setup: () => T) {
let result!: T
wrapper = mount(
defineComponent({
setup() {
result = setup()
return () => h('div')
},
}),
{ global: { plugins: [router] } },
)
return result
}
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve, reject }
}
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
})
describe('useDelayedSkeleton', () => {
it('shows only after the delay and hides on stop', async () => {
vi.useFakeTimers()
const skeleton = inComponent(() => useDelayedSkeleton(150))
skeleton.start()
await vi.advanceTimersByTimeAsync(149)
expect(skeleton.visible.value).toBe(false)
await vi.advanceTimersByTimeAsync(1)
expect(skeleton.visible.value).toBe(true)
skeleton.stop()
expect(skeleton.visible.value).toBe(false)
})
})
describe('useDirectoryCollection', () => {
it('loads, keeps the data on a later load and ignores a stale answer', async () => {
const first = deferred<string>()
const second = deferred<string>()
const answers = [first, second]
const collection = inComponent(() => useDirectoryCollection(() => answers.shift()!.promise))
const early = collection.load([])
const late = collection.load([])
second.resolve('new')
first.resolve('old')
expect(await late).toBe('ready')
expect(await early).toBe('stale')
expect(collection.data.value).toBe('new')
expect(collection.state.value).toBe('ready')
})
it('can refuse a result without committing it', async () => {
const collection = inComponent(() => useDirectoryCollection(async () => 'page'))
expect(await collection.load([], { accept: () => false })).toBe('rejected')
expect(collection.data.value).toBeNull()
})
it('maps a 403 to forbidden, other failures to error and ignores a 401', async () => {
const failures = [new ApiError(403), new ApiError(500), new ApiError(401)]
const collection = inComponent(() =>
useDirectoryCollection(async () => {
throw failures.shift()
}),
)
await collection.load([])
expect(collection.state.value).toBe('forbidden')
await collection.load([])
expect(collection.state.value).toBe('error')
expect(await collection.load([])).toBe('stale')
expect(collection.state.value).toBe('loading')
})
it('draws the skeleton only after the delay on the first load', async () => {
vi.useFakeTimers()
const hold = deferred<string>()
const collection = inComponent(() => useDirectoryCollection(() => hold.promise))
const loading = collection.load([])
expect(collection.showSkeleton.value).toBe(false)
await vi.advanceTimersByTimeAsync(150)
expect(collection.showSkeleton.value).toBe(true)
hold.resolve('done')
await loading
expect(collection.showSkeleton.value).toBe(false)
})
it('drops an answer that arrives after the component is gone', async () => {
const hold = deferred<string>()
const collection = inComponent(() => useDirectoryCollection(() => hold.promise))
const loading = collection.load([])
wrapper!.unmount()
wrapper = null
hold.resolve('late')
expect(await loading).toBe('stale')
expect(collection.data.value).toBeNull()
})
})
describe('useDirectoryDelete', () => {
it('runs once, clears the notice and reports failures to the handler', async () => {
const removal = useDirectoryDelete<{ id: number }>()
removal.open({ id: 1 })
removal.notice.value = { description: 'old' }
const run = vi.fn().mockRejectedValue(new Error('no'))
const onFailure = vi.fn()
const first = removal.confirm(run, onFailure)
await removal.confirm(run, onFailure)
await first
expect(run).toHaveBeenCalledTimes(1)
expect(onFailure).toHaveBeenCalledWith(expect.any(Error), { id: 1 })
expect(removal.pending.value).toBe(false)
expect(removal.notice.value).toBeNull()
})
it('does nothing without a target and closes cleanly', async () => {
const removal = useDirectoryDelete<{ id: number }>()
const run = vi.fn()
await removal.confirm(run, vi.fn())
expect(run).not.toHaveBeenCalled()
removal.open({ id: 2 })
removal.close()
expect(removal.target.value).toBeNull()
})
})
describe('useSlugField', () => {
it('follows the name until unlocked and goes back with reset', () => {
const slug = useSlugField(32)
expect(slug.mode.value).toBe('empty')
slug.setSource('Camille Roche')
expect(slug.mode.value).toBe('auto')
expect(slug.value.value).toBe('camille-roche')
slug.unlock()
slug.setSource('Someone Else')
expect(slug.mode.value).toBe('manual')
expect(slug.value.value).toBe('camille-roche')
slug.reset()
expect(slug.value.value).toBe('someone-else')
expect(slug.mode.value).toBe('auto')
})
it('leaves the saved mode as soon as the value is edited and reset follows the name', () => {
const slug = useSlugField(100, 'ines-duarte')
slug.setSource('Inès Duarte')
expect(slug.mode.value).toBe('saved')
slug.setValue('ines-d')
expect(slug.mode.value).toBe('manual')
slug.setValue('ines-duarte')
expect(slug.mode.value).toBe('saved')
slug.setValue('other')
slug.reset()
expect(slug.value.value).toBe('ines-duarte')
})
})
describe('useDirectoryView', () => {
beforeEach(() => setActivePinia(createPinia()))
it('opens a deep link once and ignores a lookup that finishes after unmount', async () => {
await router.push('/authors?edit=zoe')
const hold = deferred<string | null>()
const view = inComponent(() => useDirectoryView())
const open = vi.fn()
const missing = vi.fn()
const following = view.followEditLink(() => hold.promise, open, missing)
await view.followEditLink(() => hold.promise, open, missing)
wrapper!.unmount()
wrapper = null
hold.resolve('found')
await following
expect(open).not.toHaveBeenCalled()
expect(missing).not.toHaveBeenCalled()
await router.push('/login')
})
it('reports an unknown value once and drops it from the URL', async () => {
await router.push('/authors?edit=nobody&q=x')
const view = inComponent(() => useDirectoryView())
const missing = vi.fn()
await view.followEditLink(() => null, vi.fn(), missing)
await flushPromises()
expect(missing).toHaveBeenCalledTimes(1)
expect(router.currentRoute.value.query).toEqual({ q: 'x' })
await router.push('/login')
})
})
+10 -1
View File
@@ -23,6 +23,12 @@ export class NetworkError extends Error {
export type UserRole = 'owner' | 'admin'
export interface MeAuthor {
id: number
name: string
slug: string
}
export interface MeResult {
id?: number
verified: boolean
@@ -30,6 +36,7 @@ export interface MeResult {
email?: string
displayName: string
role?: UserRole
author?: MeAuthor
}
export type OnUnauthorized = 'redirect' | 'passthrough'
@@ -129,7 +136,7 @@ export function useApi() {
if (response.status === 403) {
redirectUnverified(onForbidden)
throw new ApiError(403)
throw new ApiError(403, undefined, await readErrorBody(response))
}
if (response.status === 429) {
@@ -165,6 +172,7 @@ export function useApi() {
email?: string
display_name?: string | null
role?: string
author?: MeAuthor | null
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
return {
id: body.id,
@@ -173,6 +181,7 @@ export function useApi() {
email: body.email,
displayName: body.display_name ?? '',
role: body.role as UserRole | undefined,
...(body.author ? { author: body.author } : {}),
}
}
+114
View File
@@ -0,0 +1,114 @@
import { useApi } from '@/composables/useApi'
import { useUsers } from '@/composables/useUsers'
import { reassignQuery } from '@/lib/directory'
import { jsonInit } from '@/lib/directoryRequest'
import type { LinkableUser } from '@/lib/directoryTypes'
import type { SocialType } from '@/lib/socialLinks'
export interface SocialLink {
type: SocialType
url: string
}
export interface Author {
id: number
name: string
slug: string
bio: string
avatar_id: number | null
website: string | null
social_links: SocialLink[]
user_id: number | null
is_linked: boolean
article_count: number
user_email?: string
}
export interface AuthorPage {
items: Author[]
total: number
page: number
size: number
}
export interface AuthorListQuery {
q: string
page: number
size: number
}
export interface NewAuthor {
name: string
slug: string
bio: string
avatar_id: number | null
website: string | null
social_links: SocialLink[]
user_id: number | null
}
export type AuthorPatch = Partial<NewAuthor>
export type ReassignTarget = number | 'none' | undefined
const OPTION_PAGE_SIZE = 100
export function authorListPath(query: AuthorListQuery) {
const parts = [
...(query.q ? [`q=${encodeURIComponent(query.q)}`] : []),
`page=${query.page}`,
`size=${query.size}`,
]
return `/v1/author/?${parts.join('&')}`
}
export function useAuthors() {
const { request } = useApi()
const { listLinkableUsers: listUsersWithoutAuthor } = useUsers()
function listAuthors(query: AuthorListQuery): Promise<AuthorPage> {
return request<AuthorPage>(authorListPath(query))
}
async function listAllAuthors(): Promise<Author[]> {
const all: Author[] = []
let page = 1
let total = 0
do {
const response = await listAuthors({ q: '', page, size: OPTION_PAGE_SIZE })
all.push(...response.items)
total = response.total
page += 1
if (response.items.length === 0) {
break
}
} while (all.length < total)
return all
}
async function listLinkableUsers(): Promise<LinkableUser[]> {
const authors = await listAllAuthors()
return listUsersWithoutAuthor(new Set(authors.map((author) => author.user_id)))
}
function createAuthor(author: NewAuthor): Promise<Author> {
return request<Author>('/v1/author/', jsonInit('POST', author))
}
function updateAuthor(id: number, patch: AuthorPatch): Promise<Author> {
return request<Author>(`/v1/author/${id}`, jsonInit('PATCH', patch))
}
function deleteAuthor(id: number, reassignTo: ReassignTarget): Promise<void> {
return request<void>(`/v1/author/${id}${reassignQuery(reassignTo)}`, { method: 'DELETE' })
}
return {
listAuthors,
listAllAuthors,
listLinkableUsers,
createAuthor,
updateAuthor,
deleteAuthor,
}
}
+27
View File
@@ -0,0 +1,27 @@
import { onBeforeUnmount, ref } from 'vue'
export const SKELETON_DELAY_MS = 150
export function useDelayedSkeleton(delay = SKELETON_DELAY_MS) {
const visible = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
function stop() {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
visible.value = false
}
function start() {
stop()
timer = setTimeout(() => {
visible.value = true
}, delay)
}
onBeforeUnmount(stop)
return { visible, start, stop }
}
@@ -0,0 +1,58 @@
import { onBeforeUnmount, ref, type Ref } from 'vue'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { isSessionError, statusOf } from '@/lib/directoryErrors'
export type CollectionState = 'loading' | 'ready' | 'error' | 'forbidden'
export interface LoadOptions<T> {
accept?: (result: T) => boolean
}
export function useDirectoryCollection<T, A extends unknown[]>(
fetcher: (...args: A) => Promise<T>,
) {
const state = ref<CollectionState>('loading')
const data = ref<T | null>(null) as Ref<T | null>
const skeleton = useDelayedSkeleton()
let ticket = 0
let alive = true
onBeforeUnmount(() => {
alive = false
})
async function load(args: A, options: LoadOptions<T> = {}) {
ticket += 1
const current = ticket
const first = data.value === null
if (first) {
state.value = 'loading'
skeleton.start()
}
try {
const result = await fetcher(...args)
if (!alive || current !== ticket) {
return 'stale' as const
}
if (options.accept && !options.accept(result)) {
return 'rejected' as const
}
data.value = result
state.value = 'ready'
return 'ready' as const
} catch (error) {
if (!alive || current !== ticket || isSessionError(error)) {
return 'stale' as const
}
data.value = null
state.value = statusOf(error) === 403 ? 'forbidden' : 'error'
return 'failed' as const
} finally {
if (alive && current === ticket) {
skeleton.stop()
}
}
}
return { state, data, showSkeleton: skeleton.visible, load }
}
+39
View File
@@ -0,0 +1,39 @@
import { ref, type Ref } from 'vue'
import type { DirectoryNotice } from '@/lib/directoryTypes'
export function useDirectoryDelete<T>() {
const target = ref<T | null>(null) as Ref<T | null>
const pending = ref(false)
const notice = ref<DirectoryNotice | null>(null)
function open(item: T) {
notice.value = null
target.value = item
}
function close() {
target.value = null
notice.value = null
}
async function confirm(
run: (item: T) => Promise<void>,
onFailure: (error: unknown, item: T) => Promise<void> | void,
) {
const item = target.value
if (!item || pending.value) {
return
}
pending.value = true
notice.value = null
try {
await run(item)
} catch (error) {
await onFailure(error, item)
} finally {
pending.value = false
}
}
return { target, pending, notice, open, close, confirm }
}
+147
View File
@@ -0,0 +1,147 @@
import { t } from '@/i18n'
import { copy } from '@/lib/directoryCopy'
import type { FailureContext, FailureKind } from '@/lib/directoryErrors'
import { useLocalizedToast } from '@/i18n/presentation'
type ToastColor = 'success' | 'info' | 'error'
const ICONS: Record<ToastColor, string> = {
success: 'i-lucide-circle-check',
info: 'i-lucide-info',
error: 'i-lucide-circle-x',
}
export function useDirectoryToasts() {
const toast = useLocalizedToast()
function show(id: string, color: ToastColor, title: () => string, description?: () => string) {
toast.add(() => ({
id,
color,
icon: ICONS[color],
get title() {
return title()
},
get description() {
return description?.()
},
}))
}
function siteGone() {
show(
'site-gone',
'info',
() => t('That site no longer exists'),
() => t('It was deleted somewhere else, so we removed it from the list.'),
)
}
function authorGone() {
show(
'author-gone',
'info',
() => t('That author no longer exists'),
() => t('It was deleted somewhere else, so we removed it from the list.'),
)
}
function ownerOnly() {
show(
'site-owner-only',
'error',
() => t('Only the owner can manage sites'),
() => t('Ask the owner of this site.'),
)
}
function defaultSiteBlocked() {
show('site-default-blocked', 'error', () => copy('defaultSiteBlocked'))
}
function authorRights() {
show(
'author-rights',
'error',
() => t('Only an owner or admin can change authors'),
() => t('You can still edit your own author profile.'),
)
}
function siteDefaultChanged(name: string) {
show(
'site-default-changed',
'success',
() => t('Default site changed'),
() => t('"{0}" is now the default site.', [name]),
)
}
function siteDeleted(count: number, destination: string) {
show(
'site-deleted',
'success',
() => t('Site deleted'),
count > 0
? () =>
count === 1
? t('1 article moved to {0}.', [destination])
: t('{0} articles moved to {1}.', [count, destination])
: undefined,
)
}
function authorDeleted(count: number, destination: string | null) {
show(
'author-deleted',
'success',
() => t('Author deleted'),
count > 0
? () => {
if (destination === null) {
return count === 1
? t('1 article no longer has a byline.')
: t('{0} articles no longer have a byline.', [count])
}
return count === 1
? t('1 article now credits {0}.', [destination])
: t('{0} articles now credit {1}.', [count, destination])
}
: undefined,
)
}
function userUnlinked(name: string) {
show(
'author-unlinked',
'success',
() => t('User unlinked'),
() => t('{0} is now a guest author.', [name]),
)
}
function report(kind: FailureKind, context: FailureContext) {
const handlers: Partial<Record<FailureKind, () => void>> = {
forbidden: ownerOnly,
defaultSite: defaultSiteBlocked,
rights: authorRights,
gone: context === 'site' ? siteGone : authorGone,
}
const handler = handlers[kind]
handler?.()
return handler !== undefined
}
return {
report,
siteGone,
authorGone,
ownerOnly,
defaultSiteBlocked,
authorRights,
siteDefaultChanged,
siteDeleted,
authorDeleted,
userUnlinked,
}
}
+76
View File
@@ -0,0 +1,76 @@
import { nextTick, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export function useDirectoryView() {
const route = useRoute()
const router = useRouter()
let opener: HTMLElement | null = null
let followed = ''
let ticket = 0
let alive = true
onBeforeUnmount(() => {
alive = false
})
function rememberOpener(element?: HTMLElement | null) {
opener =
element ??
(document.activeElement instanceof HTMLElement ? document.activeElement : null)
}
async function restoreFocus() {
await nextTick()
if (opener?.isConnected) {
opener.focus()
}
opener = null
}
function editParam() {
const value = route.query.edit
return typeof value === 'string' ? value : Array.isArray(value) ? (value[0] ?? '') : ''
}
function dropEditParam() {
followed = ''
if (route.query.edit !== undefined) {
router.replace({
query: Object.fromEntries(
Object.entries(route.query).filter(([key]) => key !== 'edit'),
),
})
}
}
function leavePanel() {
dropEditParam()
return restoreFocus()
}
async function followEditLink<T>(
lookup: (value: string) => T | null | Promise<T | null>,
open: (item: T) => void,
missing: () => void,
) {
const value = editParam()
if (!value || followed === value) {
return
}
followed = value
ticket += 1
const current = ticket
const item = await lookup(value)
if (!alive || current !== ticket) {
return
}
if (item === null) {
missing()
dropEditParam()
return
}
open(item)
}
return { rememberOpener, restoreFocus, editParam, dropEditParam, leavePanel, followEditLink }
}
+61
View File
@@ -0,0 +1,61 @@
import { useApi } from '@/composables/useApi'
import { jsonInit } from '@/lib/directoryRequest'
import { reassignQuery } from '@/lib/directory'
export type SiteRobots = 'index' | 'noindex'
export interface Site {
id: number
name: string
key: string
base_url: string
default_social_image_id: number | null
default_robots: SiteRobots
locale: string | null
is_default: boolean
article_count: number
}
export interface NewSite {
name: string
key: string
base_url: string
default_robots: SiteRobots
locale?: string
default_social_image_id?: number
is_default: boolean
}
export interface SitePatch {
name?: string
base_url?: string
default_robots?: SiteRobots
locale?: string | null
default_social_image_id?: number | null
}
export function useSites() {
const { request } = useApi()
function listSites(): Promise<Site[]> {
return request<Site[]>('/v1/site/')
}
function createSite(site: NewSite): Promise<Site> {
return request<Site>('/v1/site/', jsonInit('POST', site))
}
function updateSite(id: number, patch: SitePatch): Promise<Site> {
return request<Site>(`/v1/site/${id}`, jsonInit('PATCH', patch))
}
function setDefaultSite(id: number): Promise<Site> {
return request<Site>(`/v1/site/${id}/default`, { method: 'PATCH' })
}
function deleteSite(id: number, reassignTo?: number): Promise<void> {
return request<void>(`/v1/site/${id}${reassignQuery(reassignTo)}`, { method: 'DELETE' })
}
return { listSites, createSite, updateSite, setDefaultSite, deleteSite }
}
+36
View File
@@ -0,0 +1,36 @@
import { computed, ref } from 'vue'
import { slugify } from '@/lib/directory'
export type SlugMode = 'empty' | 'auto' | 'manual' | 'saved'
export function useSlugField(max: number, saved?: string) {
const source = ref('')
const override = ref<string | null>(saved ?? null)
const isSaved = saved !== undefined
const value = computed(() => override.value ?? slugify(source.value, max))
const mode = computed<SlugMode>(() => {
if (override.value !== null) {
return isSaved && override.value === saved ? 'saved' : 'manual'
}
return source.value.trim() ? 'auto' : 'empty'
})
function setSource(text: string) {
source.value = text
}
function unlock() {
override.value = value.value
}
function setValue(next: string) {
override.value = next
}
function reset() {
override.value = isSaved ? slugify(source.value, max) : null
}
return { source, value, mode, setSource, unlock, setValue, reset }
}
+13 -1
View File
@@ -1,4 +1,5 @@
import { useApi } from '@/composables/useApi'
import type { LinkableUser } from '@/lib/directoryTypes'
export type UserListItem =
| {
@@ -50,6 +51,17 @@ export function useUsers() {
return request<UserListItem[]>('/v1/user/')
}
async function listLinkableUsers(
takenUserIds: ReadonlySet<number | null>,
): Promise<LinkableUser[]> {
const users = await listUsers()
return users.flatMap((user) =>
user.kind === 'user' && !takenUserIds.has(user.id)
? [{ id: user.id, label: user.display_name || user.username, email: user.email }]
: [],
)
}
function inviteUser(email: string): Promise<CreatedInvite> {
return request<CreatedInvite>('/v1/user/invite', {
method: 'POST',
@@ -66,5 +78,5 @@ export function useUsers() {
return request<CreatedInvite>(`/v1/user/invite/${id}/renew`, { method: 'POST' })
}
return { listUsers, inviteUser, cancelInvite, renewInvite }
return { listUsers, listLinkableUsers, inviteUser, cancelInvite, renewInvite }
}
+87
View File
@@ -0,0 +1,87 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { COPY } from '@/lib/directoryCopy'
import { messages } from './messages'
const SOURCES = [
'components/AuthorAvatar.vue',
'components/AuthorFormPanel.vue',
'components/AuthorViewPanel.vue',
'components/DashboardShell.vue',
'components/DirectoryBadge.vue',
'components/DirectoryDialog.vue',
'components/DirectoryField.vue',
'components/DirectoryList.vue',
'components/DirectoryPanel.vue',
'components/DirectoryRowMenu.vue',
'components/LinkUserDialog.vue',
'components/MediaField.vue',
'components/PublishingLinksCard.vue',
'components/ReassignDialog.vue',
'components/SiteFormPanel.vue',
'components/SitesForbidden.vue',
'components/SlugField.vue',
'components/SocialLinksRepeater.vue',
'components/UserSelect.vue',
'composables/useDirectoryToasts.ts',
'composables/useDirectoryView.ts',
'lib/directory.ts',
'lib/directoryCopy.ts',
'lib/directoryRules.ts',
'views/AuthorsView.vue',
'views/SitesView.vue',
]
const SAME_IN_FRENCH = new Set([
'Articles',
'Auto',
'Cache',
'Avatar',
'Bio',
'Noindex',
'Site',
'Sites',
'Guest',
'Linked',
])
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
function keysIn(source: string) {
return [...source.matchAll(LITERAL)].map((match) =>
(match[1] ?? match[2] ?? '').replace(/\\(['"\\])/g, '$1'),
)
}
describe('Sites and authors messages', () => {
const keys = SOURCES.flatMap((file) =>
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
file,
key,
})),
)
it('finds the strings of both screens', () => {
expect(keys.length).toBeGreaterThan(100)
})
it.each(keys.map(({ file, key }) => [file, key]))(
'%s: "%s" has its own French entry',
(_file, key) => {
const french = (messages as Record<string, string>)[key as string]
expect(french).toMatch(/\S/)
if (!SAME_IN_FRENCH.has(key as string)) {
expect(french).not.toBe(key)
}
},
)
it.each(Object.values(COPY))('shared copy "%s" has its own French entry', (key) => {
const french = (messages as Record<string, string>)[key]
expect(french).toBeTruthy()
expect(french).not.toBe(key)
})
})
+222
View File
@@ -1685,6 +1685,228 @@ export const messages = {
'Get settings.': 'Récupère les paramètres.',
'Made for the browser. A key is ignored here, with no 403.':
'Prévue pour le navigateur. Une clé est ignorée ici, sans 403.',
'1 article moved to {0}.': '1 article déplacé vers {0}.',
'1 article no longer has a byline.': '1 article n’a plus de signature.',
'1 article now credits {0}.': '1 article est maintenant attribué à {0}.',
'1 article will move to {0}. Their canonical host changes to {1}.':
'1 article sera déplacé vers {0}. Son hôte canonique devient {1}.',
'A language tag. Leave empty if the site has no single language.':
'Une balise de langue. Laissez vide si le site n’a pas de langue unique.',
'A sentence or two about the author.': 'Une phrase ou deux sur l’auteur.',
'A site with the key "{0}" already exists. Choose another key.':
'Un site avec la clé « {0} » existe déjà. Choisissez une autre clé.',
'Add another site if your content is shown on more than one frontend. Each article belongs to one site.':
'Ajoutez un autre site si votre contenu est affiché sur plusieurs frontends. Chaque article appartient à un seul site.',
'Add author': 'Ajouter un auteur',
'Add link': 'Ajouter un lien',
'Add site': 'Ajouter un site',
'Ask the owner of this site.': 'Demandez au propriétaire de ce site.',
"Asks search engines not to list this site's pages.":
'Demande aux moteurs de recherche de ne pas référencer les pages de ce site.',
Author: 'Auteur',
'Author deleted': 'Auteur supprimé',
'Author name': 'Nom de l’auteur',
Authors: 'Auteurs',
"Authors are the names readers see on articles. They don't need an account.":
'Les auteurs sont les noms que les lecteurs voient sur les articles. Ils n’ont pas besoin de compte.',
Auto: 'Auto',
Avatar: 'Avatar',
'Back to Settings': 'Retour aux paramètres',
'Base URLs, default image and robots policy':
'URL de base, image par défaut et politique des robots',
Bio: 'Bio',
'Byline preview': 'Aperçu de la signature',
"Can't delete this author": 'Impossible de supprimer cet auteur',
'Canonical URL preview': 'Aperçu de l’URL canonique',
'Change image': 'Changer d’image',
'Check the spelling, or search for a shorter part of the name.':
'Vérifiez l’orthographe, ou cherchez une partie plus courte du nom.',
'Choose a user': 'Choisir un utilisateur',
'Choose an image': 'Choisir une image',
'Choose image': 'Choisir une image',
'Choose where the articles go. This site has {0} articles.':
'Choisissez où vont les articles. Ce site compte {0} articles.',
"Couldn't delete the author": 'Impossible de supprimer l’auteur',
"Couldn't delete the site": 'Impossible de supprimer le site',
"Couldn't load authors": 'Impossible de charger les auteurs',
"Couldn't load sites": 'Impossible de charger les sites',
"Couldn't save the author": 'Impossible d’enregistrer l’auteur',
"Couldn't save the author. Your changes are still here. Try again in a moment.":
'Impossible d’enregistrer l’auteur. Vos modifications sont toujours là. Réessayez dans un instant.',
"Couldn't save the site. Your changes are still here. Try again in a moment.":
'Impossible d’enregistrer le site. Vos modifications sont toujours là. Réessayez dans un instant.',
'Credit these articles to': 'Attribuer ces articles à',
Default: 'Par défaut',
'Default robots policy': 'Politique des robots par défaut',
'Default site': 'Site par défaut',
'Default site changed': 'Site par défaut modifié',
'Default social image': 'Image sociale par défaut',
'Delete and reassign 1 article': 'Supprimer et réattribuer 1 article',
'Delete and reassign {0} articles': 'Supprimer et réattribuer {0} articles',
'Delete author': 'Supprimer l’auteur',
'Delete site': 'Supprimer le site',
'Delete this author?': 'Supprimer cet auteur ?',
'Delete this site?': 'Supprimer ce site ?',
'Edit key': 'Modifier la clé',
'Edit slug': 'Modifier l’identifiant',
'Edit {0}': 'Modifier {0}',
'Enter a full URL starting with https://.':
'Saisissez une URL complète commençant par https://.',
'Enter a full URL, like https://blog.example.com.':
'Saisissez une URL complète, comme https://blog.example.com.',
'Every user already has an author.': 'Chaque utilisateur a déjà un auteur.',
'Generated from the name': 'Généré à partir du nom',
'Generated from the name. Used in public author URLs and API paths. Unique across all authors.':
'Généré à partir du nom. Utilisé dans les URL publiques des auteurs et les chemins d’API. Unique parmi tous les auteurs.',
Guest: 'Invité',
'Image #{0}': 'Image nº {0}',
Index: 'Indexer',
'Link URL {0}': 'URL du lien {0}',
'Link a CMS user if this author has an account. Guest writers need no link.':
'Liez un utilisateur du CMS si cet auteur a un compte. Les auteurs invités n’ont pas besoin de lien.',
'Link type {0}': 'Type du lien {0}',
'Link user': 'Lier un utilisateur',
'Link {0} to a user?': 'Lier {0} à un utilisateur ?',
Linked: 'Lié',
"Linked to {0}. The user account isn't affected.":
'Lié à {0}. Le compte utilisateur n’est pas modifié.',
'Linked user': 'Utilisateur lié',
Locale: 'Langue',
'Lowercase letters, numbers and hyphens. Unique across all authors.':
'Lettres minuscules, chiffres et traits d’union. Unique parmi tous les auteurs.',
"Lowercase letters, numbers and hyphens. Used in API paths. It can't be changed after the site is added.":
'Lettres minuscules, chiffres et traits d’union. Utilisée dans les chemins d’API. Elle ne peut plus être modifiée après l’ajout du site.',
'Make default': 'Définir par défaut',
'Make this the default site': 'Faire de ce site le site par défaut',
'Make {0} the default site?': 'Faire de {0} le site par défaut ?',
'Manage sites': 'Gérer les sites',
'Move 1 article and delete': 'Déplacer 1 article et supprimer',
'Move articles to': 'Déplacer les articles vers',
'Move {0} articles and delete': 'Déplacer {0} articles et supprimer',
'Moving articles…': 'Déplacement des articles…',
'New articles by this user start with this author.':
'Les nouveaux articles de cet utilisateur commencent avec cet auteur.',
'New articles start here. The current default is {0}.':
'Les nouveaux articles commencent ici. Le site par défaut actuel est {0}.',
'New articles start on the default site. To change it, make another site the default.':
'Les nouveaux articles commencent sur le site par défaut. Pour le changer, définissez un autre site par défaut.',
'New articles start on this site. Articles that are already on other sites stay where they are.':
'Les nouveaux articles commencent sur ce site. Les articles déjà présents sur d’autres sites y restent.',
'No articles': 'Aucun article',
'No authors match "{0}"': 'Aucun auteur ne correspond à « {0} »',
'No authors yet': 'Aucun auteur pour l’instant',
'No linked user': 'Aucun utilisateur lié',
Nobody: 'Personne',
Noindex: 'Noindex',
'Nothing was changed. Try again in a moment.':
'Rien n’a été modifié. Réessayez dans un instant.',
'Only an owner or admin can change authors':
'Seul un propriétaire ou un administrateur peut modifier les auteurs',
'Only an owner or admin can change this author.':
'Seul un propriétaire ou un administrateur peut modifier cet auteur.',
'Only the owner can manage sites': 'Seul le propriétaire peut gérer les sites',
'Only the owner can manage sites. Ask the owner of this site if you need a change.':
'Seul le propriétaire peut gérer les sites. Demandez au propriétaire de ce site si vous avez besoin d’un changement.',
'Only users without an author are listed.': 'Seuls les utilisateurs sans auteur sont listés.',
Publishing: 'Publication',
'Reassign articles': 'Réattribuer les articles',
'Reassigning…': 'Réattribution…',
'Remove link {0}': 'Supprimer le lien {0}',
'Remove the byline': 'Supprimer la signature',
'Remove the path. Use only the host, like https://blog.example.com.':
'Retirez le chemin. Utilisez seulement l’hôte, comme https://blog.example.com.',
'Reset to match the name': 'Rétablir selon le nom',
'Search by name or slug': 'Rechercher par nom ou identifiant',
"Search engines may list this site's pages.":
'Les moteurs de recherche peuvent référencer les pages de ce site.',
'Set as default': 'Définir par défaut',
'Shorten the bio to 280 characters.': 'Raccourcissez la bio à 280 caractères.',
'Shown as a square. Choose an image with the face or logo near the middle.':
'Affichée en carré. Choisissez une image dont le visage ou le logo est près du centre.',
'Shown when the base URL is valid.': 'Affiché lorsque l’URL de base est valide.',
Site: 'Site',
'Site deleted': 'Site supprimé',
Sites: 'Sites',
Slug: 'Identifiant',
'Social links': 'Liens sociaux',
'That author no longer exists': 'Cet auteur n’existe plus',
'That site no longer exists': 'Ce site n’existe plus',
'That user already has an author. Choose another user.':
'Cet utilisateur a déjà un auteur. Choisissez un autre utilisateur.',
'The articles keep any other authors. Articles with only this author will have no byline.':
'Les articles conservent leurs autres auteurs. Les articles n’ayant que cet auteur n’auront plus de signature.',
'The avatar for {0}.': 'L’avatar de {0}.',
'The bio appears here.': 'La bio apparaît ici.',
"The default site can't be deleted. Make another site the default first.":
'Le site par défaut ne peut pas être supprimé. Définissez d’abord un autre site par défaut.',
'The default social image for {0}.': 'L’image sociale par défaut de {0}.',
'The frontends that display your content.': 'Les frontends qui affichent votre contenu.',
"The names readers see on your articles. An author doesn't need an account.":
'Les noms que les lecteurs voient sur vos articles. Un auteur n’a pas besoin de compte.',
'The names readers see on your articles. Only an owner or admin can change authors.':
'Les noms que les lecteurs voient sur vos articles. Seul un propriétaire ou un administrateur peut modifier les auteurs.',
'The public address of the frontend. Use https and no path.':
'L’adresse publique du frontend. Utilisez https et aucun chemin.',
'The slug "{0}" is already used by another author.':
'L’identifiant « {0} » est déjà utilisé par un autre auteur.',
'This is your author profile. Only an owner or admin can change the link.':
'Ceci est votre profil d’auteur. Seul un propriétaire ou un administrateur peut modifier le lien.',
Unlink: 'Dissocier',
'Unlink user': 'Dissocier l’utilisateur',
'Unlink {0}?': 'Dissocier {0} ?',
'Unlinking…': 'Dissociation…',
'Use a language tag like en or fr-CH.': 'Utilisez une balise de langue comme en ou fr-CH.',
'Use https. For example https://blog.example.com.':
'Utilisez https. Par exemple https://blog.example.com.',
'Use image': 'Utiliser l’image',
'Use lowercase letters, numbers and hyphens, 2 to 32 characters.':
'Utilisez des lettres minuscules, des chiffres et des traits d’union, de 2 à 32 caractères.',
'Use {0}': 'Utiliser {0}',
"Used in API paths, so it can't change.":
'Utilisée dans les chemins d’API, elle ne peut donc pas changer.',
'Used when an article has no image of its own.':
'Utilisée lorsqu’un article n’a pas d’image propre.',
'User unlinked': 'Utilisateur dissocié',
'Users without an author': 'Utilisateurs sans auteur',
'View {0}': 'Voir {0}',
Website: 'Site web',
'Where an article has several authors, {0} is removed and the others stay.':
'Lorsqu’un article a plusieurs auteurs, {0} est retiré et les autres restent.',
'Where your content is shown.': 'Où votre contenu est affiché.',
'You can add up to {0} links.': 'Vous pouvez ajouter jusqu’à {0} liens.',
'You can still edit your own author profile.':
'Vous pouvez toujours modifier votre propre profil d’auteur.',
"You don't have access to Sites": 'Vous n’avez pas accès aux sites',
'"{0}" is credited on 1 article. Choose another author to credit instead, or remove {0} from those articles first.':
'« {0} » est attribué à 1 article. Choisissez un autre auteur à lui attribuer, ou retirez {0} de cet article d’abord.',
'"{0}" is credited on {1} articles. Choose another author to credit instead, or remove {0} from those articles first.':
'« {0} » est attribué à {1} articles. Choisissez un autre auteur à leur attribuer, ou retirez {0} de ces articles d’abord.',
'"{0}" is now the default site.': '« {0} » est maintenant le site par défaut.',
'"{0}" will be deleted and is credited on 1 article. This can\'t be undone.':
'« {0} » sera supprimé et est attribué à 1 article. Cette action est irréversible.',
'"{0}" will be deleted and is credited on {1} articles. This can\'t be undone.':
'« {0} » sera supprimé et est attribué à {1} articles. Cette action est irréversible.',
'"{0}" will be deleted. 1 article belongs to it, so choose a site to move them to. This can\'t be undone.':
'« {0} » sera supprimé. 1 article lui appartient, choisissez donc un site vers lequel le déplacer. Cette action est irréversible.',
'"{0}" will be deleted. No articles belong to it. This can\'t be undone.':
'« {0} » sera supprimé. Aucun article ne lui appartient. Cette action est irréversible.',
'"{0}" will be deleted. No articles credit this author. This can\'t be undone.':
'« {0} » sera supprimé. Aucun article n’est attribué à cet auteur. Cette action est irréversible.',
'"{0}" will be deleted. {1} articles belong to it, so choose a site to move them to. This can\'t be undone.':
'« {0} » sera supprimé. {1} articles lui appartiennent, choisissez donc un site vers lequel les déplacer. Cette action est irréversible.',
'e.g. Camille Roche': 'p. ex. Camille Roche',
'e.g. Journal': 'p. ex. Journal',
'e.g. en or fr-CH': 'p. ex. en ou fr-CH',
'this author': 'cet auteur',
'this site': 'ce site',
'{0} articles moved to {1}.': '{0} articles déplacés vers {1}.',
'{0} articles no longer have a byline.': '{0} articles n’ont plus de signature.',
'{0} articles now credit {1}.': '{0} articles sont maintenant attribués à {1}.',
'{0} articles will move to {1}. Their canonical host changes to {2}.':
'{0} articles seront déplacés vers {1}. Leur hôte canonique devient {2}.',
'{0} is now a guest author.': '{0} est maintenant un auteur invité.',
'{0} will stay on their articles. New articles by {1} will no longer start with this author.':
'{0} restera sur ses articles. Les nouveaux articles de {1} ne commenceront plus avec cet auteur.',
} as const
export type MessageKey = keyof typeof messages
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it } from 'vitest'
import {
AUTHOR_SLUG_MAX,
SITE_KEY_MAX,
SLUG_PATTERN,
siteUrlProblem,
slugify,
suggestFreeSlug,
} from '@/lib/directory'
import {
parseDirectoryQuery,
toDirectoryRouteQuery,
type DirectoryQuery,
} from '@/lib/directoryQuery'
const ALPHABET = 'abcXYZ019 -_.,é ñ ß æ Ł 日本語 ! /'
const SAMPLES = 400
function seeded(seed: number) {
let state = seed
return () => {
state = (state * 1664525 + 1013904223) % 4294967296
return state / 4294967296
}
}
function randomText(next: () => number, length: number) {
return Array.from(
{ length },
() => [...ALPHABET][Math.floor(next() * [...ALPHABET].length)],
).join('')
}
describe('slugify', () => {
it('never ends or starts with a dash, matches the slug pattern and respects the maximum', () => {
const next = seeded(7)
for (let index = 0; index < SAMPLES; index += 1) {
const max = 1 + Math.floor(next() * 40)
const slug = slugify(randomText(next, Math.floor(next() * 80)), max)
expect(slug.length).toBeLessThanOrEqual(max)
expect(slug === '' || SLUG_PATTERN.test(slug)).toBe(true)
}
})
it('trims the dash left by cutting at the maximum', () => {
expect(slugify('Journal Renovation', 8)).toBe('journal')
})
it('is idempotent', () => {
const next = seeded(11)
for (let index = 0; index < SAMPLES; index += 1) {
const once = slugify(randomText(next, 30), AUTHOR_SLUG_MAX)
expect(slugify(once, AUTHOR_SLUG_MAX)).toBe(once)
}
})
it('folds diacritics and ligatures like the server', () => {
expect(slugify('Inès Duarte', 100)).toBe('ines-duarte')
expect(slugify('Straße Œuvre', 100)).toBe('strasse-oeuvre')
})
})
describe('suggestFreeSlug', () => {
it('returns a free, valid candidate within the maximum for any taken set', () => {
const next = seeded(3)
for (let index = 0; index < SAMPLES; index += 1) {
const max = 6 + Math.floor(next() * 30)
const slug = slugify(randomText(next, 20), max) || 'a'
const taken = new Set<string>()
const rounds = Math.floor(next() * 6)
for (let round = 0; round < rounds; round += 1) {
taken.add(suggestFreeSlug(slug, (candidate) => taken.has(candidate), max))
}
const candidate = suggestFreeSlug(slug, (value) => taken.has(value), max)
expect(taken.has(candidate)).toBe(false)
expect(candidate.length).toBeLessThanOrEqual(max)
expect(SLUG_PATTERN.test(candidate)).toBe(true)
}
})
it('keeps a full-length site key within 32 characters', () => {
const key = 'a'.repeat(SITE_KEY_MAX)
expect(suggestFreeSlug(key, () => false, SITE_KEY_MAX)).toBe(`${'a'.repeat(30)}-2`)
})
it('replaces an existing numeric suffix', () => {
expect(suggestFreeSlug('journal-2', (value) => value === 'journal-2', 32)).toBe('journal-3')
})
})
describe('siteUrlProblem', () => {
it.each([
'https://blog.example.com',
'https://blog.example.com/',
'http://localhost:5173',
'http://127.0.0.1:8081',
'http://[::1]:5173',
'https://localhost',
])('accepts %s', (value) => {
expect(siteUrlProblem(value)).toBeNull()
})
it.each([
'https://user@blog.example.com',
'https://user:secret@blog.example.com',
'https://:secret@blog.example.com',
'http://user@localhost:5173',
])('rejects credentials in %s', (value) => {
expect(siteUrlProblem(value)).toBe('invalid')
})
it('rejects credentials hidden among random paths and hosts', () => {
const next = seeded(5)
for (let index = 0; index < SAMPLES; index += 1) {
const user = randomText(next, 1 + Math.floor(next() * 5)).replace(/[^a-z0-9]/gi, 'u')
const problem = siteUrlProblem(`https://${user || 'u'}@host.example`)
expect(problem).not.toBeNull()
}
})
it.each([
['http://blog.example.com', 'scheme'],
['https://blog.example.com/en', 'path'],
['https://blog.example.com?x=1', 'path'],
['blog', 'invalid'],
['ftp://blog.example.com', 'invalid'],
['', 'empty'],
])('classifies %s as %s', (value, problem) => {
expect(siteUrlProblem(value)).toBe(problem)
})
})
describe('parseDirectoryQuery', () => {
it('survives a round trip through the route query', () => {
const next = seeded(13)
const sizes = [5, 25, 50, 100, 30, 0, -1]
for (let index = 0; index < SAMPLES; index += 1) {
const raw: Record<string, string> = {}
if (next() > 0.3) {
raw.q = randomText(next, Math.floor(next() * 12))
}
if (next() > 0.3) {
raw.page = String(Math.floor(next() * 12) - 2)
}
if (next() > 0.3) {
raw.size = String(sizes[Math.floor(next() * sizes.length)])
}
const parsed = parseDirectoryQuery(raw)
const again = parseDirectoryQuery(toDirectoryRouteQuery(parsed))
expect(again).toEqual(parsed)
expect(parsed.page).toBeGreaterThanOrEqual(1)
expect([5, 25, 50, 100]).toContain(parsed.size)
}
})
it('keeps other parameters such as edit and drops defaults', () => {
const parsed: DirectoryQuery = { q: '', page: 1, size: 25 }
expect(toDirectoryRouteQuery(parsed, { edit: 'camille', page: '9' })).toEqual({
edit: 'camille',
})
})
})
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { ApiError } from '@/composables/useApi'
import { classifyFailure, type FailureContext, type FailureKind } from '@/lib/directoryErrors'
const CASES: [FailureContext, number, string | undefined, FailureKind][] = [
['site', 401, undefined, 'session'],
['site', 403, 'default_site', 'defaultSite'],
['site', 403, 'Only the owner can do this.', 'forbidden'],
['site', 404, 'site_not_found', 'gone'],
['site', 409, 'site_key_taken', 'keyTaken'],
['site', 409, 'site_has_articles', 'hasArticles'],
['site', 400, 'invalid_site_url', 'invalidSiteUrl'],
['site', 400, 'invalid_locale', 'invalidLocale'],
['site', 500, undefined, 'failed'],
['author', 403, 'author_rights', 'rights'],
['author', 404, 'author_not_found', 'gone'],
['author', 409, 'author_slug_taken', 'slugTaken'],
['author', 409, 'user_already_linked', 'userLinked'],
['author', 409, 'author_has_articles', 'hasArticles'],
['author', 422, 'bio_too_long', 'bioTooLong'],
['author', 422, 'invalid_url', 'invalidUrl'],
['author', 500, undefined, 'failed'],
['author', 409, 'something_else', 'failed'],
]
describe('classifyFailure', () => {
it.each(CASES)('%s %s %s is %s', (context, status, code, kind) => {
expect(classifyFailure(new ApiError(status, undefined, code), context)).toBe(kind)
})
it('treats a network error as a generic failure', () => {
expect(classifyFailure(new Error('offline'), 'site')).toBe('failed')
})
it('does not let a site code leak into the author context', () => {
expect(classifyFailure(new ApiError(409, undefined, 'site_key_taken'), 'author')).toBe(
'failed',
)
})
})
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
BIO_MAX,
authorSlugProblem,
baseUrlProblem,
bioProblem,
nameProblem,
optionalSecureUrlProblem,
secureUrlProblem,
siteKeyProblem,
} from '@/lib/directoryRules'
describe('directory validation rules', () => {
it('asks for a name', () => {
expect(nameProblem(' ')).toBe('Enter a name.')
expect(nameProblem('Zoe')).toBe('')
})
it('counts the bio in characters, not code units', () => {
expect(bioProblem('x'.repeat(BIO_MAX))).toBe('')
expect(bioProblem('x'.repeat(BIO_MAX + 1))).toBe('Shorten the bio to 280 characters.')
expect(bioProblem('😀'.repeat(BIO_MAX))).toBe('')
})
it('requires https for links and treats an empty website as fine', () => {
expect(secureUrlProblem('http://x')).toBe('Enter a full URL starting with https://.')
expect(secureUrlProblem('')).toBe('Enter a full URL starting with https://.')
expect(secureUrlProblem('https://x.example')).toBe('')
expect(optionalSecureUrlProblem('')).toBe('')
expect(optionalSecureUrlProblem('example.com')).not.toBe('')
})
it('validates author slugs and site keys', () => {
expect(authorSlugProblem('')).toBe('')
expect(authorSlugProblem('camille-roche')).toBe('')
expect(authorSlugProblem('Camille Roche')).not.toBe('')
expect(siteKeyProblem('journal')).toBe('')
expect(siteKeyProblem('j')).not.toBe('')
expect(siteKeyProblem('a'.repeat(33))).not.toBe('')
expect(siteKeyProblem('Journal')).not.toBe('')
})
it('maps a base URL to its message', () => {
expect(baseUrlProblem('https://blog.example.com')).toBe('')
expect(baseUrlProblem('http://blog.example.com')).toBe(
'Use https. For example https://blog.example.com.',
)
expect(baseUrlProblem('https://u:p@blog.example.com')).toBe(
'Enter a full URL, like https://blog.example.com.',
)
})
})
+134
View File
@@ -0,0 +1,134 @@
import { t } from '@/i18n'
export const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
export const AUTHOR_SLUG_MAX = 100
export const SITE_KEY_MAX = 32
export const SITE_KEY_MIN = 2
export const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']
const LIGATURES: Record<string, string> = {
æ: 'ae',
œ: 'oe',
ß: 'ss',
ø: 'o',
ł: 'l',
đ: 'd',
ı: 'i',
}
const COMBINING_MARKS = /\p{M}/gu
const NON_SLUG_RUN = /[^a-z0-9]+/g
const EDGE_DASHES = /^-+|-+$/g
const TITLE_PREFIX = /^Dr\.\s*/
const NUMBERED_SUFFIX = /-(\d+)$/
export function slugify(text: string, max: number) {
const folded = text
.toLowerCase()
.normalize('NFD')
.replace(COMBINING_MARKS, '')
.replace(/[æœßøłđı]/g, (letter) => LIGATURES[letter] ?? letter)
return folded.replace(NON_SLUG_RUN, '-').slice(0, max).replace(EDGE_DASHES, '')
}
export function suggestFreeSlug(
slug: string,
isTaken: (candidate: string) => boolean,
max: number,
) {
const base = slug.replace(NUMBERED_SUFFIX, '')
let counter = 2
for (;;) {
const suffix = `-${counter}`
const stem = base.slice(0, Math.max(1, max - suffix.length)).replace(EDGE_DASHES, '')
const candidate = `${stem}${suffix}`
if (!isTaken(candidate)) {
return candidate
}
counter += 1
}
}
export function hostOf(url: string) {
return url.replace(/^https?:\/\//, '').replace(/\/$/, '')
}
export function initialsOf(name: string) {
return name
.replace(TITLE_PREFIX, '')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => [...word][0] ?? '')
.join('')
.toUpperCase()
}
export function pluralArticles(count: number) {
if (count === 0) {
return t('No articles')
}
return t(count === 1 ? '1 article' : '{0} articles', [count])
}
export type SiteUrlProblem = 'empty' | 'scheme' | 'path' | 'invalid'
function parseUrl(value: string) {
try {
return new URL(value)
} catch {
return null
}
}
export function siteUrlProblem(value: string): SiteUrlProblem | null {
const trimmed = value.trim()
if (!trimmed) {
return 'empty'
}
const parsed = parseUrl(trimmed)
if (
!parsed ||
!/^https?:$/.test(parsed.protocol) ||
!parsed.hostname ||
parsed.username ||
parsed.password
) {
return 'invalid'
}
const loopback = LOOPBACK_HOSTS.includes(parsed.hostname)
if (parsed.protocol === 'http:' && !loopback) {
return 'scheme'
}
if (parsed.pathname.replace(/\/+$/, '') || parsed.search || parsed.hash) {
return 'path'
}
return null
}
export function siteUrlMessage(problem: SiteUrlProblem) {
if (problem === 'scheme') {
return t('Use https. For example https://blog.example.com.')
}
if (problem === 'path') {
return t('Remove the path. Use only the host, like https://blog.example.com.')
}
return t('Enter a full URL, like https://blog.example.com.')
}
export function isSecureUrl(value: string) {
const parsed = parseUrl(value.trim())
return parsed !== null && parsed.protocol === 'https:' && parsed.hostname !== ''
}
export function canonicalPreview(baseUrl: string) {
const trimmed = baseUrl.trim().replace(/\/+$/, '')
return `${trimmed}/how-we-price-a-bathroom-renovation`
}
export function characterCount(text: string) {
return [...text].length
}
export function reassignQuery(target: number | string | undefined) {
return target === undefined ? '' : `?reassign_to=${target}`
}
+21
View File
@@ -0,0 +1,21 @@
import { t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
export const COPY = {
nothingChanged: 'Nothing was changed. Try again in a moment.',
siteSaveFailed: "Couldn't save the site. Your changes are still here. Try again in a moment.",
authorSaveFailed:
"Couldn't save the author. Your changes are still here. Try again in a moment.",
defaultSiteBlocked: "The default site can't be deleted. Make another site the default first.",
authorSaveFailedTitle: "Couldn't save the author",
enterName: 'Enter a name.',
shortenBio: 'Shorten the bio to 280 characters.',
urlHttps: 'Enter a full URL starting with https://.',
userLinked: 'That user already has an author. Choose another user.',
loadAuthorsFailed: "Couldn't load authors",
checkConnection: 'Check your connection and try again.',
} as const satisfies Record<string, MessageKey>
export function copy(key: keyof typeof COPY) {
return t(COPY[key])
}
+86
View File
@@ -0,0 +1,86 @@
import { ApiError } from '@/composables/useApi'
export const SITE_CODES = {
notFound: 'site_not_found',
keyTaken: 'site_key_taken',
hasArticles: 'site_has_articles',
defaultSite: 'default_site',
invalidUrl: 'invalid_site_url',
invalidLocale: 'invalid_locale',
} as const
export const AUTHOR_CODES = {
notFound: 'author_not_found',
slugTaken: 'author_slug_taken',
hasArticles: 'author_has_articles',
userLinked: 'user_already_linked',
rights: 'author_rights',
bioTooLong: 'bio_too_long',
invalidUrl: 'invalid_url',
userNotFound: 'user_not_found',
} as const
const SESSION_STATUSES = [401]
export function statusOf(error: unknown) {
return error instanceof ApiError ? error.status : 0
}
export function codeOf(error: unknown) {
return error instanceof ApiError ? (error.body ?? '').trim() : ''
}
export function isSessionError(error: unknown) {
return SESSION_STATUSES.includes(statusOf(error))
}
export type FailureContext = 'site' | 'author'
export type FailureKind =
| 'session'
| 'forbidden'
| 'defaultSite'
| 'rights'
| 'gone'
| 'keyTaken'
| 'invalidSiteUrl'
| 'invalidLocale'
| 'slugTaken'
| 'userLinked'
| 'bioTooLong'
| 'invalidUrl'
| 'hasArticles'
| 'failed'
export function classifyFailure(error: unknown, context: FailureContext): FailureKind {
const status = statusOf(error)
const code = codeOf(error)
if (isSessionError(error)) {
return 'session'
}
if (status === 404) {
return 'gone'
}
if (status === 403) {
if (context === 'author') {
return 'rights'
}
return code === SITE_CODES.defaultSite ? 'defaultSite' : 'forbidden'
}
const table: Record<FailureContext, Record<string, FailureKind>> = {
site: {
[`409 ${SITE_CODES.keyTaken}`]: 'keyTaken',
[`409 ${SITE_CODES.hasArticles}`]: 'hasArticles',
[`400 ${SITE_CODES.invalidUrl}`]: 'invalidSiteUrl',
[`400 ${SITE_CODES.invalidLocale}`]: 'invalidLocale',
},
author: {
[`409 ${AUTHOR_CODES.slugTaken}`]: 'slugTaken',
[`409 ${AUTHOR_CODES.userLinked}`]: 'userLinked',
[`409 ${AUTHOR_CODES.hasArticles}`]: 'hasArticles',
[`422 ${AUTHOR_CODES.bioTooLong}`]: 'bioTooLong',
[`422 ${AUTHOR_CODES.invalidUrl}`]: 'invalidUrl',
},
}
return table[context][`${status} ${code}`] ?? 'failed'
}
+10
View File
@@ -0,0 +1,10 @@
export interface DirectoryMenuItem {
key: string
label: string
icon: string
destructive?: boolean
unavailable?: boolean
hint?: string
separated?: boolean
onSelect: (trigger: HTMLElement | null) => void
}
+48
View File
@@ -0,0 +1,48 @@
import type { LocationQuery } from 'vue-router'
import {
ARTICLE_PAGE_SIZES,
ARTICLE_SEARCH_MAX_LENGTH,
DEFAULT_ARTICLE_PAGE_SIZE,
} from '@/lib/articleListQuery'
export interface DirectoryQuery {
q: string
page: number
size: number
}
const POSITIVE_INTEGER = /^[1-9]\d*$/
function first(value: LocationQuery[string] | undefined) {
const single = Array.isArray(value) ? value[0] : value
return single ?? ''
}
export function parseDirectoryQuery(query: LocationQuery): DirectoryQuery {
const page = first(query.page)
const size = Number(first(query.size))
return {
q: first(query.q).slice(0, ARTICLE_SEARCH_MAX_LENGTH),
page: POSITIVE_INTEGER.test(page) ? Number(page) : 1,
size: ARTICLE_PAGE_SIZES.includes(size) ? size : DEFAULT_ARTICLE_PAGE_SIZE,
}
}
export function toDirectoryRouteQuery(query: DirectoryQuery, rest: LocationQuery = {}) {
const route: Record<string, string> = {}
for (const [key, value] of Object.entries(rest)) {
if (key !== 'q' && key !== 'page' && key !== 'size') {
route[key] = first(value)
}
}
if (query.q) {
route.q = query.q
}
if (query.page !== 1) {
route.page = String(query.page)
}
if (query.size !== DEFAULT_ARTICLE_PAGE_SIZE) {
route.size = String(query.size)
}
return route
}
+7
View File
@@ -0,0 +1,7 @@
export function jsonInit(method: string, body: unknown): RequestInit {
return {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
}
+46
View File
@@ -0,0 +1,46 @@
import { t } from '@/i18n'
import { copy } from '@/lib/directoryCopy'
import {
SITE_KEY_MAX,
SITE_KEY_MIN,
SLUG_PATTERN,
characterCount,
isSecureUrl,
siteUrlMessage,
siteUrlProblem,
} from '@/lib/directory'
export const BIO_MAX = 280
export function nameProblem(name: string) {
return name.trim() ? '' : copy('enterName')
}
export function bioProblem(bio: string) {
return characterCount(bio) > BIO_MAX ? copy('shortenBio') : ''
}
export function secureUrlProblem(value: string) {
return isSecureUrl(value) ? '' : copy('urlHttps')
}
export function optionalSecureUrlProblem(value: string) {
return value.trim() ? secureUrlProblem(value) : ''
}
export function authorSlugProblem(slug: string) {
if (!slug || SLUG_PATTERN.test(slug)) {
return ''
}
return t('Lowercase letters, numbers and hyphens. Unique across all authors.')
}
export function siteKeyProblem(key: string) {
const valid = SLUG_PATTERN.test(key) && key.length >= SITE_KEY_MIN && key.length <= SITE_KEY_MAX
return valid ? '' : t('Use lowercase letters, numbers and hyphens, 2 to 32 characters.')
}
export function baseUrlProblem(value: string) {
const problem = siteUrlProblem(value)
return problem ? siteUrlMessage(problem) : ''
}
+10
View File
@@ -0,0 +1,10 @@
export interface LinkableUser {
id: number
label: string
email: string
}
export interface DirectoryNotice {
title?: string
description: string
}
+6
View File
@@ -0,0 +1,6 @@
export interface ImageChoice {
title: string
description: string
hint?: string
confirmLabel: string
}
+18
View File
@@ -0,0 +1,18 @@
export const SOCIAL_TYPES = ['x', 'linkedin', 'github', 'mastodon', 'custom'] as const
export type SocialType = (typeof SOCIAL_TYPES)[number]
export const MAX_SOCIAL_LINKS = 5
export interface SocialLinkRow {
key: number
type: SocialType
url: string
}
export const SOCIAL_PLACEHOLDERS: Record<SocialType, string> = {
x: 'https://x.com/username',
linkedin: 'https://www.linkedin.com/in/username',
github: 'https://github.com/username',
mastodon: 'https://mastodon.social/@username',
custom: 'https://example.com',
}
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import router from '@/router'
describe('Sites and authors routes', () => {
it.each([
['/settings/sites', 'Settings'],
['/authors', 'Authors'],
])('registers %s as a verified, lazy-loaded route', (path, title) => {
const route = router.getRoutes().find((candidate) => candidate.path === path)!
expect(route.meta.auth).toBe('verified')
expect(route.meta.title).toBe(title)
expect(typeof route.components?.default).toBe('function')
})
})
+10
View File
@@ -77,6 +77,11 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/MediaView.vue'),
meta: { auth: 'verified', title: 'Media' },
},
{
path: '/authors',
component: () => import('@/views/AuthorsView.vue'),
meta: { auth: 'verified', title: 'Authors' },
},
{
path: '/users',
component: () => import('@/views/UsersView.vue'),
@@ -112,6 +117,11 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/VariantsPage.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/settings/sites',
component: () => import('@/views/SitesView.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/settings/media/cache',
component: () => import('@/views/CachePage.vue'),
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useSessionStore } from '@/stores/session'
function sessionWith(role?: 'owner' | 'admin' | 'member') {
const store = useSessionStore()
store.session = { verified: true, displayName: '', ...(role ? { role: role as never } : {}) }
return store
}
describe('session role getters', () => {
beforeEach(() => setActivePinia(createPinia()))
it.each([
['owner', true, true],
['admin', false, true],
['member', false, false],
[undefined, false, false],
] as const)('%s: isOwner %s, canManageContent %s', (role, owner, manage) => {
const store = sessionWith(role)
expect(store.isOwner).toBe(owner)
expect(store.canManageContent).toBe(manage)
})
it('is false without a session', () => {
const store = useSessionStore()
expect(store.isOwner).toBe(false)
expect(store.canManageContent).toBe(false)
})
})
+19 -3
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ApiError, useApi, type UserRole } from '@/composables/useApi'
import { computed, ref } from 'vue'
import { ApiError, useApi, type MeAuthor, type UserRole } from '@/composables/useApi'
export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error'
@@ -11,6 +11,7 @@ export interface Session {
email?: string
displayName: string
role?: UserRole
author?: MeAuthor
}
export const useSessionStore = defineStore('session', () => {
@@ -18,6 +19,11 @@ export const useSessionStore = defineStore('session', () => {
const session = ref<Session | null>(null)
let pending: Promise<void> | null = null
const isOwner = computed(() => session.value?.role === 'owner')
const canManageContent = computed(
() => session.value?.role === 'owner' || session.value?.role === 'admin',
)
function check() {
if (pending) {
return pending
@@ -71,5 +77,15 @@ export const useSessionStore = defineStore('session', () => {
}
}
return { status, session, check, retry, refresh, clear, setDisplayName }
return {
status,
session,
isOwner,
canManageContent,
check,
retry,
refresh,
clear,
setDisplayName,
}
})
+1 -1
View File
@@ -67,7 +67,7 @@ export const useUpdateStore = defineStore('update', () => {
const dismissedVersion = ref<string | null>(readDismissedVersion())
let pendingLoad: Promise<void> | null = null
const isOwner = computed(() => session.session?.role === 'owner')
const isOwner = computed(() => session.isOwner)
const available = computed(() => status.value?.available === true)
const showCue = computed(() => isOwner.value && available.value)
const restarting = computed(() => status.value?.state === 'restarting')
+752
View File
@@ -0,0 +1,752 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation'
import AppPaginator from '@/components/AppPaginator.vue'
import AuthorAvatar from '@/components/AuthorAvatar.vue'
import AuthorFormPanel from '@/components/AuthorFormPanel.vue'
import AuthorViewPanel from '@/components/AuthorViewPanel.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
import DirectoryBadge from '@/components/DirectoryBadge.vue'
import DirectoryList from '@/components/DirectoryList.vue'
import LinkUserDialog from '@/components/LinkUserDialog.vue'
import ReassignDialog, { type ReassignOption } from '@/components/ReassignDialog.vue'
import { useAuthors, type Author } from '@/composables/useAuthors'
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useDirectoryView } from '@/composables/useDirectoryView'
import { sameRouteQuery } from '@/lib/articleListQuery'
import { pluralArticles } from '@/lib/directory'
import { copy } from '@/lib/directoryCopy'
import { classifyFailure } from '@/lib/directoryErrors'
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
import {
parseDirectoryQuery,
toDirectoryRouteQuery,
type DirectoryQuery,
} from '@/lib/directoryQuery'
import { pageCountFor } from '@/lib/pagination'
import { useSessionStore } from '@/stores/session'
const SEARCH_DEBOUNCE_MS = 120
const DESKTOP_QUERY = '(min-width: 48rem)'
const LIST_COLUMNS = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px'
const SKELETON_ROWS = 6
const PAGINATOR_THRESHOLD = 25
const NOBODY = 'none'
const LOOKUP_SIZE = 100
type PanelTarget =
| { kind: 'add' }
| { kind: 'edit'; author: Author; own: boolean }
| { kind: 'view'; author: Author }
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const overlay = useLocalizedOverlay()
const toasts = useDirectoryToasts()
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const { listAuthors, listAllAuthors, deleteAuthor } = useAuthors()
const collection = useDirectoryCollection(listAuthors)
const removal = useDirectoryDelete<Author>()
const { data: result, showSkeleton } = collection
const { target: deleteTarget, pending: deletePending, notice: deleteNotice } = removal
const loadState = computed(() =>
collection.state.value === 'forbidden' ? 'error' : collection.state.value,
)
const pendingPage = ref<number | null>(null)
const searchText = ref(parseDirectoryQuery(route.query).q)
const panel = ref<PanelTarget | null>(null)
const linkTarget = ref<Author | null>(null)
const deleteOptions = ref<ReassignOption[]>([])
const destination = ref<string | number | undefined>(undefined)
let searchTimer: ReturnType<typeof setTimeout> | null = null
const sessionReady = computed(() => session.status === 'ready')
const canManage = computed(() => session.canManageContent)
const query = computed(() => parseDirectoryQuery(route.query))
const queryKey = computed(() => JSON.stringify(query.value))
const items = computed(() => result.value?.items ?? [])
const total = computed(() => result.value?.total ?? 0)
const pageCount = computed(() => pageCountFor(total.value, query.value.size))
const firstRun = computed(
() => loadState.value === 'ready' && total.value === 0 && query.value.q === '',
)
const noResults = computed(
() => loadState.value === 'ready' && total.value === 0 && query.value.q !== '',
)
const showToolbar = computed(() => loadState.value !== 'error' && !firstRun.value)
const showList = computed(
() =>
(loadState.value === 'ready' && total.value > 0) ||
(loadState.value === 'loading' && showSkeleton.value),
)
const showPaginator = computed(
() => loadState.value === 'ready' && (total.value > PAGINATOR_THRESHOLD || pageCount.value > 1),
)
const overlayOpen = computed(
() => panel.value !== null || linkTarget.value !== null || deleteTarget.value !== null,
)
const showPinnedButton = computed(
() =>
!isDesktop.value &&
canManage.value &&
!overlayOpen.value &&
loadState.value === 'ready' &&
!firstRun.value,
)
const description = computed(() =>
canManage.value
? t("The names readers see on your articles. An author doesn't need an account.")
: t('The names readers see on your articles. Only an owner or admin can change authors.'),
)
const headings = computed(() => [t('Author'), t('Slug'), t('Account'), t('Articles'), ''])
const ownSlug = computed(() => session.session?.author?.slug ?? '')
const knownSlugs = computed(() => items.value.map((author) => author.slug))
function clearSearchTimer() {
if (searchTimer !== null) {
clearTimeout(searchTimer)
searchTimer = null
}
}
async function load(requested: DirectoryQuery) {
if (result.value !== null) {
pendingPage.value = requested.page
}
const outcome = await collection.load([requested], {
accept: (response) => {
const lastPage = pageCountFor(response.total, requested.size)
if (response.total > 0 && requested.page > lastPage) {
replaceQuery({ ...requested, page: lastPage })
return false
}
return true
},
})
if (outcome !== 'stale') {
pendingPage.value = null
}
if (outcome === 'ready') {
followLink()
}
}
async function replaceQuery(next: DirectoryQuery, mode: 'push' | 'replace' = 'replace') {
const target = toDirectoryRouteQuery(next, route.query)
if (sameRouteQuery(route.query, target)) {
return
}
await router[mode]({ query: target })
}
function normalizeUrl() {
const target = toDirectoryRouteQuery(query.value, route.query)
if (!sameRouteQuery(route.query, target)) {
router.replace({ query: target })
}
}
watch(
[sessionReady, queryKey],
([ready]) => {
if (!ready) {
return
}
normalizeUrl()
load(query.value)
},
{ immediate: true },
)
watch(
() => query.value.q,
(value) => {
if (value !== searchText.value.trim()) {
searchText.value = value
}
},
)
watch(searchText, (value) => {
clearSearchTimer()
searchTimer = setTimeout(() => {
searchTimer = null
const text = value.trim()
if (text !== query.value.q) {
replaceQuery({ q: text, page: 1, size: query.value.size })
}
}, SEARCH_DEBOUNCE_MS)
})
watch(
() => route.query.edit,
() => {
if (loadState.value === 'ready') {
followLink()
}
},
)
onBeforeUnmount(clearSearchTimer)
function clearSearch() {
clearSearchTimer()
searchText.value = ''
replaceQuery({ q: '', page: 1, size: query.value.size })
}
function changePage(page: number) {
replaceQuery({ ...query.value, page }, 'push')
}
function changeSize(size: number) {
replaceQuery({ ...query.value, size, page: 1 }, 'push')
}
async function findBySlug(slug: string) {
const loaded = items.value.find((author) => author.slug === slug)
if (loaded) {
return loaded
}
try {
const found = await listAuthors({ q: slug, page: 1, size: LOOKUP_SIZE })
return found.items.find((author) => author.slug === slug) ?? null
} catch {
return null
}
}
function openFor(author: Author): PanelTarget {
if (canManage.value) {
return { kind: 'edit', author, own: false }
}
return author.slug === ownSlug.value
? { kind: 'edit', author, own: true }
: { kind: 'view', author }
}
function followLink() {
return followEditLink(
findBySlug,
(author) => {
panel.value = openFor(author)
},
toasts.authorGone,
)
}
function openAdd() {
rememberOpener()
panel.value = { kind: 'add' }
}
function openEdit(author: Author, trigger: HTMLElement | null) {
rememberOpener(trigger)
panel.value = { kind: 'edit', author, own: false }
}
function openRow(author: Author) {
rememberOpener()
panel.value = { kind: 'view', author }
}
function closePanel() {
panel.value = null
leavePanel()
}
function replaceItem(author: Author) {
if (!result.value) {
return
}
result.value = {
...result.value,
items: result.value.items.map((item) =>
item.id === author.id
? { ...author, user_email: author.user_email ?? item.user_email }
: item,
),
}
}
function removeItem(id: number) {
if (!result.value) {
return
}
const remaining = result.value.items.filter((item) => item.id !== id)
result.value = { ...result.value, items: remaining, total: Math.max(0, result.value.total - 1) }
if (remaining.length === 0 && result.value.total > 0) {
load(query.value)
}
}
function onSaved(author: Author) {
if (items.value.some((item) => item.id === author.id)) {
replaceItem(author)
} else {
load(query.value)
}
closePanel()
}
function onGone(id: number) {
removeItem(id)
closePanel()
}
function openLink(author: Author, trigger: HTMLElement | null) {
rememberOpener(trigger)
linkTarget.value = author
}
function closeLink() {
linkTarget.value = null
restoreFocus()
}
function onLinked(author: Author) {
replaceItem(author)
closeLink()
}
function onLinkGone(id: number) {
removeItem(id)
closeLink()
}
function nobodyOption(): ReassignOption {
return {
value: NOBODY,
label: t('Nobody'),
description: t('Remove the byline'),
display: t('Nobody'),
none: true,
}
}
async function loadDeleteOptions(author: Author) {
let others: Author[] = []
let failed = false
try {
others = (await listAllAuthors()).filter((candidate) => candidate.id !== author.id)
} catch {
failed = true
}
const options: ReassignOption[] = [
...others.map((candidate) => ({
value: candidate.id,
label: candidate.name,
description: candidate.slug,
display: candidate.name,
avatarName: candidate.name,
})),
nobodyOption(),
]
return { options, failed }
}
async function openDelete(author: Author, trigger: HTMLElement | null) {
rememberOpener(trigger)
const loaded = author.article_count > 0 ? await loadDeleteOptions(author) : null
deleteOptions.value = loaded?.options ?? []
destination.value = deleteOptions.value[0]?.value
removal.open(author)
if (loaded?.failed) {
removal.notice.value = {
title: copy('loadAuthorsFailed'),
description: copy('checkConnection'),
}
}
}
function closeDelete() {
removal.close()
restoreFocus()
}
const chosenAuthorName = computed(
() => deleteOptions.value.find((option) => option.value === destination.value)?.label ?? '',
)
const deleteBody = computed(() => {
const target = deleteTarget.value
if (!target) {
return { description: '', summary: '', confirm: '', pending: '' }
}
if (target.article_count === 0) {
return {
description: t(
'"{0}" will be deleted. No articles credit this author. This can\'t be undone.',
[target.name],
),
summary: '',
confirm: t('Delete author'),
pending: t('Deleting…'),
}
}
const many = target.article_count !== 1
return {
description: many
? t('"{0}" will be deleted and is credited on {1} articles. This can\'t be undone.', [
target.name,
target.article_count,
])
: t('"{0}" will be deleted and is credited on 1 article. This can\'t be undone.', [
target.name,
]),
summary:
destination.value === NOBODY
? t(
'The articles keep any other authors. Articles with only this author will have no byline.',
)
: t('Where an article has several authors, {0} is removed and the others stay.', [
target.name,
]),
confirm: many
? t('Delete and reassign {0} articles', [target.article_count])
: t('Delete and reassign 1 article'),
pending: t('Reassigning…'),
}
})
async function runDelete(target: Author) {
const credited = target.article_count
const reassignTo =
credited === 0
? undefined
: destination.value === NOBODY
? NOBODY
: Number(destination.value)
const receiver = reassignTo === NOBODY || credited === 0 ? null : chosenAuthorName.value
await deleteAuthor(target.id, reassignTo)
removeItem(target.id)
toasts.authorDeleted(credited, receiver)
closeDelete()
}
async function handleDeleteFailure(error: unknown, target: Author) {
const kind = classifyFailure(error, 'author')
if (kind === 'session') {
return
}
if (kind === 'hasArticles') {
closeDelete()
await showBlocked(target)
return
}
if (toasts.report(kind, 'author')) {
if (kind === 'gone') {
removeItem(target.id)
}
closeDelete()
return
}
removal.notice.value = {
title: t("Couldn't delete the author"),
description: copy('nothingChanged'),
}
}
function confirmDelete() {
return removal.confirm(runDelete, handleDeleteFailure)
}
async function creditedCount(target: Author) {
try {
const found = await listAuthors({ q: target.slug, page: 1, size: LOOKUP_SIZE })
return (
found.items.find((item) => item.id === target.id)?.article_count || target.article_count
)
} catch {
return target.article_count
}
}
async function showBlocked(target: Author) {
const count = Math.max(1, await creditedCount(target))
const reassign = await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t("Can't delete this author")
},
get description() {
return count === 1
? t(
'"{0}" is credited on 1 article. Choose another author to credit instead, or remove {0} from those articles first.',
[target.name],
)
: t(
'"{0}" is credited on {1} articles. Choose another author to credit instead, or remove {0} from those articles first.',
[target.name, count],
)
},
get confirmLabel() {
return t('Reassign articles')
},
get cancelLabel() {
return t('Close')
},
get pendingLabel() {
return t('Reassign articles')
},
tone: 'neutral',
action: async () => undefined,
},
})
.open()
if (reassign) {
await openDelete({ ...target, article_count: count }, null)
} else {
restoreFocus()
}
}
function menuItems(author: Author): DirectoryMenuItem[] {
const items: DirectoryMenuItem[] = [
{
key: 'edit',
label: t('Edit'),
icon: 'i-lucide-pencil',
onSelect: (trigger) => openEdit(author, trigger),
},
]
if (!author.is_linked) {
items.push({
key: 'link',
label: t('Link user'),
icon: 'i-lucide-link',
onSelect: (trigger) => openLink(author, trigger),
})
}
items.push({
key: 'delete',
label: t('Delete…'),
icon: 'i-lucide-trash-2',
destructive: true,
separated: true,
onSelect: (trigger) => openDelete(author, trigger),
})
return items
}
const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q]))
</script>
<template>
<DashboardShell compact-padding :mobile-title="t('Authors')">
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ t('Authors') }}</h1>
<p class="text-muted">{{ description }}</p>
</div>
<DirectoryAddButton v-if="isDesktop && canManage" @click="openAdd">
{{ t('Add author') }}
</DirectoryAddButton>
</div>
<UInput
v-if="showToolbar"
v-model="searchText"
type="search"
icon="i-lucide-search"
:placeholder="t('Search by name or slug')"
:aria-label="t('Search by name or slug')"
class="w-full sm:w-70"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
/>
<UAlert
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load authors')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="load(query)"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<UEmpty
v-else-if="firstRun"
variant="naked"
icon="i-lucide-feather"
:title="t('No authors yet')"
:description="
t('Authors are the names readers see on articles. They don\'t need an account.')
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-85 text-pretty text-muted',
}"
>
<template v-if="canManage" #actions>
<UButton icon="i-lucide-plus" class="h-11 px-4 md:h-8" @click="openAdd">
{{ t('Add author') }}
</UButton>
</template>
</UEmpty>
<UEmpty
v-else-if="noResults"
variant="naked"
icon="i-lucide-search"
:title="noResultsTitle"
:description="t('Check the spelling, or search for a shorter part of the name.')"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-85 text-pretty text-muted',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
@click="clearSearch"
>
{{ t('Clear search') }}
</UButton>
</template>
</UEmpty>
<DirectoryList
v-else-if="showList"
:items="items"
:columns="LIST_COLUMNS"
:headings="headings"
:loading="loadState === 'loading'"
:skeleton-rows="SKELETON_ROWS"
:read-only="!canManage"
:label="t('Authors')"
:row-title="(author: Author) => author.name"
:menu-items="menuItems"
@open="openRow"
>
<template #cells="{ item }">
<div class="flex min-w-0 items-center gap-2.5">
<AuthorAvatar :name="item.name" :size="32" />
<span class="truncate font-medium text-highlighted" :title="item.name">{{
item.name
}}</span>
</div>
<span class="truncate font-mono text-xs/4.5 text-muted">{{ item.slug }}</span>
<span class="flex">
<DirectoryBadge :kind="item.is_linked ? 'linked' : 'guest'" />
</span>
<span class="text-muted">{{ pluralArticles(item.article_count) }}</span>
</template>
<template #card="{ item }">
<AuthorAvatar :name="item.name" :size="40" />
<div class="flex min-w-0 flex-1 flex-col gap-0.75">
<span
class="truncate text-md/5.25 font-medium text-highlighted"
:title="item.name"
>{{ item.name }}</span
>
<span class="truncate font-mono text-xs/4 text-muted">{{ item.slug }}</span>
<div class="flex items-center gap-2 text-xs text-muted">
<DirectoryBadge :kind="item.is_linked ? 'linked' : 'guest'" />
<span>{{ pluralArticles(item.article_count) }}</span>
</div>
</div>
</template>
</DirectoryList>
<AppPaginator
v-if="showPaginator"
:page="query.page"
:page-count="pageCount"
:total="total"
:size="query.size"
:loading-page="pendingPage"
@update:page="changePage"
@update:size="changeSize"
/>
</div>
<DirectoryAddButton v-if="showPinnedButton" pinned @click="openAdd">
{{ t('Add author') }}
</DirectoryAddButton>
<AuthorFormPanel
v-if="panel && (panel.kind === 'add' || panel.kind === 'edit')"
:key="panel.kind === 'edit' ? panel.author.id : 'add'"
:author="panel.kind === 'edit' ? panel.author : undefined"
:own-profile="panel.kind === 'edit' && panel.own"
:own-email="session.session?.email ?? ''"
:known-slugs="knownSlugs"
@close="closePanel"
@saved="onSaved"
@updated="replaceItem"
@gone="onGone"
/>
<AuthorViewPanel
v-if="panel && panel.kind === 'view'"
:author="panel.author"
@close="closePanel"
/>
<LinkUserDialog
v-if="linkTarget"
:author="linkTarget"
@close="closeLink"
@linked="onLinked"
@gone="onLinkGone"
/>
<ReassignDialog
v-if="deleteTarget"
v-model:selected="destination"
closable
:title="t('Delete this author?')"
:description="deleteBody.description"
:confirm-label="deleteBody.confirm"
:pending-label="deleteBody.pending"
:select-label="t('Credit these articles to')"
:options="deleteOptions"
:summary="deleteBody.summary"
:pending="deletePending"
:notice="deleteNotice"
@confirm="confirmDelete"
@close="closeDelete"
>
<p v-if="deleteTarget.is_linked && deleteTarget.user_email" class="text-xs text-muted">
{{
t("Linked to {0}. The user account isn't affected.", [deleteTarget.user_email])
}}
</p>
</ReassignDialog>
</DashboardShell>
</template>
+4 -1
View File
@@ -6,6 +6,7 @@ import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import MediaLinksCard from '@/components/MediaLinksCard.vue'
import PublishingLinksCard from '@/components/PublishingLinksCard.vue'
import MediaCleanupCard from '@/components/MediaCleanupCard.vue'
import RestartOverlay from '@/components/RestartOverlay.vue'
import RetentionCard from '@/components/RetentionCard.vue'
@@ -56,7 +57,7 @@ const updatesCard = ref<InstanceType<typeof UpdatesCard> | null>(null)
const loadState = ref<LoadState>('loading')
const settings = ref<WorkspaceSettings | null>(null)
const version = computed(() => settings.value?.version ?? '')
const isOwner = computed(() => session.session?.role === 'owner')
const isOwner = computed(() => session.isOwner)
const showSkeleton = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
@@ -465,6 +466,8 @@ onBeforeUnmount(() => {
<MediaLinksCard />
<PublishingLinksCard v-if="isOwner" />
<MediaCleanupCard />
<RetentionCard
+541
View File
@@ -0,0 +1,541 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
import DirectoryBadge from '@/components/DirectoryBadge.vue'
import DirectoryList from '@/components/DirectoryList.vue'
import ReassignDialog, { type ReassignOption } from '@/components/ReassignDialog.vue'
import SiteFormPanel from '@/components/SiteFormPanel.vue'
import SitesForbidden from '@/components/SitesForbidden.vue'
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
import { useDirectoryView } from '@/composables/useDirectoryView'
import { useSites, type Site } from '@/composables/useSites'
import { hostOf, pluralArticles } from '@/lib/directory'
import { COPY, copy } from '@/lib/directoryCopy'
import { classifyFailure } from '@/lib/directoryErrors'
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
import { useSessionStore } from '@/stores/session'
const DESKTOP_QUERY = '(min-width: 48rem)'
const LIST_COLUMNS = 'minmax(0,1.1fr) minmax(0,1.5fr) 110px 44px'
const SKELETON_ROWS = 3
type PanelTarget = { mode: 'add' } | { mode: 'edit'; site: Site }
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const overlay = useLocalizedOverlay()
const toasts = useDirectoryToasts()
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const { listSites, setDefaultSite, deleteSite } = useSites()
const collection = useDirectoryCollection(listSites)
const removal = useDirectoryDelete<Site>()
const { state: loadState, showSkeleton } = collection
const { target: deleteTarget, pending: deletePending, notice: deleteNotice } = removal
const sites = computed({
get: () => collection.data.value ?? [],
set: (value: Site[]) => {
collection.data.value = value
},
})
const panel = ref<PanelTarget | null>(null)
const destinationId = ref<number | undefined>(undefined)
let requested = false
const sessionReady = computed(() => session.status === 'ready')
const isOwner = computed(() => session.isOwner)
const forbidden = computed(
() => (sessionReady.value && !isOwner.value) || loadState.value === 'forbidden',
)
const showList = computed(
() => loadState.value === 'ready' || (loadState.value === 'loading' && showSkeleton.value),
)
const onlyDefault = computed(
() => loadState.value === 'ready' && sites.value.length === 1 && sites.value[0]!.is_default,
)
const overlayOpen = computed(() => panel.value !== null || deleteTarget.value !== null)
const defaultSite = computed(() => sites.value.find((site) => site.is_default))
const panelSite = computed(() => {
const target = panel.value
if (!target || target.mode === 'add') {
return undefined
}
return sites.value.find((site) => site.id === target.site.id) ?? target.site
})
const destinationOptions = computed<ReassignOption[]>(() => {
const target = deleteTarget.value
if (!target || target.article_count === 0) {
return []
}
return sites.value
.filter((site) => site.id !== target.id)
.map((site) => ({
value: site.id,
label: site.name,
description: hostOf(site.base_url),
display: `${site.name} · ${hostOf(site.base_url)}`,
}))
})
const destination = computed(() => sites.value.find((site) => site.id === destinationId.value))
const deleteDescription = computed(() => {
const target = deleteTarget.value
if (!target) {
return ''
}
if (target.article_count === 0) {
return t('"{0}" will be deleted. No articles belong to it. This can\'t be undone.', [
target.name,
])
}
return target.article_count === 1
? t(
'"{0}" will be deleted. 1 article belongs to it, so choose a site to move them to. This can\'t be undone.',
[target.name],
)
: t(
'"{0}" will be deleted. {1} articles belong to it, so choose a site to move them to. This can\'t be undone.',
[target.name, target.article_count],
)
})
const deleteSummary = computed(() => {
const target = deleteTarget.value
const to = destination.value
if (!target || !to) {
return ''
}
return target.article_count === 1
? t('1 article will move to {0}. Their canonical host changes to {1}.', [
to.name,
hostOf(to.base_url),
])
: t('{0} articles will move to {1}. Their canonical host changes to {2}.', [
target.article_count,
to.name,
hostOf(to.base_url),
])
})
const deleteConfirmLabel = computed(() => {
const target = deleteTarget.value
if (!target || target.article_count === 0) {
return t('Delete site')
}
return target.article_count === 1
? t('Move 1 article and delete')
: t('Move {0} articles and delete', [target.article_count])
})
const deletePendingLabel = computed(() =>
deleteTarget.value && deleteTarget.value.article_count > 0
? t('Moving articles…')
: t('Deleting…'),
)
async function loadSites() {
const outcome = await collection.load([])
if (outcome === 'ready') {
followLink()
}
}
watch(
[sessionReady, isOwner],
([ready, owner]) => {
if (ready && owner && !requested) {
requested = true
loadSites()
}
},
{ immediate: true },
)
watch(
() => route.query.edit,
() => {
if (loadState.value === 'ready' && isOwner.value) {
followLink()
}
},
)
function followLink() {
if (!isOwner.value) {
return
}
return followEditLink(
(key) => sites.value.find((candidate) => candidate.key === key) ?? null,
(site) => {
panel.value = { mode: 'edit', site }
},
toasts.siteGone,
)
}
function openAdd() {
rememberOpener()
panel.value = { mode: 'add' }
}
function openEdit(site: Site, trigger: HTMLElement | null) {
rememberOpener(trigger)
panel.value = { mode: 'edit', site }
}
function closePanel() {
panel.value = null
leavePanel()
}
function upsert(site: Site) {
const exists = sites.value.some((candidate) => candidate.id === site.id)
sites.value = exists
? sites.value.map((candidate) => (candidate.id === site.id ? site : candidate))
: [...sites.value, site]
if (site.is_default) {
markDefault(site.id)
}
}
function markDefault(id: number) {
sites.value = sites.value.map((site) => ({ ...site, is_default: site.id === id }))
}
function removeSite(id: number) {
sites.value = sites.value.filter((site) => site.id !== id)
}
function onSaved(site: Site) {
upsert(site)
closePanel()
}
function onGone(id: number) {
removeSite(id)
closePanel()
}
function confirmDefault(site: Site, trigger: HTMLElement | null) {
rememberOpener(trigger)
overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Make {0} the default site?', [site.name])
},
get description() {
return t(
'New articles start on this site. Articles that are already on other sites stay where they are.',
)
},
get confirmLabel() {
return t('Make default')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Saving…')
},
tone: 'neutral',
action: async () => {
try {
await setDefaultSite(site.id)
} catch (error) {
const kind = classifyFailure(error, 'site')
if (kind === 'session') {
return
}
if (toasts.report(kind, 'site')) {
if (kind === 'gone') {
removeSite(site.id)
}
return
}
throw new Error(COPY.siteSaveFailed)
}
markDefault(site.id)
toasts.siteDefaultChanged(site.name)
},
},
})
.open()
.then(restoreFocus)
}
function openDelete(site: Site, trigger: HTMLElement | null) {
rememberOpener(trigger)
removal.open(site)
const options = destinationOptions.value
const preferred = options.find((option) => option.value === defaultSite.value?.id)
destinationId.value = Number((preferred ?? options[0])?.value) || undefined
}
function closeDelete() {
removal.close()
restoreFocus()
}
async function refreshTarget(id: number) {
try {
sites.value = await listSites()
removal.target.value = sites.value.find((site) => site.id === id) ?? null
} catch {
return
}
}
async function runDelete(target: Site) {
const moved = target.article_count
const to = moved > 0 ? destination.value : undefined
await deleteSite(target.id, to?.id)
removeSite(target.id)
if (to) {
to.article_count += moved
}
toasts.siteDeleted(moved, to?.name ?? '')
closeDelete()
}
async function handleDeleteFailure(error: unknown, target: Site) {
const kind = classifyFailure(error, 'site')
if (kind === 'session') {
return
}
if (kind === 'hasArticles') {
await refreshTarget(target.id)
destinationId.value = defaultSite.value?.id
removal.notice.value = {
description: t('Choose where the articles go. This site has {0} articles.', [
deleteTarget.value?.article_count ?? target.article_count,
]),
}
return
}
if (toasts.report(kind, 'site')) {
if (kind === 'gone') {
removeSite(target.id)
}
closeDelete()
return
}
removal.notice.value = {
title: t("Couldn't delete the site"),
description: copy('nothingChanged'),
}
}
function confirmDelete() {
return removal.confirm(runDelete, handleDeleteFailure)
}
function menuItems(site: Site): DirectoryMenuItem[] {
const items: DirectoryMenuItem[] = [
{
key: 'edit',
label: t('Edit'),
icon: 'i-lucide-pencil',
onSelect: (trigger) => openEdit(site, trigger),
},
]
if (!site.is_default) {
items.push({
key: 'default',
label: t('Set as default'),
icon: 'i-lucide-star',
onSelect: (trigger) => confirmDefault(site, trigger),
})
}
items.push({
key: 'delete',
label: t('Delete…'),
icon: 'i-lucide-trash-2',
destructive: true,
separated: !site.is_default,
unavailable: site.is_default,
hint: site.is_default ? copy('defaultSiteBlocked') : undefined,
onSelect: (trigger) => openDelete(site, trigger),
})
return items
}
const headings = computed(() => [t('Site'), t('Base URL'), t('Articles'), ''])
function backToSettings() {
router.push('/settings')
}
</script>
<template>
<DashboardShell compact-padding :mobile-title="t('Sites')">
<template #breadcrumb>
<UBreadcrumb
:items="[
{
get label() {
return t('Settings')
},
to: '/settings',
},
{
get label() {
return t('Sites')
},
},
]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<SitesForbidden v-if="forbidden" @back="backToSettings" />
<div v-else-if="sessionReady" class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ t('Sites') }}</h1>
<p class="text-muted">{{ t('The frontends that display your content.') }}</p>
</div>
<DirectoryAddButton v-if="isDesktop" @click="openAdd">
{{ t('Add site') }}
</DirectoryAddButton>
</div>
<UAlert
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load sites')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="loadSites"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<DirectoryList
v-if="showList"
:items="sites"
:columns="LIST_COLUMNS"
:headings="headings"
:loading="loadState === 'loading'"
:skeleton-rows="SKELETON_ROWS"
:label="t('Sites')"
:row-title="(site: Site) => site.name"
:menu-items="menuItems"
>
<template #cells="{ item }">
<div class="flex min-w-0 flex-col gap-0.5">
<div class="flex min-w-0 items-center gap-2">
<span
class="truncate font-medium text-highlighted"
:title="item.name"
>{{ item.name }}</span
>
<DirectoryBadge v-if="item.is_default" kind="default" />
<DirectoryBadge
v-if="item.default_robots === 'noindex'"
kind="noindex"
/>
</div>
<span class="font-mono text-xs/4 text-muted">{{ item.key }}</span>
</div>
<span class="truncate font-mono text-xs/4.5 text-toned">{{
item.base_url
}}</span>
<span class="text-muted">{{ pluralArticles(item.article_count) }}</span>
</template>
<template #card="{ item }">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex min-w-0 items-center gap-2">
<span class="truncate font-medium text-highlighted">{{
item.name
}}</span>
<DirectoryBadge v-if="item.is_default" kind="default" />
<DirectoryBadge
v-if="item.default_robots === 'noindex'"
kind="noindex"
/>
</div>
<span class="truncate font-mono text-xs/4.5 text-toned">{{
hostOf(item.base_url)
}}</span>
<span class="text-xs/5 text-muted">{{
pluralArticles(item.article_count)
}}</span>
</div>
</template>
</DirectoryList>
<p v-if="onlyDefault" class="text-pretty text-muted">
{{
t(
'Add another site if your content is shown on more than one frontend. Each article belongs to one site.',
)
}}
</p>
</div>
<DirectoryAddButton
v-if="!isDesktop && !forbidden && loadState !== 'error'"
v-show="!overlayOpen"
pinned
@click="openAdd"
>
{{ t('Add site') }}
</DirectoryAddButton>
<SiteFormPanel
v-if="panel"
:key="panel.mode === 'edit' ? panel.site.id : 'add'"
:site="panelSite"
:sites="sites"
@close="closePanel"
@saved="onSaved"
@updated="upsert"
@defaulted="upsert"
@gone="onGone"
/>
<ReassignDialog
v-if="deleteTarget"
v-model:selected="destinationId"
:title="t('Delete this site?')"
:description="deleteDescription"
:confirm-label="deleteConfirmLabel"
:pending-label="deletePendingLabel"
:select-label="t('Move articles to')"
:options="destinationOptions"
:summary="deleteSummary"
:pending="deletePending"
:notice="deleteNotice"
@confirm="confirmDelete"
@close="closeDelete"
/>
</DashboardShell>
</template>
+1 -3
View File
@@ -54,9 +54,7 @@ let formOpener: HTMLElement | null = null
let requested = false
const sessionReady = computed(() => session.status === 'ready')
const canManage = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const canManage = computed(() => session.canManageContent)
const forbidden = computed(
() => (sessionReady.value && !canManage.value) || store.loadState === 'forbidden',
)
+2 -1
View File
@@ -79,12 +79,13 @@ describe('Navigation', () => {
'/home',
'/article',
'/media',
'/authors',
'/users',
'/activity',
'/integration',
'/settings',
])
const activity = links[4]!
const activity = links[5]!
expect(activity.getAttribute('aria-current')).toBe('page')
await expectIcon(activity, 'history')
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe('Activity')
@@ -0,0 +1,209 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
textOf,
unmountDirectory,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountDirectory)
async function openDelete(name: string, backend = createBackend()) {
await mountDirectory('authors', '/authors', { backend })
await openRowMenu(name)
await chooseMenuItem('Delete…')
return backend
}
function box() {
return dialog()!
}
function trigger() {
return box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
}
async function openSelect() {
trigger().click()
await flushPromises()
}
function optionLabels() {
return Array.from(box().querySelectorAll('[role="option"]')).map((option) => textOf(option))
}
describe('Authors delete: reassign (recommended)', () => {
it('explains, preselects the first other author and lists the buttons', async () => {
const backend = await openDelete('Camille Roche')
const text = textOf(box())
expect(box().getAttribute('role')).toBe('alertdialog')
expect(text).toContain('Delete this author?')
expect(text).toContain(
'"Camille Roche" will be deleted and is credited on 3 articles. This can\'t be undone.',
)
expect(text).toContain('Credit these articles to')
expect(textOf(trigger())).toBe('Inès Duarte')
expect(text).toContain(
'Where an article has several authors, Camille Roche is removed and the others stay.',
)
expect(button('Cancel', box())).toBeTruthy()
expect(button('Delete and reassign 3 articles', box())).toBeTruthy()
expect(
backend.calls.filter((call) => call.raw === '/v1/author/?page=1&size=100'),
).toHaveLength(1)
})
it('adds the linked-account line for a linked author', async () => {
await openDelete('Inès Duarte')
expect(textOf(box())).toContain(
"Linked to ines.duarte@renew.example. The user account isn't affected.",
)
})
})
describe('Authors delete: reassign succeeds', () => {
it('sends one DELETE with reassign_to, reads Reassigning… and toasts', async () => {
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['DELETE /v1/author/7'] = () => hold.promise
await openDelete('Camille Roche', backend)
await click('Delete and reassign 3 articles', box())
expect(button('Reassigning…', box())).toBeTruthy()
hold.resolve(undefined)
await flushPromises()
const deletes = callsTo(backend, 'DELETE', '/v1/author/7')
expect(deletes).toHaveLength(1)
expect(deletes[0]!.query.get('reassign_to')).toBe('2')
expect(rowTexts().join(' ')).not.toContain('Camille Roche')
expect(document.body.textContent).toContain('Author deleted')
expect(document.body.textContent).toContain('3 articles now credit Inès Duarte.')
})
})
describe('Authors delete: choose who', () => {
it('does not list the deleted author and ends with Nobody', async () => {
await openDelete('Camille Roche')
await openSelect()
const labels = optionLabels()
expect(labels.some((label) => label.includes('Camille Roche'))).toBe(false)
expect(labels.at(-1)).toContain('Nobody')
expect(labels.at(-1)).toContain('Remove the byline')
expect(labels[0]).toContain('Inès Duarte')
})
})
describe('Authors delete: nobody', () => {
it('explains the byline and sends reassign_to=none', async () => {
const backend = await openDelete('Camille Roche')
await openSelect()
Array.from(box().querySelectorAll<HTMLElement>('[role="option"]'))
.find((option) => textOf(option).includes('Nobody'))!
.click()
await flushPromises()
expect(textOf(box())).toContain(
'The articles keep any other authors. Articles with only this author will have no byline.',
)
await click('Delete and reassign 3 articles', box())
expect(callsTo(backend, 'DELETE', '/v1/author/7')[0]!.query.get('reassign_to')).toBe('none')
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Authors delete: failed with %s', (_name, responder) => {
it('keeps the dialog open with the notice and enables the buttons again', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/author/7'] = responder
await openDelete('Camille Roche', backend)
await click('Delete and reassign 3 articles', box())
const alert = textOf(box().querySelector('[role="alert"]'))
expect(alert).toContain("Couldn't delete the author")
expect(alert).toContain('Nothing was changed. Try again in a moment.')
expect(button('Cancel', box())!.hasAttribute('disabled')).toBe(false)
expect(button('Delete and reassign 3 articles', box())!.hasAttribute('disabled')).toBe(
false,
)
})
})
describe('Authors delete: server fallback (409)', () => {
it('opens the fallback dialog and Reassign articles reopens the reassign dialog', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/author/7'] = failWith(409, 'author_has_articles')
await openDelete('Camille Roche', backend)
await click('Delete and reassign 3 articles', box())
await flushPromises()
const text = textOf(box())
expect(text).toContain("Can't delete this author")
expect(text).toContain(
'"Camille Roche" is credited on 3 articles. Choose another author to credit instead, or remove Camille Roche from those articles first.',
)
expect(button('Close', box())).toBeTruthy()
expect(button('Reassign articles', box())).toBeTruthy()
await click('Reassign articles', box())
await flushPromises()
expect(textOf(box())).toContain('Delete this author?')
})
})
describe('Authors delete: no articles', () => {
it('asks a plain confirmation with no select and sends no reassign_to', async () => {
const backend = await openDelete('Léa Fontaine')
const text = textOf(box())
expect(text).toContain('Delete this author?')
expect(text).toContain(
'"Léa Fontaine" will be deleted. No articles credit this author. This can\'t be undone.',
)
expect(trigger()).toBeNull()
expect(button('Delete author', box())).toBeTruthy()
await click('Delete author', box())
const deletes = callsTo(backend, 'DELETE', '/v1/author/10')
expect(deletes).toHaveLength(1)
expect(deletes[0]!.query.has('reassign_to')).toBe(false)
expect(rowTexts().join(' ')).not.toContain('Léa Fontaine')
})
})
describe('Authors delete: author already gone', () => {
it('closes, removes the row and shows the info toast on 404', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/author/7'] = failWith(404, 'author_not_found')
await openDelete('Camille Roche', backend)
await click('Delete and reassign 3 articles', box())
expect(dialog()).toBeNull()
expect(rowTexts().join(' ')).not.toContain('Camille Roche')
expect(document.body.textContent).toContain('That author no longer exists')
})
})
+543
View File
@@ -0,0 +1,543 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
authorsFixture,
blur,
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
field,
labelled,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
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 URL_MESSAGE = 'Enter a full URL starting with https://.'
const SAVE_FAILED = "Couldn't save the author. Your changes are still here. Try again in a moment."
afterEach(unmountDirectory)
function box() {
return dialog()!
}
function nameField() {
return field('e.g. Camille Roche')
}
function bioField() {
return field('A sentence or two about the author.')
}
function websiteField() {
return field('https://example.com')
}
function slugField() {
return labelled('Slug') as HTMLInputElement
}
function submit(label = 'Add author') {
return click(label, box())
}
function userTrigger() {
return box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
}
async function openAdd(options: Parameters<typeof mountDirectory>[2] = {}) {
const backend = await mountDirectory('authors', '/authors', options)
await click('Add author')
return backend
}
async function openEdit(name: string, options: Parameters<typeof mountDirectory>[2] = {}) {
const backend = await mountDirectory('authors', '/authors', options)
await openRowMenu(name)
await chooseMenuItem('Edit')
return backend
}
describe('Authors form: add, empty', () => {
it('opens a slide-over with every field, placeholder, counter and the byline preview', async () => {
await openAdd()
const text = textOf(box())
for (const copy of [
'Add author',
'Avatar (optional)',
'Choose image',
'Name',
'Slug',
'Bio (optional)',
'0 / 280',
'Website (optional)',
'Social links (optional)',
'Add link',
'0 of 5',
'Linked user (optional)',
'Link a CMS user if this author has an account. Guest writers need no link.',
'Byline preview',
'Author name',
'The bio appears here.',
'Cancel',
]) {
expect(text).toContain(copy)
}
expect(nameField()).toBeTruthy()
expect(field('Generated from the name')).toBeTruthy()
expect(bioField()).toBeTruthy()
expect(websiteField()).toBeTruthy()
expect(textOf(userTrigger())).toBe('No linked user')
expect(button('Add author', box())).toBeTruthy()
})
})
describe('Authors form: slug from the name', () => {
it('fills the slug with an Auto badge, an Edit slug pencil and follows the byline preview', async () => {
await openAdd()
await typeInto(nameField(), 'Camille Roche')
expect(slugField().value).toBe('camille-roche')
const text = textOf(box())
expect(text).toContain('Auto')
expect(button('Edit slug', box())).toBeTruthy()
expect(text).toContain(
'Generated from the name. Used in public author URLs and API paths. Unique across all authors.',
)
expect(text).toContain('Byline preview Camille Roche')
})
})
describe('Authors form: add succeeds', () => {
it('sends exactly one POST with the exact body, reads Saving… and lists the author', async () => {
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['POST /v1/author/'] = (call) => {
backend.authors.push({
id: 200,
is_linked: false,
article_count: 0,
...call.body,
} as never)
return hold.promise
}
await openAdd({ backend })
await typeInto(nameField(), 'Zoe Lambert')
await typeInto(bioField(), 'Writes about plaster.')
await typeInto(websiteField(), 'https://zoe.example')
await click('Add link', box())
await typeInto(
box().querySelector<HTMLInputElement>('input[placeholder="https://x.com/username"]')!,
'https://x.com/zoe',
)
await submit()
expect(button('Saving…', box())).toBeTruthy()
hold.resolve({ id: 200 })
await flushPromises()
const posts = callsTo(backend, 'POST', '/v1/author/')
expect(posts).toHaveLength(1)
expect(posts[0]!.body).toEqual({
name: 'Zoe Lambert',
slug: 'zoe-lambert',
bio: 'Writes about plaster.',
avatar_id: null,
website: 'https://zoe.example',
social_links: [{ type: 'x', url: 'https://x.com/zoe' }],
user_id: null,
})
expect(dialog()).toBeNull()
expect(rowTexts().join(' ')).toContain('Zoe Lambert')
})
})
describe('Authors form: slug collision', () => {
it('shows the message, a suggestion and the reset link, and the suggestion keeps the other values', async () => {
const backend = createBackend()
backend.responders['POST /v1/author/'] = failWith(409, 'author_slug_taken')
await openAdd({ backend })
await typeInto(nameField(), 'Camille Roche')
await typeInto(bioField(), 'A bio')
await submit()
const text = textOf(box())
expect(text).toContain('The slug "camille-roche" is already used by another author.')
expect(text).toContain('Use camille-roche-2')
expect(text).toContain('Reset to match the name')
await click('Use camille-roche-2', box())
expect(slugField().value).toBe('camille-roche-2')
expect(bioField().value).toBe('A bio')
expect(nameField().value).toBe('Camille Roche')
})
})
describe('Authors form: validation', () => {
it('asks for a name and sends nothing', async () => {
const backend = await openAdd()
await submit()
expect(textOf(box())).toContain('Enter a name.')
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
})
it('refuses a bio of 291 characters with the counter in the error colour', async () => {
const backend = await openAdd()
await typeInto(nameField(), 'Zoe')
await typeInto(bioField(), 'x'.repeat(291))
await submit()
expect(textOf(box())).toContain('Shorten the bio to 280 characters.')
const counter = Array.from(box().querySelectorAll('span')).find(
(span) => textOf(span) === '291 / 280' && !span.querySelector('span'),
)!
expect(counter.className).toContain('text-error')
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
})
it('refuses a website without https', async () => {
const backend = await openAdd()
await typeInto(nameField(), 'Zoe')
await typeInto(websiteField(), 'example.com')
await submit()
expect(textOf(box())).toContain(URL_MESSAGE)
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
})
it('refuses a social link with an http URL under its row', async () => {
const backend = await openAdd()
await typeInto(nameField(), 'Zoe')
await click('Add link', box())
await typeInto(
box().querySelector<HTMLInputElement>('input[placeholder="https://x.com/username"]')!,
'http://x',
)
await submit()
const row = box()
.querySelector('[data-testid="social-link-row"]')!
.closest('[data-slot="root"]')!
expect(textOf(row)).toContain(URL_MESSAGE)
expect(callsTo(backend, 'POST', '/v1/author/')).toHaveLength(0)
})
})
describe.each([
[422, 'bio_too_long', 'Shorten the bio to 280 characters.'],
[422, 'invalid_url', URL_MESSAGE],
[409, 'user_already_linked', 'That user already has an author. Choose another user.'],
])('Authors form: server validation %s %s', (status, code, message) => {
it('shows the message under the field', async () => {
const backend = createBackend()
backend.responders['POST /v1/author/'] = failWith(status, code)
await openAdd({ backend })
await typeInto(nameField(), 'Zoe')
await typeInto(websiteField(), 'https://zoe.example')
await submit()
expect(textOf(box())).toContain(message)
})
})
describe('Authors form: social links', () => {
it('adds rows with a type select, a type-specific placeholder, and a limit of five', async () => {
await openAdd()
for (let index = 0; index < 5; index += 1) {
await click('Add link', box())
}
expect(box().querySelectorAll('[data-testid="social-link-row"]')).toHaveLength(5)
expect(button('Add link', box())!.hasAttribute('disabled')).toBe(true)
expect(textOf(box())).toContain('You can add up to 5 links.')
expect(textOf(box())).toContain('5 of 5')
expect(box().querySelectorAll('input[placeholder="https://x.com/username"]')).toHaveLength(
5,
)
})
})
describe('Authors form: linked user, choose', () => {
it('lists the users without an author under their group', async () => {
const backend = await openAdd()
userTrigger().click()
await flushPromises()
const text = textOf(box())
expect(text).toContain('Users without an author')
expect(text).toContain('Marta Alves marta@renew.example')
expect(text).toContain('Rui Santos rui@renew.example')
expect(text).not.toContain('Inès Duarte ines.duarte')
expect(callsTo(backend, 'GET', '/v1/user/')).toHaveLength(1)
expect(
backend.calls.filter((call) => call.raw === '/v1/author/?page=1&size=100'),
).toHaveLength(1)
})
it('pages through the authors to find every linked user', async () => {
const backend = createBackend()
backend.authors = Array.from({ length: 120 }, (_, index) => ({
...authorsFixture()[1]!,
id: 600 + index,
name: `Writer ${index}`,
slug: `writer-${index}`,
user_id: index === 119 ? 1 : null,
}))
await openAdd({ backend })
userTrigger().click()
await flushPromises()
const paths = backend.calls
.filter((call) => call.path === '/v1/author/')
.map((call) => call.raw)
expect(paths).toContain('/v1/author/?page=1&size=100')
expect(paths).toContain('/v1/author/?page=2&size=100')
expect(textOf(box())).not.toContain('Marta Alves')
expect(textOf(box())).toContain('Rui Santos')
})
it('says so when every user already has an author', async () => {
const backend = createBackend()
backend.authors = authorsFixture().map((item, index) => ({ ...item, user_id: index + 1 }))
backend.users = backend.users.slice(0, 1)
await openAdd({ backend })
userTrigger().click()
await flushPromises()
expect(textOf(box())).toContain('Every user already has an author.')
})
})
describe('Authors form: edit a linked author', () => {
it('fills the fields, shows the linked user with Unlink and sends only the changed fields', async () => {
const backend = await openEdit('Inès Duarte')
const text = textOf(box())
expect(text).toContain('Edit Inès Duarte')
expect(nameField().value).toBe('Inès Duarte')
expect(slugField().value).toBe('ines-duarte')
expect(text).toContain('ines.duarte@renew.example')
expect(button('Unlink', box())).toBeTruthy()
expect(text).toContain('New articles by this user start with this author.')
await typeInto(bioField(), 'A new bio')
await submit('Save changes')
const patches = callsTo(backend, 'PATCH', '/v1/author/2')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ bio: 'A new bio' })
})
})
describe('Authors form: unlink', () => {
it('asks, sends one PATCH with user_id null, returns to No linked user and toasts', async () => {
const backend = await openEdit('Inès Duarte')
await click('Unlink', box())
const ask = document.body.querySelector('[role="alertdialog"]')!
const text = textOf(ask)
expect(text).toContain('Unlink Inès Duarte?')
expect(text).toContain(
'Inès Duarte will stay on their articles. New articles by ines.duarte@renew.example will no longer start with this author.',
)
expect(button('Cancel', ask)).toBeTruthy()
await click('Unlink user', ask)
const patches = callsTo(backend, 'PATCH', '/v1/author/2')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ user_id: null })
expect(textOf(userTrigger())).toBe('No linked user')
expect(document.body.textContent).toContain('User unlinked')
expect(document.body.textContent).toContain('Inès Duarte is now a guest author.')
})
})
describe('Authors form: link a guest', () => {
it('links a user from the row menu and shows Linked on the row', async () => {
const backend = await mountDirectory('authors', '/authors')
await openRowMenu('Camille Roche')
await chooseMenuItem('Link user')
const ask = document.body.querySelector('[role="alertdialog"]')!
expect(textOf(ask)).toContain('Link Camille Roche to a user?')
expect(textOf(ask)).toContain('Only users without an author are listed.')
expect(button('Link user', ask)!.hasAttribute('disabled')).toBe(true)
ask.querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
await flushPromises()
expect(textOf(ask)).toContain('Marta Alves marta@renew.example')
const option = Array.from(ask.querySelectorAll<HTMLElement>('[role="option"]')).find(
(item) => textOf(item).includes('Marta Alves'),
)!
option.click()
await flushPromises()
await click('Link user', ask)
const patches = callsTo(backend, 'PATCH', '/v1/author/7')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ user_id: 1 })
expect(rowTexts()[1]).toContain('Linked')
})
})
describe('Authors form: avatar picker', () => {
it('opens the media picker for the avatar, returns the id and sends avatar_id on save', async () => {
const backend = createBackend()
backend.responders['GET /v1/media/'] = () => ({
items: [
{
id: 55,
filename: 'face.jpg',
url: '/media/face.jpg',
size: 1000,
content_type: 'image/jpeg',
width: 400,
height: 400,
sha256: '0abc',
uploaded_by: 'Marta Alves',
created_at: '2026-09-12T12:00:00Z',
used_in: 0,
duplicate: false,
variant_count: 0,
variant_size: 0,
},
],
total: 1,
page: 1,
size: 25,
counts: { all: 1, unused: 1, duplicates: 0 },
})
await openEdit('Camille Roche', { backend })
await click('Choose image', box())
await flushPromises()
const picker = Array.from(document.body.querySelectorAll('[role="dialog"]')).at(-1)!
const text = textOf(picker)
expect(text).toContain('Choose an image')
expect(text).toContain('The avatar for Camille Roche.')
expect(text).toContain(
'Shown as a square. Choose an image with the face or logo near the middle.',
)
picker.querySelector<HTMLElement>('[data-picker-tile]')!.click()
await flushPromises()
await click('Use image', picker)
expect(textOf(box())).toContain('face.jpg')
await submit('Save changes')
expect(callsTo(backend, 'PATCH', '/v1/author/7')[0]!.body).toEqual({ avatar_id: 55 })
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Authors form: save failed with %s', (_name, responder) => {
it('shows the notice and keeps the values', async () => {
const backend = createBackend()
backend.responders['POST /v1/author/'] = responder
await openAdd({ backend })
await typeInto(nameField(), 'Zoe')
await typeInto(bioField(), 'Keep me')
await submit()
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
expect(nameField().value).toBe('Zoe')
expect(bioField().value).toBe('Keep me')
expect(nameField().disabled).toBe(false)
})
})
describe('Authors form: role changed while open', () => {
it('raises an error toast on 403 author_rights', async () => {
const backend = createBackend()
backend.responders['POST /v1/author/'] = failWith(403, 'author_rights')
await openAdd({ backend })
await typeInto(nameField(), 'Zoe')
await submit()
expect(document.body.textContent).toContain('Only an owner or admin can change authors')
expect(document.body.textContent).toContain('You can still edit your own author profile.')
})
})
describe('Authors form: deep link', () => {
it('opens the edit slide-over for an owner or admin', async () => {
await mountDirectory('authors', '/authors?edit=camille-roche', { role: 'admin' })
expect(textOf(box())).toContain('Edit Camille Roche')
})
it('shows an info toast and opens nothing for an unknown slug', async () => {
await mountDirectory('authors', '/authors?edit=nobody-here')
expect(dialog()).toBeNull()
expect(document.body.textContent).toContain('That author no longer exists')
})
})
describe('Authors form: own author, any role, by deep link only', () => {
function memberBackend() {
const backend = createBackend()
backend.authors = authorsFixture(false)
return backend
}
const own = { id: 2, name: 'Inès Duarte', slug: 'ines-duarte' }
it('opens the form with the linked user locked, no Unlink, no Link user and no delete', async () => {
const backend = memberBackend()
await mountDirectory('authors', '/authors?edit=ines-duarte', {
backend,
role: 'member',
author: own,
email: 'ines.duarte@renew.example',
})
const text = textOf(box())
expect(text).toContain('Edit Inès Duarte')
expect(text).toContain('ines.duarte@renew.example')
expect(text).toContain(
'This is your author profile. Only an owner or admin can change the link.',
)
expect(button('Unlink', box())).toBeUndefined()
expect(button('Link user', box())).toBeUndefined()
expect(button(/Delete/, box())).toBeUndefined()
await typeInto(bioField(), 'Mine')
await submit('Save changes')
expect(callsTo(backend, 'PATCH', '/v1/author/2')[0]!.body).toEqual({ bio: 'Mine' })
})
it('opens the read-only detail for another author', async () => {
await mountDirectory('authors', '/authors?edit=camille-roche', {
backend: memberBackend(),
role: 'member',
author: own,
})
expect(textOf(box())).toContain('Only an owner or admin can change this author.')
expect(box().querySelectorAll('input, textarea')).toHaveLength(0)
})
})
void blur
+324
View File
@@ -0,0 +1,324 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import type { Author } from '@/composables/useAuthors'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
authorsFixture,
button,
callsTo,
click,
createBackend,
dialog,
failWith,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
rows,
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 SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120
const OWNER_DESCRIPTION =
"The names readers see on your articles. An author doesn't need an account."
const READ_ONLY_DESCRIPTION =
'The names readers see on your articles. Only an owner or admin can change authors.'
afterEach(unmountDirectory)
function listCalls(backend: ReturnType<typeof createBackend>) {
return backend.calls.filter((call) => call.method === 'GET' && call.path === '/v1/author/')
}
function manyAuthors(count: number): Author[] {
return Array.from({ length: count }, (_, index) => ({
...authorsFixture()[1]!,
id: 500 + index,
name: `Writer ${String(index).padStart(2, '0')}`,
slug: `writer-${index}`,
}))
}
describe.each(['owner', 'admin'])('Authors: default for %s', (role) => {
it('shows the header, the search field, the columns and the rows', async () => {
const backend = await mountDirectory('authors', '/authors', { role })
expect(document.body.querySelector('h1')?.textContent).toBe('Authors')
expect(document.body.textContent).toContain(OWNER_DESCRIPTION)
expect(button('Add author')).toBeTruthy()
expect(
document.body.querySelector('input[placeholder="Search by name or slug"]'),
).toBeTruthy()
const header = document.body.querySelector(
'[data-testid="directory-row"]',
)!.previousElementSibling!
expect(textOf(header)).toBe('Author Slug Account Articles')
expect(rowTexts()).toEqual([
'ID Inès Duarte ines-duarte Linked 9 articles',
'CR Camille Roche camille-roche Guest 3 articles',
'AK Dr. Alexandra Konstantinopoulou-Whitfield dr-alexandra-konstantinopoulou-whitfield Guest 1 article',
'LF Léa Fontaine lea-fontaine Guest No articles',
])
expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?page=1&size=25')
})
it('puts Authors between Media and Users in the sidebar, current on /authors', async () => {
await mountDirectory('authors', '/authors', { role })
const links = Array.from(
document.body.querySelectorAll<HTMLAnchorElement>('nav[aria-label="Main"] a'),
)
const hrefs = links.map((link) => link.getAttribute('href'))
expect(hrefs.slice(2, 5)).toEqual(['/media', '/authors', '/users'])
expect(links[3]!.getAttribute('aria-current')).toBe('page')
})
it('shows a long name in full on hover', async () => {
await mountDirectory('authors', '/authors', { role })
const name = Array.from(document.body.querySelectorAll('span.truncate')).find((span) =>
textOf(span).startsWith('Dr. Alexandra'),
)!
expect(name.getAttribute('title')).toBe('Dr. Alexandra Konstantinopoulou-Whitfield')
})
})
describe('Authors: loading', () => {
it('draws skeleton rows after the delay and marks the list busy', async () => {
vi.useFakeTimers()
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['GET /v1/author/'] = () => hold.promise
await mountDirectory('authors', '/authors', { backend })
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
).toHaveLength(6)
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Authors: error on load with %s', (_name, responder) => {
it('shows the alert and requests the list again on Try again', async () => {
const backend = createBackend()
backend.responders['GET /v1/author/'] = responder
await mountDirectory('authors', '/authors', { backend })
const alert = textOf(document.body.querySelector('[role="alert"]'))
expect(alert).toContain("Couldn't load authors")
expect(alert).toContain('Check your connection and try again.')
delete backend.responders['GET /v1/author/']
await click('Try again')
expect(listCalls(backend)).toHaveLength(2)
expect(rows()).toHaveLength(4)
})
})
describe.each([
['owner', true],
['admin', true],
['member', false],
])('Authors: empty, first run for %s', (role, canAdd) => {
it('hides the toolbar and shows the empty state', async () => {
const backend = createBackend()
backend.authors = []
await mountDirectory('authors', '/authors', { backend, role })
expect(
document.body.querySelector('input[placeholder="Search by name or slug"]'),
).toBeNull()
expect(document.body.textContent).toContain('No authors yet')
expect(document.body.textContent).toContain(
"Authors are the names readers see on articles. They don't need an account.",
)
const emptyButton = Array.from(document.body.querySelectorAll('button')).filter(
(element) => textOf(element) === 'Add author',
)
expect(emptyButton.length > 0).toBe(canAdd)
})
})
describe('Authors: no results', () => {
it('names the search and clears it from the URL', async () => {
const backend = await mountDirectory('authors', '/authors?q=zoe')
expect(document.body.textContent).toContain('No authors match "zoe"')
expect(document.body.textContent).toContain(
'Check the spelling, or search for a shorter part of the name.',
)
await click('Clear search')
await flushPromises()
expect(router.currentRoute.value.query.q).toBeUndefined()
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?page=1&size=25')
})
})
describe('Authors: search and pagination in the URL', () => {
it('debounces the search, requests it and keeps it in the URL', async () => {
vi.useFakeTimers()
const backend = await mountDirectory('authors', '/authors')
const input = document.body.querySelector<HTMLInputElement>(
'input[placeholder="Search by name or slug"]',
)!
await typeInto(input, 'cam')
expect(listCalls(backend)).toHaveLength(1)
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS)
await flushPromises()
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?q=cam&page=1&size=25')
expect(router.currentRoute.value.query.q).toBe('cam')
})
it('requests the page and size found in the URL', async () => {
const backend = createBackend()
backend.authors = manyAuthors(130)
await mountDirectory('authors', '/authors?q=writer&page=2&size=50', { backend })
expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?q=writer&page=2&size=50')
expect(document.body.textContent).toContain('Showing 51–100 of 130')
})
it('shows the paginator only when the total exceeds 25', async () => {
await mountDirectory('authors', '/authors')
expect(document.body.textContent).not.toContain('per page')
await unmountDirectory()
const backend = createBackend()
backend.authors = manyAuthors(40)
await mountDirectory('authors', '/authors', { backend })
expect(document.body.textContent).toContain('Showing 1–25 of 40')
})
})
describe.each([['size=30'], ['page=abc'], ['page=0']])(
'Authors: invalid value in the URL (%s)',
(query) => {
it('replaces the value by its default with no notice and never reaches the API', async () => {
const backend = await mountDirectory('authors', `/authors?${query}`)
await flushPromises()
expect(router.currentRoute.value.query.size).toBeUndefined()
expect(router.currentRoute.value.query.page).toBeUndefined()
expect(listCalls(backend).map((call) => call.raw)).toEqual([
'/v1/author/?page=1&size=25',
])
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
},
)
describe('Authors: row menu for an owner or admin', () => {
it('offers Edit, Link user and Delete… for a guest and Edit and Delete… for a linked author', async () => {
await mountDirectory('authors', '/authors')
await openRowMenu('Camille Roche')
expect(Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)).toEqual(
['Edit', 'Link user', 'Delete…'],
)
})
it('lists Edit and Delete… only for a linked author', async () => {
await mountDirectory('authors', '/authors')
await openRowMenu('Inès Duarte')
expect(Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)).toEqual(
['Edit', 'Delete…'],
)
})
it('opens a bottom sheet below md', async () => {
await mountDirectory('authors', '/authors', { width: MOBILE_WIDTH })
await openRowMenu('Camille Roche')
expect(textOf(dialog()!.querySelector('h2'))).toBe('Camille Roche')
expect(dialog()!.querySelectorAll('[role="menuitem"]')).toHaveLength(3)
})
})
describe('Authors: read-only list for other roles', () => {
it('has no Add author button, no pinned button, no row menu and a chevron on each row', async () => {
await mountDirectory('authors', '/authors', { role: 'member', width: MOBILE_WIDTH })
expect(document.body.textContent).toContain(READ_ONLY_DESCRIPTION)
expect(button('Add author')).toBeUndefined()
expect(
Array.from(document.body.querySelectorAll('button[aria-label^="Actions for"]')),
).toHaveLength(0)
expect(document.body.querySelectorAll('button[aria-label^="View "]')).toHaveLength(4)
})
it('keeps search working and shows the Authors sidebar entry on desktop', async () => {
vi.useFakeTimers()
const backend = await mountDirectory('authors', '/authors', { role: 'member' })
expect(
document.body.querySelector('nav[aria-label="Main"] a[href="/authors"]'),
).not.toBeNull()
const input = document.body.querySelector<HTMLInputElement>(
'input[placeholder="Search by name or slug"]',
)!
await typeInto(input, 'lea')
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS)
await flushPromises()
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?q=lea&page=1&size=25')
})
})
describe('Authors: read-only detail', () => {
async function openDetail() {
await mountDirectory('authors', '/authors', { role: 'member' })
await click('View Inès Duarte')
}
it('shows the author with Close as the only action and no email', async () => {
await openDetail()
const text = textOf(dialog())
for (const copy of [
'Inès Duarte',
'Linked',
'9 articles',
'Bio',
'Website',
'Only an owner or admin can change this author.',
]) {
expect(text).toContain(copy)
}
expect(text).not.toContain('ines.duarte@renew.example')
expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0)
const actions = Array.from(dialog()!.querySelectorAll('button')).map(textOf)
expect(actions.filter((label) => label !== '')).toEqual(['Close'])
})
it('opens the same panel from /authors?edit=ines-duarte', async () => {
await mountDirectory('authors', '/authors?edit=ines-duarte', { role: 'member' })
expect(textOf(dialog())).toContain('Only an owner or admin can change this author.')
expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0)
})
})
void callsTo
@@ -0,0 +1,170 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { initializeLocale, setLocale } from '@/i18n'
import { ApiError } from '@/composables/useApi'
import type { Author } from '@/composables/useAuthors'
import {
authorsFixture,
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
field,
labelled,
mountDirectory,
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() }
})
afterEach(unmountDirectory)
function box() {
return dialog()!
}
function manyAuthors(count: number): Author[] {
return Array.from({ length: count }, (_, index) => ({
...authorsFixture()[1]!,
id: 500 + index,
name: `Writer ${String(index).padStart(3, '0')}`,
slug: `writer-${index}`,
article_count: index === 0 ? 3 : 1,
}))
}
describe('Author slug suggestions are never taken', () => {
it('skips slugs that are already on the loaded page and ones the server just refused', async () => {
const backend = createBackend()
backend.authors.push({
...authorsFixture()[1]!,
id: 90,
name: 'Other',
slug: 'camille-roche-2',
})
backend.responders['POST /v1/author/'] = failWith(409, 'author_slug_taken')
await mountDirectory('authors', '/authors', { backend })
await click('Add author')
await typeInto(field('e.g. Camille Roche'), 'Camille Roche')
await click('Add author', box())
expect(textOf(box())).toContain('Use camille-roche-3')
await click('Use camille-roche-3', box())
await click('Add author', box())
expect(textOf(box())).toContain('Use camille-roche-4')
expect((labelled('Slug') as HTMLInputElement).value).toBe('camille-roche-3')
})
})
describe('Author reassign options', () => {
it('walks every page of authors so nobody past the first hundred is missing', async () => {
const backend = createBackend()
backend.authors = manyAuthors(130)
await mountDirectory('authors', '/authors', { backend })
await openRowMenu('Writer 000')
await chooseMenuItem('Delete…')
const paths = backend.calls
.filter((call) => call.path === '/v1/author/')
.map((call) => call.raw)
expect(paths).toContain('/v1/author/?page=1&size=100')
expect(paths).toContain('/v1/author/?page=2&size=100')
const trigger = box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
trigger.click()
await flushPromises()
const labels = Array.from(box().querySelectorAll('[role="option"]')).map(textOf)
expect(labels.some((label) => label.includes('Writer 129'))).toBe(true)
expect(labels.some((label) => label.includes('Writer 000'))).toBe(false)
})
it('says so when the options cannot be loaded and still offers Nobody', async () => {
const backend = createBackend()
await mountDirectory('authors', '/authors', { backend })
backend.responders['GET /v1/author/?page=1&size=100'] = failWith(500)
await openRowMenu('Camille Roche')
await chooseMenuItem('Delete…')
const alert = textOf(box().querySelector('[role="alert"]'))
expect(alert).toContain("Couldn't load authors")
expect(alert).toContain('Check your connection and try again.')
expect(textOf(box().querySelector('button[aria-haspopup="listbox"]'))).toBe('Nobody')
})
})
describe('Making another site the default from the form', () => {
async function openProjects(backend = createBackend()) {
await mountDirectory('sites', '/settings/sites', { backend })
await openRowMenu('Projects')
await chooseMenuItem('Edit')
return backend
}
it('keeps the list truthful when only the default step fails and retries just that step', async () => {
const backend = createBackend()
let defaultCalls = 0
backend.responders['PATCH /v1/site/3/default'] = () => {
defaultCalls += 1
if (defaultCalls === 1) {
throw new ApiError(500)
}
backend.sites.forEach((site) => (site.is_default = site.id === 3))
return backend.sites.find((site) => site.id === 3)
}
await openProjects(backend)
await typeInto(field('e.g. Journal'), 'Projects 2')
box().querySelector<HTMLElement>('[role="switch"]')!.click()
await flushPromises()
await click('Save changes', box())
expect(textOf(box().querySelector('[role="alert"]'))).toContain("Couldn't save the site")
expect(
textOf(document.body.querySelectorAll('[data-testid="directory-row"]')[2]),
).toContain('Projects 2')
await click('Save changes', box())
expect(callsTo(backend, 'PATCH', '/v1/site/3')).toHaveLength(1)
expect(callsTo(backend, 'PATCH', '/v1/site/3/default')).toHaveLength(2)
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
})
})
describe('Focus returns to the row menu button', () => {
it('puts focus back on the ellipsis button after closing a panel opened from the menu', async () => {
await mountDirectory('sites', '/settings/sites')
await openRowMenu('Projects')
await chooseMenuItem('Edit')
await click('Cancel', box())
await flushPromises()
expect(document.activeElement).toBe(button('Actions for Projects'))
})
})
describe('Failure copy passes through the dialog in the active language', () => {
beforeEach(() => setLocale('fr'))
afterEach(() => initializeLocale())
it('shows the French notice when setting the default fails', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/2/default'] = failWith(500)
await mountDirectory('sites', '/settings/sites', { backend })
await click('Actions pour Journal')
await chooseMenuItem('Définir par défaut')
await click('Définir par défaut', box())
expect(textOf(box().querySelector('[role="alert"]'))).toContain(
'Impossible d’enregistrer le site.',
)
})
})
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { initializeLocale, setLocale } from '@/i18n'
import { messages } from '@/i18n/messages'
import { chooseMenuItem, click, mountDirectory, textOf, unmountDirectory } from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const MIN_LENGTH = 14
const translatable = Object.entries(messages).filter(
([english, french]) =>
english !== french && !english.includes('{') && english.length >= MIN_LENGTH,
)
function leftovers() {
const shown = textOf(document.body)
return translatable.filter(([english]) => shown.includes(english)).map(([english]) => english)
}
beforeEach(() => {
localStorage.clear()
setLocale('fr')
})
afterEach(async () => {
await unmountDirectory()
localStorage.clear()
initializeLocale()
})
describe('Language: every string of the sites screen has a French entry', () => {
it('translates the list, the row menu and the add form', async () => {
await mountDirectory('sites', '/settings/sites')
expect(document.body.textContent).toContain('Les frontends qui affichent votre contenu.')
expect(leftovers()).toEqual([])
await click('Actions pour Journal')
expect(document.body.textContent).toContain('Définir par défaut')
expect(leftovers()).toEqual([])
await chooseMenuItem('Définir par défaut')
expect(leftovers()).toEqual([])
})
it('translates the add form and the delete dialog', async () => {
await mountDirectory('sites', '/settings/sites')
await click('Ajouter un site')
expect(leftovers()).toEqual([])
})
it('translates the delete dialog', async () => {
await mountDirectory('sites', '/settings/sites')
await click('Actions pour Journal')
await chooseMenuItem('Supprimer…')
expect(document.body.textContent).toContain('Supprimer ce site ?')
expect(leftovers()).toEqual([])
})
it('translates the 403 page', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
expect(document.body.textContent).toContain('Vous n’avez pas accès aux sites')
expect(document.body.textContent).toContain('Retour aux paramètres')
expect(leftovers()).toEqual([])
})
})
describe('Language: every string of the authors screen has a French entry', () => {
it('translates the list, the add form and the delete dialog', async () => {
await mountDirectory('authors', '/authors')
expect(document.body.textContent).toContain('Auteurs')
expect(
document.body.querySelector('input[placeholder="Rechercher par nom ou identifiant"]'),
).not.toBeNull()
expect(leftovers()).toEqual([])
await click('Ajouter un auteur')
await flushPromises()
expect(document.body.textContent).toContain('Aperçu de la signature')
expect(leftovers()).toEqual([])
})
it('translates the delete dialog', async () => {
await mountDirectory('authors', '/authors')
await click('Actions pour Camille Roche')
await chooseMenuItem('Supprimer…')
expect(document.body.textContent).toContain('Supprimer cet auteur ?')
expect(leftovers()).toEqual([])
})
it('translates the read-only list', async () => {
await mountDirectory('authors', '/authors', { role: 'member' })
expect(document.body.textContent).toContain('Seul un propriétaire ou un administrateur')
expect(leftovers()).toEqual([])
})
})
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import AuthorsView from '../AuthorsView.vue'
import SitesView from '../SitesView.vue'
let wrapper: VueWrapper | null = null
function reply(status: number, body = '') {
return new Response(body, { status, headers: { 'Content-Type': 'text/plain' } })
}
async function mountWith(
view: typeof AuthorsView | typeof SitesView,
path: string,
verified: boolean,
) {
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified, username: 'marta', id: 1, displayName: '', role: 'owner' }
session.status = 'ready'
await router.push(path)
wrapper = mount(defineComponent({ render: () => h(UApp, null, { default: () => h(view) }) }), {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.unstubAllGlobals()
await router.push('/login')
document.body.innerHTML = ''
})
describe('Session problems use the app-wide handling', () => {
it.each([
['/authors', AuthorsView, '/v1/author/'],
['/settings/sites', SitesView, '/v1/site/'],
])('sends a 401 on %s to the login page with the expired reason', async (path, view, api) => {
const calls: string[] = []
vi.stubGlobal(
'fetch',
vi.fn(async (input: string) => {
calls.push(input)
return reply(401)
}),
)
await mountWith(view, path, true)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
expect(calls.some((call) => call.startsWith(api))).toBe(true)
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query).toEqual({ redirect: path, reason: 'expired' })
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
it('sends an unverified 403 on /authors to /register/pending', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => reply(403)),
)
await mountWith(AuthorsView, '/authors', false)
await flushPromises()
expect(router.currentRoute.value.path).toBe('/register/pending')
})
})
@@ -1505,6 +1505,7 @@ describe('Sidebar', () => {
'/home',
'/article',
'/media',
'/authors',
'/users',
'/activity',
'/integration',
+2 -2
View File
@@ -279,11 +279,11 @@ describe('Default: the usual first visit with no display name', () => {
expect(request).not.toHaveBeenCalled()
})
it('marks none of the seven navigation items as the current page', async () => {
it('marks none of the eight navigation items as the current page', async () => {
await mountApp()
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(7)
expect(links).toHaveLength(8)
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
+34 -2
View File
@@ -30,10 +30,10 @@ function deferred<T>() {
return { promise, resolve }
}
async function mountPage(settings: () => Promise<unknown>) {
async function mountPage(settings: () => Promise<unknown>, role?: 'owner' | 'admin') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1 }
session.session = { verified: true, username: 'marta', id: 1, ...(role ? { role } : {}) }
session.status = 'ready'
requestMock = vi.fn((path: string) => {
if (path === SETTINGS_PATH) {
@@ -185,3 +185,35 @@ describe('A 401 or an unverified 403 is handled globally, not by the page', () =
expect(tryAgainButton()).toBeUndefined()
})
})
describe('Publishing card: the owner manages sites from Settings', () => {
it('shows a Publishing card with a Manage sites row linking to /settings/sites', async () => {
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'owner')
const card = document.body.querySelector('[data-testid="publishing-links-card"]')!
expect(card.textContent).toContain('Publishing')
expect(card.textContent).toContain('Where your content is shown.')
const row = card.querySelector('a')!
expect(row.getAttribute('href')).toBe('/settings/sites')
expect(row.textContent).toContain('Manage sites')
expect(row.textContent).toContain('Base URLs, default image and robots policy')
})
it('shows the sidebar Publishing group with a Sites entry linking to /settings/sites', async () => {
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'owner')
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(nav.textContent).toContain('Publishing')
expect(nav.querySelector('a[href="/settings/sites"]')?.textContent).toContain('Sites')
})
it('hides the Publishing card, the Manage sites row and the Sites entry from an admin', async () => {
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'admin')
expect(document.body.querySelector('[data-testid="publishing-links-card"]')).toBeNull()
expect(document.body.textContent).not.toContain('Manage sites')
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(nav.textContent).not.toContain('Publishing')
expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull()
})
})
+173
View File
@@ -0,0 +1,173 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import {
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
textOf,
unmountDirectory,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
afterEach(unmountDirectory)
async function openDelete(name: string, backend = createBackend()) {
await mountDirectory('sites', '/settings/sites', { backend })
await openRowMenu(name)
await chooseMenuItem('Delete…')
return backend
}
function box() {
return dialog()!
}
function trigger() {
return box().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!
}
describe('Sites delete: site with articles', () => {
it('explains, defaults the destination to the default site and shows the move summary', async () => {
await openDelete('Journal')
const text = textOf(box())
expect(box().getAttribute('role')).toBe('alertdialog')
expect(text).toContain('Delete this site?')
expect(text).toContain(
'"Journal" will be deleted. 38 articles belong to it, so choose a site to move them to. This can\'t be undone.',
)
expect(text).toContain('Move articles to')
expect(textOf(trigger())).toBe('Renew · renew.example')
expect(text).toContain(
'38 articles will move to Renew. Their canonical host changes to renew.example.',
)
expect(button('Cancel', box())).toBeTruthy()
expect(button('Move 38 articles and delete', box())).toBeTruthy()
})
})
describe('Sites delete: choose destination', () => {
it('does not offer the deleted site and shows the name and the host on each option', async () => {
await openDelete('Journal')
trigger().click()
await flushPromises()
const options = Array.from(box().querySelectorAll('[role="option"]')).map((option) =>
textOf(option),
)
expect(options).toEqual([
'Renew renew.example',
'Projects projects.example',
'Archive archive.example',
])
})
})
describe('Sites delete: with articles succeeds', () => {
it('sends one DELETE with reassign_to, reads Moving articles… and toasts', async () => {
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['DELETE /v1/site/2'] = () => hold.promise
await openDelete('Journal', backend)
await click('Move 38 articles and delete', box())
expect(button('Moving articles…', box())).toBeTruthy()
hold.resolve(undefined)
await flushPromises()
const deletes = callsTo(backend, 'DELETE', '/v1/site/2')
expect(deletes).toHaveLength(1)
expect(deletes[0]!.query.get('reassign_to')).toBe('1')
expect(dialog()).toBeNull()
expect(rowTexts().join(' ')).not.toContain('Journal')
expect(document.body.textContent).toContain('Site deleted')
expect(document.body.textContent).toContain('38 articles moved to Renew.')
})
})
describe('Sites delete: no articles', () => {
it('asks a plain confirmation with no select and sends no reassign_to', async () => {
const backend = await openDelete('Archive')
const text = textOf(box())
expect(text).toContain('Delete this site?')
expect(text).toContain(
'"Archive" will be deleted. No articles belong to it. This can\'t be undone.',
)
expect(trigger()).toBeNull()
expect(button('Delete site', box())).toBeTruthy()
await click('Delete site', box())
const deletes = callsTo(backend, 'DELETE', '/v1/site/4')
expect(deletes).toHaveLength(1)
expect(deletes[0]!.query.has('reassign_to')).toBe(false)
expect(rowTexts().join(' ')).not.toContain('Archive')
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Sites delete: failure with %s', (_name, responder) => {
it('keeps the dialog open with the notice and enables the buttons again', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/site/2'] = responder
await openDelete('Journal', backend)
await click('Move 38 articles and delete', box())
const alert = textOf(box().querySelector('[role="alert"]'))
expect(alert).toContain("Couldn't delete the site")
expect(alert).toContain('Nothing was changed. Try again in a moment.')
expect(button('Cancel', box())!.hasAttribute('disabled')).toBe(false)
expect(button('Move 38 articles and delete', box())!.hasAttribute('disabled')).toBe(false)
})
})
describe('Sites delete: server refusals', () => {
it('keeps the dialog open and asks for a destination on 409 site_has_articles', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/site/2'] = failWith(409, 'site_has_articles')
await openDelete('Journal', backend)
await click('Move 38 articles and delete', box())
expect(dialog()).not.toBeNull()
expect(textOf(box())).toContain('Choose where the articles go. This site has 38 articles.')
})
it('closes and toasts on 403 default_site', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/site/2'] = failWith(403, 'default_site')
await openDelete('Journal', backend)
await click('Move 38 articles and delete', box())
expect(dialog()).toBeNull()
expect(document.body.textContent).toContain(
"The default site can't be deleted. Make another site the default first.",
)
})
it('closes, removes the row and toasts on 404', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/site/2'] = failWith(404, 'site_not_found')
await openDelete('Journal', backend)
await click('Move 38 articles and delete', box())
expect(dialog()).toBeNull()
expect(rowTexts().join(' ')).not.toContain('Journal')
expect(document.body.textContent).toContain('That site no longer exists')
})
})
+417
View File
@@ -0,0 +1,417 @@
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,
labelled,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
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 BASE_URL_MESSAGES = {
scheme: 'Use https. For example https://blog.example.com.',
path: 'Remove the path. Use only the host, like https://blog.example.com.',
invalid: 'Enter a full URL, like https://blog.example.com.',
}
const SAVE_FAILED = "Couldn't save the site. Your changes are still here. Try again in a moment."
afterEach(unmountDirectory)
async function openAdd(options: Parameters<typeof mountDirectory>[2] = {}) {
const backend = await mountDirectory('sites', '/settings/sites', options)
await click('Add site')
return backend
}
async function openEdit(name: string) {
const backend = await mountDirectory('sites', '/settings/sites')
await openRowMenu(name)
await chooseMenuItem('Edit')
return backend
}
async function fillValid(name = 'Journal 2', url = 'https://journal2.example') {
await typeInto(field('e.g. Journal'), name)
await typeInto(field('https://blog.example.com'), url)
}
function box() {
return dialog()!
}
function submit(label = 'Add site') {
return click(label, box())
}
describe('Sites form: add, empty', () => {
it('opens a slide-over with every field, placeholder and help', async () => {
await openAdd()
const text = textOf(box())
for (const copy of [
'Add site',
'Name',
'Key',
'Base URL',
'The public address of the frontend. Use https and no path.',
'Default social image (optional)',
'Choose image',
'Used when an article has no image of its own.',
'Default robots policy',
'Index',
'Noindex',
"Search engines may list this site's pages.",
'Locale (optional)',
'A language tag. Leave empty if the site has no single language.',
'Make this the default site',
'New articles start here. The current default is Renew.',
'Canonical URL preview',
'Cancel',
]) {
expect(text).toContain(copy)
}
expect(field('e.g. Journal')).toBeTruthy()
expect(field('Generated from the name')).toBeTruthy()
expect(field('https://blog.example.com')).toBeTruthy()
expect(field('e.g. en or fr-CH')).toBeTruthy()
expect(button('Add site', box())).toBeTruthy()
})
it('focuses Name', async () => {
await openAdd()
await flushPromises()
expect(document.activeElement).toBe(field('e.g. Journal'))
})
})
describe('Sites form: key from the name', () => {
it('fills the key with an Auto badge and unlocks it with the pencil', async () => {
await openAdd()
await typeInto(field('e.g. Journal'), 'Journal Online')
const key = labelled('Key') as HTMLInputElement
expect(key.value).toBe('journal-online')
expect(textOf(box())).toContain('Auto')
expect(textOf(box())).toContain(
"Lowercase letters, numbers and hyphens. Used in API paths. It can't be changed after the site is added.",
)
await click('Edit key', box())
await typeInto(labelled('Key') as HTMLInputElement, 'custom-key')
await typeInto(field('e.g. Journal'), 'Something else')
expect((labelled('Key') as HTMLInputElement).value).toBe('custom-key')
expect(textOf(box())).not.toContain('Auto')
})
})
describe.each([
['http://blog.example.com', BASE_URL_MESSAGES.scheme],
['https://blog.example.com/en', BASE_URL_MESSAGES.path],
['blog', BASE_URL_MESSAGES.invalid],
])('Sites form: base URL "%s" is not valid', (value, message) => {
it('shows the message on leaving the field and sends nothing', async () => {
const backend = await openAdd()
await fillValid('Journal 2', value)
await blur(field('https://blog.example.com'))
expect(textOf(box())).toContain(message)
await submit()
expect(callsTo(backend, 'POST', '/v1/site/')).toHaveLength(0)
expect(textOf(box())).toContain(message)
})
})
describe('Sites form: base URL refused by the server', () => {
it('shows the generic URL message on 400 invalid_site_url', async () => {
const backend = createBackend()
backend.responders['POST /v1/site/'] = failWith(400, 'invalid_site_url')
await openAdd({ backend })
await fillValid()
await submit()
expect(textOf(box())).toContain(BASE_URL_MESSAGES.invalid)
})
})
describe.each(['http://localhost:5173', 'http://127.0.0.1:8081', 'http://[::1]:5173'])(
'Sites form: loopback host %s accepts http',
(value) => {
it('shows no base URL error', async () => {
await openAdd()
await fillValid('Journal 2', value)
await blur(field('https://blog.example.com'))
const text = textOf(box())
expect(text).not.toContain(BASE_URL_MESSAGES.scheme)
expect(text).not.toContain(BASE_URL_MESSAGES.invalid)
expect(text).not.toContain(BASE_URL_MESSAGES.path)
})
},
)
describe('Sites form: add succeeds', () => {
it('sends exactly one POST, shows Saving… with disabled fields, then closes and lists the site', async () => {
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['POST /v1/site/'] = () => hold.promise
await openAdd({ backend })
await typeInto(field('e.g. Journal'), 'Notes')
await typeInto(field('https://blog.example.com'), 'https://notes.example')
box().querySelector<HTMLElement>('[role="radio"][value="noindex"]')!.click()
await flushPromises()
await submit()
expect(button('Saving…', box())).toBeTruthy()
expect(field('e.g. Journal').disabled).toBe(true)
hold.resolve({
id: 5,
name: 'Notes',
key: 'notes',
base_url: 'https://notes.example',
default_social_image_id: null,
default_robots: 'noindex',
locale: null,
is_default: false,
article_count: 0,
})
await flushPromises()
const posts = callsTo(backend, 'POST', '/v1/site/')
expect(posts).toHaveLength(1)
expect(posts[0]!.body).toEqual({
name: 'Notes',
key: 'notes',
base_url: 'https://notes.example',
default_robots: 'noindex',
is_default: false,
})
expect(dialog()).toBeNull()
expect(rowTexts().at(-1)).toContain('Notes')
})
})
describe('Sites form: duplicate key', () => {
it('flags the key on leaving the field and applies the suggestion', async () => {
await openAdd()
await typeInto(field('e.g. Journal'), 'Journal')
await click('Edit key', box())
const key = labelled('Key') as HTMLInputElement
await blur(key)
expect(textOf(box())).toContain(
'A site with the key "journal" already exists. Choose another key.',
)
await click('Use journal-2', box())
expect((labelled('Key') as HTMLInputElement).value).toBe('journal-2')
expect(textOf(box())).not.toContain('already exists')
})
it('flags the key when the server answers 409 site_key_taken', async () => {
const backend = createBackend()
backend.sites = backend.sites.filter((site) => site.key !== 'journal')
backend.responders['POST /v1/site/'] = failWith(409, 'site_key_taken')
await openAdd({ backend })
await fillValid('Journal', 'https://journal.example')
await submit()
expect(textOf(box())).toContain(
'A site with the key "journal" already exists. Choose another key.',
)
expect(textOf(box())).toContain('Use journal-2')
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Sites form: save failed with %s', (_name, responder) => {
it('shows the notice, keeps the values and enables the form again', async () => {
const backend = createBackend()
backend.responders['POST /v1/site/'] = responder
await openAdd({ backend })
await fillValid()
await submit()
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
expect(field('e.g. Journal').value).toBe('Journal 2')
expect(field('https://blog.example.com').value).toBe('https://journal2.example')
expect(field('e.g. Journal').disabled).toBe(false)
expect(button('Add site', box())).toBeTruthy()
})
})
describe('Sites form: invalid locale from the server', () => {
it('shows the locale message and keeps the panel open', async () => {
const backend = createBackend()
backend.responders['POST /v1/site/'] = failWith(400, 'invalid_locale')
await openAdd({ backend })
await fillValid()
await typeInto(field('e.g. en or fr-CH'), 'english')
await submit()
expect(textOf(box())).toContain('Use a language tag like en or fr-CH.')
expect(dialog()).not.toBeNull()
})
})
describe('Sites form: edit the default site', () => {
it('fills the fields, locks the key and keeps the default switch on and locked', async () => {
await openEdit('Renew')
const text = textOf(box())
expect(text).toContain('Edit Renew')
expect(field('e.g. Journal').value).toBe('Renew')
const key = labelled('Key') as HTMLInputElement
expect(key.value).toBe('renew')
expect(key.readOnly || key.disabled).toBe(true)
expect(box().querySelector('[data-testid="slug-lock"]')).not.toBeNull()
expect(text).toContain("Used in API paths, so it can't change.")
expect(text).toContain('Default site')
expect(text).toContain(
'New articles start on the default site. To change it, make another site the default.',
)
const toggle = box().querySelector<HTMLButtonElement>('[role="switch"]')!
expect(toggle.getAttribute('aria-checked')).toBe('true')
expect(toggle.disabled || toggle.getAttribute('data-disabled') !== null).toBe(true)
expect(button('Save changes', box())).toBeTruthy()
})
})
describe('Sites form: edit another site, noindex', () => {
it('shows Noindex selected with its help and sends a PATCH without the key', async () => {
const backend = await openEdit('Projects')
const text = textOf(box())
expect(text).toContain('Edit Projects')
expect(text).toContain("Asks search engines not to list this site's pages.")
expect(text).toContain('Make this the default site')
const noindex = box().querySelector('[role="radio"][value="noindex"]')!
expect(noindex.getAttribute('aria-checked')).toBe('true')
await typeInto(field('e.g. Journal'), 'Projects 2')
await submit('Save changes')
const patches = callsTo(backend, 'PATCH', '/v1/site/3')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ name: 'Projects 2' })
expect(patches[0]!.body).not.toHaveProperty('key')
})
})
describe('Sites form: make another site the default from the form', () => {
it('sends the changed fields, then the default route, and never is_default in the first PATCH', async () => {
const backend = await openEdit('Projects')
await typeInto(field('e.g. Journal'), 'Projects 2')
box().querySelector<HTMLElement>('[role="switch"]')!.click()
await flushPromises()
await submit('Save changes')
const order = backend.calls
.filter((call) => call.method === 'PATCH')
.map((call) => call.path)
expect(order).toEqual(['/v1/site/3', '/v1/site/3/default'])
expect(backend.calls.find((call) => call.path === '/v1/site/3')!.body).not.toHaveProperty(
'is_default',
)
expect(
textOf(document.body.querySelectorAll('[data-testid="directory-row"]')[2]),
).toContain('Default')
})
})
describe('Sites form: discard changes', () => {
it('asks before closing a changed form with Escape and keeps it on Keep editing', async () => {
await openAdd()
await typeInto(field('e.g. Journal'), 'Notes')
box().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
const ask = document.body.querySelector('[role="alertdialog"]')!
expect(textOf(ask)).toContain('Discard changes?')
await click('Keep editing', ask)
expect(field('e.g. Journal').value).toBe('Notes')
})
it('discards on confirmation and closes an unchanged form with Cancel straight away', async () => {
await openAdd()
await typeInto(field('e.g. Journal'), 'Notes')
await click('Cancel', box())
await click('Discard changes', document.body.querySelector('[role="alertdialog"]')!)
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
await click('Add site')
await click('Cancel', box())
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
})
})
describe('Sites form: deep link', () => {
it('opens the edit slide-over for a known key', async () => {
await mountDirectory('sites', '/settings/sites?edit=projects')
expect(textOf(box())).toContain('Edit Projects')
})
it('shows an info toast and opens nothing for an unknown key', async () => {
await mountDirectory('sites', '/settings/sites?edit=nope')
expect(dialog()).toBeNull()
expect(document.body.textContent).toContain('That site no longer exists')
})
})
describe('Slide-over accessibility', () => {
it('moves focus into the panel and returns it to the opening control on close', async () => {
await mountDirectory('sites', '/settings/sites')
const opener = button('Add site')!
opener.focus()
opener.click()
await flushPromises()
await flushPromises()
expect(box().contains(document.activeElement)).toBe(true)
await click('Cancel', box())
await flushPromises()
expect(document.activeElement).toBe(opener)
})
it('ignores Escape while a request is running', async () => {
const backend = createBackend()
backend.responders['POST /v1/site/'] = () => pending<unknown>().promise
await openAdd({ backend })
await fillValid()
await submit()
box().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
expect(dialog()).not.toBeNull()
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
})
})
void useApi
+323
View File
@@ -0,0 +1,323 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import { DESKTOP_WIDTH, MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
rows,
textOf,
unmountDirectory,
button,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const SKELETON_DELAY_MS = 150
const DEFAULT_SITE_TIP = "The default site can't be deleted. Make another site the default first."
afterEach(unmountDirectory)
describe('Sites: default (owner)', () => {
it('shows the header, the columns, the rows, the counts and the badges', async () => {
await mountDirectory('sites', '/settings/sites')
expect(document.body.querySelector('h1')?.textContent).toBe('Sites')
expect(document.body.textContent).toContain('The frontends that display your content.')
expect(button('Add site')).toBeTruthy()
const header = document.body.querySelector(
'[data-testid="directory-row"]',
)!.previousElementSibling!
expect(textOf(header)).toBe('Site Base URL Articles')
expect(rowTexts()).toEqual([
'Renew Default renew https://renew.example 268 articles',
'Journal journal https://journal.example 38 articles',
'Projects Noindex projects https://projects.example 6 articles',
'Archive archive https://archive.example No articles',
])
})
it('draws cards below md with a full-width Add site button pinned to the bottom', async () => {
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
expect(document.body.querySelectorAll('[data-testid="directory-card"]')).toHaveLength(4)
expect(document.body.querySelector('[data-testid="directory-row"]')).toBeNull()
const pinned = button('Add site')!
expect(pinned.className).toContain('fixed')
expect(pinned.className).toContain('inset-x-4')
})
})
describe('Sites: only the default site', () => {
it('lists the row with the hint and draws no empty-state wall', async () => {
const backend = createBackend()
backend.sites = backend.sites.slice(0, 1)
await mountDirectory('sites', '/settings/sites', { backend })
expect(rows()).toHaveLength(1)
expect(document.body.textContent).toContain(
'Add another site if your content is shown on more than one frontend. Each article belongs to one site.',
)
expect(document.body.textContent).not.toContain('No sites yet')
})
})
describe('Sites: loading', () => {
it('draws nothing before 150 ms, then skeleton rows with aria-busy', async () => {
vi.useFakeTimers()
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['GET /v1/site/'] = () => hold.promise
await mountDirectory('sites', '/settings/sites', { backend })
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
).toHaveLength(0)
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
).toHaveLength(3)
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Sites: error on load with %s', (_name, responder) => {
it('shows the alert, no rows, and Try again requests the list again', async () => {
const backend = createBackend()
backend.responders['GET /v1/site/'] = responder
await mountDirectory('sites', '/settings/sites', { backend })
const alert = document.body.querySelector('[role="alert"]')!
expect(textOf(alert)).toContain("Couldn't load sites")
expect(textOf(alert)).toContain('Check your connection and try again.')
expect(rows()).toHaveLength(0)
expect(textOf(alert)).not.toContain('500')
delete backend.responders['GET /v1/site/']
await click('Try again')
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(2)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(rows()).toHaveLength(4)
})
})
describe('Sites: row menu', () => {
it('offers Edit, Set as default and Delete… for a non-default site', async () => {
await mountDirectory('sites', '/settings/sites')
await openRowMenu('Journal')
const labels = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)
expect(labels).toEqual(['Edit', 'Set as default', 'Delete…'])
})
it('opens a bottom sheet titled with the site name below md', async () => {
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
await openRowMenu('Journal')
const sheet = dialog()!
expect(textOf(sheet.querySelector('h2'))).toBe('Journal')
expect(sheet.querySelectorAll('[role="menuitem"]')).toHaveLength(3)
})
})
describe('Sites: delete unavailable on the default site', () => {
it('keeps Delete… focusable with aria-disabled and sends nothing when activated', async () => {
const backend = await mountDirectory('sites', '/settings/sites')
await openRowMenu('Renew')
const item = button(/^Delete…/)!
expect(item.getAttribute('aria-disabled')).toBe('true')
expect(item.hasAttribute('disabled')).toBe(false)
expect(item.getAttribute('data-disabled')).toBeNull()
expect(textOf(document.getElementById(item.getAttribute('aria-describedby')!))).toBe(
DEFAULT_SITE_TIP,
)
item.focus()
expect(document.activeElement).toBe(item)
item.click()
await flushPromises()
expect(dialog()?.getAttribute('role')).not.toBe('alertdialog')
expect(backend.calls.filter((call) => call.method === 'DELETE')).toHaveLength(0)
})
it('writes the explanation in the sheet below md', async () => {
await mountDirectory('sites', '/settings/sites', { width: MOBILE_WIDTH })
await openRowMenu('Renew')
expect(textOf(dialog())).toContain(DEFAULT_SITE_TIP)
expect(button('Delete…')!.getAttribute('aria-disabled')).toBe('true')
})
})
describe('Sites: set as default', () => {
async function openDialog(backend = createBackend()) {
await mountDirectory('sites', '/settings/sites', { backend })
await openRowMenu('Journal')
await chooseMenuItem('Set as default')
return backend
}
it('asks, sends one PATCH without a body, shows Saving… and moves the badge with a toast', async () => {
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['PATCH /v1/site/2/default'] = () => hold.promise
await openDialog(backend)
const box = dialog()!
expect(textOf(box)).toContain('Make Journal the default site?')
expect(textOf(box)).toContain(
'New articles start on this site. Articles that are already on other sites stay where they are.',
)
expect(button('Cancel', box)).toBeTruthy()
await click('Make default', box)
expect(button('Saving…', box)).toBeTruthy()
hold.resolve({})
await flushPromises()
const patches = callsTo(backend, 'PATCH', '/v1/site/2/default')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toBeUndefined()
expect(textOf(rows()[1])).toContain('Default')
expect(textOf(rows()[0])).not.toContain('Default')
expect(document.body.textContent).toContain('Default site changed')
expect(document.body.textContent).toContain('"Journal" is now the default site.')
})
it('keeps the dialog open with a notice on a 500', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/2/default'] = failWith(500)
await openDialog(backend)
await click('Make default', dialog()!)
expect(dialog()).not.toBeNull()
expect(textOf(dialog()!.querySelector('[role="alert"]'))).toContain(
"Couldn't save the site. Your changes are still here. Try again in a moment.",
)
})
it('closes, removes the row and toasts on a 404', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/2/default'] = failWith(404, 'site_not_found')
await openDialog(backend)
await click('Make default', dialog()!)
expect(dialog()).toBeNull()
expect(rowTexts().join(' ')).not.toContain('Journal')
expect(document.body.textContent).toContain('That site no longer exists')
expect(document.body.textContent).toContain(
'It was deleted somewhere else, so we removed it from the list.',
)
})
it('closes and shows the owner-only toast on a 403', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/2/default'] = failWith(
403,
'Only the owner can do this.',
)
await openDialog(backend)
await click('Make default', dialog()!)
expect(dialog()).toBeNull()
expect(document.body.textContent).toContain('Only the owner can manage sites')
expect(document.body.textContent).toContain('Ask the owner of this site.')
})
})
describe.each([
['admin', '/settings/sites'],
['member', '/settings/sites'],
['admin', '/settings/sites?edit=journal'],
])('Sites: forbidden (403 page) for %s on %s', (role, path) => {
it('explains instead of redirecting, opens no panel and Back to Settings goes to /settings', async () => {
const backend = await mountDirectory('sites', path, { role })
const text = document.body.textContent ?? ''
expect(text).toContain("You don't have access to Sites")
expect(text).toContain(
'Only the owner can manage sites. Ask the owner of this site if you need a change.',
)
expect(router.currentRoute.value.fullPath).toBe(path)
expect(dialog()).toBeNull()
expect(
document.body.querySelector('nav[aria-label="Main"] a[href="/settings/sites"]'),
).toBeNull()
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(0)
const push = vi.spyOn(router, 'push').mockResolvedValue(undefined)
await click('Back to Settings')
expect(push).toHaveBeenCalledWith('/settings')
push.mockRestore()
})
})
describe('Sites: the API answers 403 where the SPA expected the owner', () => {
it('shows the 403 page when the answer is for the page load', async () => {
const backend = createBackend()
backend.responders['GET /v1/site/'] = failWith(403, 'Only the owner can do this.')
await mountDirectory('sites', '/settings/sites', { backend })
expect(document.body.textContent).toContain("You don't have access to Sites")
expect(rows()).toHaveLength(0)
})
it('shows an error toast when the answer is for an action', async () => {
const backend = createBackend()
backend.responders['DELETE /v1/site/4'] = failWith(403, 'Only the owner can do this.')
await mountDirectory('sites', '/settings/sites', { backend })
await openRowMenu('Archive')
await chooseMenuItem('Delete…')
await click('Delete site', dialog()!)
expect(document.body.textContent).toContain('Only the owner can manage sites')
expect(document.body.textContent).toContain('Ask the owner of this site.')
})
})
describe('Sites: the sidebar for the owner and for an admin', () => {
it('shows a Publishing group with a Sites entry linking to /settings/sites for the owner', async () => {
await mountDirectory('sites', '/settings/sites')
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(textOf(nav)).toContain('Publishing')
const link = nav.querySelector<HTMLAnchorElement>('a[href="/settings/sites"]')!
expect(textOf(link)).toBe('Sites')
expect(link.getAttribute('aria-current')).toBe('page')
})
it('has no Publishing group and no Sites entry for an admin', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(textOf(nav)).not.toContain('Publishing')
expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull()
})
})
void useApi
void DESKTOP_WIDTH
+445
View File
@@ -0,0 +1,445 @@
import { vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { ApiError, NetworkError, useApi, type MeAuthor } from '@/composables/useApi'
import type { Author } from '@/composables/useAuthors'
import type { Site } from '@/composables/useSites'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import AuthorsView from '../AuthorsView.vue'
import SitesView from '../SitesView.vue'
export interface Call {
method: string
path: string
raw: string
query: URLSearchParams
body: Record<string, unknown> | undefined
}
export type Responder = (call: Call) => unknown | Promise<unknown>
export interface Backend {
sites: Site[]
authors: Author[]
users: unknown[]
calls: Call[]
responders: Record<string, Responder>
}
export function sitesFixture(): Site[] {
const base = {
default_social_image_id: null,
default_robots: 'index' as const,
locale: null,
}
return [
{
...base,
id: 1,
name: 'Renew',
key: 'renew',
base_url: 'https://renew.example',
is_default: true,
article_count: 268,
},
{
...base,
id: 2,
name: 'Journal',
key: 'journal',
base_url: 'https://journal.example',
is_default: false,
article_count: 38,
},
{
...base,
id: 3,
name: 'Projects',
key: 'projects',
base_url: 'https://projects.example',
default_robots: 'noindex',
is_default: false,
article_count: 6,
},
{
...base,
id: 4,
name: 'Archive',
key: 'archive',
base_url: 'https://archive.example',
is_default: false,
article_count: 0,
},
]
}
function author(overrides: Partial<Author> & Pick<Author, 'id' | 'name' | 'slug'>): Author {
return {
bio: '',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 0,
...overrides,
}
}
export function authorsFixture(withEmails = true): Author[] {
const list = [
author({
id: 2,
name: 'Inès Duarte',
slug: 'ines-duarte',
bio: 'Site manager and restoration carpenter.',
website: 'https://ines-duarte.example',
social_links: [{ type: 'linkedin', url: 'https://www.linkedin.com/in/ines-duarte' }],
user_id: 3,
is_linked: true,
article_count: 9,
user_email: 'ines.duarte@renew.example',
}),
author({
id: 7,
name: 'Camille Roche',
slug: 'camille-roche',
bio: 'Freelance writer on lime and stone.',
article_count: 3,
}),
author({
id: 9,
name: 'Dr. Alexandra Konstantinopoulou-Whitfield',
slug: 'dr-alexandra-konstantinopoulou-whitfield',
article_count: 1,
}),
author({ id: 10, name: 'Léa Fontaine', slug: 'lea-fontaine' }),
]
return withEmails ? list : list.map((item) => ({ ...item, user_email: undefined }))
}
export function usersFixture() {
const user = (id: number, username: string, display: string, email: string, role: string) => ({
kind: 'user',
id,
username,
display_name: display,
email,
role,
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
})
return [
user(3, 'ines', 'Inès Duarte', 'ines.duarte@renew.example', 'admin'),
user(1, 'marta', 'Marta Alves', 'marta@renew.example', 'owner'),
user(4, 'rui', 'Rui Santos', 'rui@renew.example', 'admin'),
]
}
function fail(status: number, code?: string): never {
throw new ApiError(status, undefined, code)
}
function parseBody(options: RequestInit | undefined) {
return typeof options?.body === 'string'
? (JSON.parse(options.body) as Record<string, unknown>)
: undefined
}
function pageOf(backend: Backend, query: URLSearchParams) {
const q = (query.get('q') ?? '').toLowerCase()
const size = Number(query.get('size') ?? 25)
const page = Number(query.get('page') ?? 1)
const matching = backend.authors.filter((item) =>
`${item.name} ${item.slug}`.toLowerCase().includes(q),
)
return {
items: matching.slice((page - 1) * size, page * size),
total: matching.length,
page,
size,
}
}
function defaultRoute(backend: Backend, call: Call): unknown {
const { method, path, query, body } = call
const siteMatch = /^\/v1\/site\/(\d+)(\/default)?$/.exec(path)
const authorMatch = /^\/v1\/author\/(\d+)$/.exec(path)
if (method === 'GET' && path === '/v1/site/') {
return structuredClone(backend.sites)
}
if (method === 'POST' && path === '/v1/site/') {
if (backend.sites.some((site) => site.key === body?.key)) {
fail(409, 'site_key_taken')
}
const created = {
id: 100,
default_social_image_id: null,
locale: null,
article_count: 0,
...body,
} as unknown as Site
backend.sites.push(created)
return created
}
if (siteMatch && method === 'PATCH') {
const site = backend.sites.find((candidate) => candidate.id === Number(siteMatch[1]))
if (!site) {
fail(404, 'site_not_found')
}
if (siteMatch[2]) {
backend.sites.forEach((candidate) => (candidate.is_default = candidate === site))
} else {
Object.assign(site, body)
}
return structuredClone(site)
}
if (siteMatch && method === 'DELETE') {
const id = Number(siteMatch[1])
const site = backend.sites.find((candidate) => candidate.id === id)
if (!site) {
fail(404, 'site_not_found')
}
if (site.is_default) {
fail(403, 'default_site')
}
const reassign = query.get('reassign_to')
if (site.article_count > 0 && reassign === null) {
fail(409, 'site_has_articles')
}
backend.sites = backend.sites.filter((candidate) => candidate.id !== id)
return undefined
}
if (method === 'GET' && path === '/v1/author/') {
return pageOf(backend, query)
}
if (method === 'POST' && path === '/v1/author/') {
const created = { id: 200, is_linked: body?.user_id != null, article_count: 0, ...body }
backend.authors.push(created as unknown as Author)
return created
}
if (authorMatch && method === 'PATCH') {
const target = backend.authors.find((candidate) => candidate.id === Number(authorMatch[1]))
if (!target) {
fail(404, 'author_not_found')
}
Object.assign(target, body)
if (body && 'user_id' in body) {
target.is_linked = body.user_id !== null
}
return structuredClone(target)
}
if (authorMatch && method === 'DELETE') {
const id = Number(authorMatch[1])
backend.authors = backend.authors.filter((candidate) => candidate.id !== id)
return undefined
}
if (method === 'GET' && path === '/v1/user/') {
return backend.users
}
if (method === 'GET' && path === '/v1/media/') {
return {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 0, unused: 0, duplicates: 0 },
}
}
throw new Error(`Unexpected request: ${method} ${path}`)
}
export function createBackend(): Backend {
return {
sites: sitesFixture(),
authors: authorsFixture(),
users: usersFixture(),
calls: [],
responders: {},
}
}
export function callsTo(backend: Backend, method: string, path: string) {
return backend.calls.filter((call) => call.method === method && call.path === path)
}
export function failWith(status: number, code?: string): Responder {
return () => fail(status, code)
}
export const networkFailure: Responder = () => {
throw new NetworkError()
}
export function pending<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((done) => {
resolve = done
})
return { promise, resolve }
}
export interface MountOptions {
role?: string
width?: number
backend?: Backend
author?: MeAuthor
email?: string
}
export const kit: { wrapper: VueWrapper | null; backend: Backend } = {
wrapper: null,
backend: createBackend(),
}
export async function mountDirectory(
view: 'sites' | 'authors',
url: string,
options: MountOptions = {},
) {
setViewportWidth(options.width ?? DESKTOP_WIDTH)
Element.prototype.scrollIntoView = vi.fn()
setActivePinia(createPinia())
const backend = options.backend ?? createBackend()
kit.backend = backend
const session = useSessionStore()
session.session = {
verified: true,
username: 'marta',
id: 1,
email: options.email ?? 'marta@renew.example',
displayName: 'Marta Alves',
role: (options.role ?? 'owner') as never,
...(options.author ? { author: options.author } : {}),
}
session.status = 'ready'
const request = vi.fn(async (path: string, init?: RequestInit) => {
const [pathname = '', search = ''] = path.split('?')
const call: Call = {
method: init?.method ?? 'GET',
path: pathname,
raw: path,
query: new URLSearchParams(search),
body: parseBody(init),
}
backend.calls.push(call)
const responder =
backend.responders[`${call.method} ${path}`] ??
backend.responders[`${call.method} ${pathname}`]
return responder ? responder(call) : defaultRoute(backend, call)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
request,
} as unknown as ReturnType<typeof useApi>)
await router.push(url)
const component = view === 'sites' ? SitesView : AuthorsView
kit.wrapper = mount(
defineComponent({ render: () => h(UApp, null, { default: () => h(component) }) }),
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
)
await flushPromises()
return backend
}
export async function unmountDirectory() {
kit.wrapper?.unmount()
kit.wrapper = null
vi.useRealTimers()
await router.push('/login')
document.body.innerHTML = ''
}
export function textOf(element: Element | null | undefined) {
if (!element) {
return ''
}
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
const parts: string[] = []
while (walker.nextNode()) {
const text = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim()
if (text) {
parts.push(text)
}
}
return parts.join(' ')
}
export function button(label: string | RegExp, root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLElement>('button, [role="menuitem"], a')).find(
(element) => {
const text = element.getAttribute('aria-label') || textOf(element)
return typeof label === 'string' ? text === label : label.test(text)
},
)
}
export async function click(label: string | RegExp, root: ParentNode = document.body) {
const target = button(label, root)
if (!target) {
throw new Error(`No control named ${String(label)}`)
}
target.click()
await flushPromises()
}
export function dialog() {
return document.body.querySelector<HTMLElement>('[role="dialog"], [role="alertdialog"]')
}
export function rows() {
return Array.from(
document.body.querySelectorAll<HTMLElement>(
'[data-testid="directory-row"], [data-testid="directory-card"]',
),
)
}
export function rowTexts() {
return rows().map((row) => textOf(row))
}
export async function openRowMenu(name: string) {
await click(`Actions for ${name}`)
}
export async function chooseMenuItem(label: string) {
await click(label)
}
export async function typeInto(element: HTMLInputElement | HTMLTextAreaElement, value: string) {
element.value = value
element.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
export async function blur(element: HTMLElement) {
element.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
element.dispatchEvent(new Event('blur'))
await flushPromises()
}
export function field(placeholder: string) {
return document.body.querySelector<HTMLInputElement>(`[placeholder="${placeholder}"]`)!
}
export function labelled(label: string) {
const labelElement = Array.from(document.body.querySelectorAll('label')).find((element) =>
textOf(element).startsWith(label),
)
const id = labelElement?.getAttribute('for')
return id ? document.getElementById(id)! : null
}
export function toasts() {
return textOf(
document.body.querySelector(
'[data-slot="viewport"], [role="region"][aria-label*="otification"]',
),
)
}
+55
View File
@@ -32,6 +32,61 @@ const VISUAL_REFERENCE =
const officeBoard = 'RenewCMS Screens Back-office.dc.html'
const extraSources: Record<string, Source[]> = {
sites: [
{
board: officeBoard,
group: 'Sites: form',
states: [
'Add: empty',
'Add: key from the name',
'Add: URL not valid',
'Add: duplicate key',
'Add: save failed',
'Edit: default site',
'Edit: other site, noindex',
],
alias: {},
},
{
board: officeBoard,
group: 'Sites: delete',
states: ['With articles', 'Choose destination', 'No articles'],
alias: {},
},
],
authors: [
{
board: officeBoard,
group: 'Authors: form',
states: [
'Add: empty',
'Slug from the name',
'Slug collision',
'Validation',
'Social links',
'Linked user: choose',
'Edit: linked author',
'Avatar: picker',
'Save failed',
'Unlink: confirm',
'Re-link',
],
alias: {},
},
{
board: officeBoard,
group: 'Authors: delete',
states: [
'Reassign (recommended)',
'Choose who',
'Nobody',
'Failed (shared by site and author delete)',
'Server fallback (409)',
'No articles',
],
alias: {},
},
],
integration: [
{
board: officeBoard,
+720
View File
@@ -4484,7 +4484,727 @@ function withDetailTabsException(states: Record<string, VisualState>): Record<st
}
}
function directorySession(role: string, displayName = 'Marta Alves'): ApiMock {
return {
status: 200,
body: { ...(verifiedSession.body as object), display_name: displayName, role },
}
}
const directorySites = [
{
id: 1,
name: 'Renew',
key: 'renew',
base_url: 'https://renew.example',
default_social_image_id: 11,
default_robots: 'index',
locale: 'en',
is_default: true,
article_count: 268,
},
{
id: 2,
name: 'Journal',
key: 'journal',
base_url: 'https://journal.renew.example',
default_social_image_id: null,
default_robots: 'index',
locale: null,
is_default: false,
article_count: 38,
},
{
id: 3,
name: 'Projects',
key: 'projects',
base_url: 'https://projects.renew.example',
default_social_image_id: 12,
default_robots: 'noindex',
locale: 'fr',
is_default: false,
article_count: 6,
},
{
id: 4,
name: 'Archive',
key: 'archive',
base_url: 'https://archive.renew.example',
default_social_image_id: null,
default_robots: 'noindex',
locale: null,
is_default: false,
article_count: 0,
},
]
function sitesApi(
overrides: Record<string, ApiMock> = {},
role = 'owner',
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': directorySession(role),
'GET /v1/site/': { status: 200, body: directorySites },
...overrides,
}
}
const listboxTrigger = 'button[aria-haspopup="listbox"]'
function isPhone(page: Page) {
return (page.viewportSize()?.width ?? 0) < 768
}
async function openDirectoryMenu(page: Page, name: string) {
await page.getByRole('button', { name: `Actions for ${name}`, exact: true }).click()
await page.getByRole('menuitem').first().waitFor()
}
async function chooseDirectoryMenuItem(page: Page, label: string) {
await page.getByRole('menuitem', { name: label, exact: true }).click()
await page.mouse.move(0, 0)
}
async function openDirectoryAdd(page: Page, label: string) {
await page.getByRole('button', { name: label, exact: true }).first().click()
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
}
async function openSiteEdit(page: Page, name: string) {
await openDirectoryMenu(page, name)
await chooseDirectoryMenuItem(page, 'Edit')
await page.getByRole('dialog').waitFor()
}
async function openSiteDelete(page: Page, name: string) {
await openDirectoryMenu(page, name)
await chooseDirectoryMenuItem(page, 'Delete…')
await page.getByRole('alertdialog').waitFor()
}
function siteFormField(page: Page, placeholder: string) {
return page.getByRole('dialog').getByPlaceholder(placeholder)
}
async function scrollPanelToTop(page: Page) {
await page.evaluate(() => {
document.querySelectorAll('[role="dialog"] [data-slot="body"]').forEach((body) => {
body.scrollTop = 0
})
})
await page.mouse.move(0, 0)
}
async function submitDirectoryPanel(page: Page, label: string) {
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click()
await page.mouse.move(0, 0)
}
const sitesStates: Record<string, VisualState> = {
Default: { path: '/settings/sites', api: sitesApi() },
'Only the default site': {
path: '/settings/sites',
api: sitesApi({
'GET /v1/site/': {
status: 200,
body: [{ ...directorySites[0], article_count: 312 }],
},
}),
},
Loading: {
path: '/settings/sites',
api: sitesApi({ 'GET /v1/site/': { status: 200, hang: true } }),
},
Error: { path: '/settings/sites', api: sitesApi({ 'GET /v1/site/': { status: 500 } }) },
'Row menu': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryMenu(page, 'Journal')
await page.mouse.move(0, 0)
},
},
'Default site: delete unavailable': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryMenu(page, 'Renew')
if (!isPhone(page)) {
await page.getByRole('menuitem', { name: 'Delete…' }).hover()
await page.waitForTimeout(TOOLTIP_SETTLE_MS)
}
},
},
'Set as default: confirm': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryMenu(page, 'Journal')
await chooseDirectoryMenuItem(page, 'Set as default')
await page.getByRole('alertdialog').waitFor()
},
},
'Hidden entry (admin)': { path: '/settings', skip: 'clock-sensitive' },
'Forbidden (403)': {
path: '/settings/sites',
api: sitesApi({}, 'admin'),
},
'Add: empty': {
path: '/settings/sites',
api: sitesApi(),
steps: (page) => openDirectoryAdd(page, 'Add site'),
},
'Add: key from the name': {
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').focus()
},
},
'Add: URL not valid': {
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(
'http://journal.renew.example',
)
await siteFormField(page, 'https://blog.example.com').blur()
await page.getByRole('dialog').getByText('Use https. For example').waitFor()
await page.mouse.move(0, 0)
},
},
'Add: duplicate key': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryAdd(page, 'Add site')
await siteFormField(page, 'e.g. Journal').fill('Journal')
await page.getByRole('button', { name: 'Edit key', exact: true }).click()
await page.getByRole('dialog').getByLabel('Key', { exact: true }).focus()
await siteFormField(page, 'https://blog.example.com').fill(
'https://journal2.renew.example',
)
await page.getByRole('dialog').getByText('already exists').waitFor()
await page.mouse.move(0, 0)
},
},
'Add: save failed': {
path: '/settings/sites',
api: sitesApi({ 'POST /v1/site/': { status: 500 } }),
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 submitDirectoryPanel(page, 'Add site')
await page.getByRole('dialog').getByText("Couldn't save the site").waitFor()
},
},
'Edit: default site': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openSiteEdit(page, 'Renew')
await page.mouse.move(0, 0)
},
},
'Edit: other site, noindex': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openSiteEdit(page, 'Projects')
await page.mouse.move(0, 0)
},
},
'With articles': {
path: '/settings/sites',
api: sitesApi(),
steps: (page) => openSiteDelete(page, 'Journal'),
},
'Choose destination': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openSiteDelete(page, 'Journal')
await page.getByRole('alertdialog').locator(listboxTrigger).click()
await page.getByRole('option').first().waitFor()
},
},
'No articles': {
path: '/settings/sites',
api: sitesApi(),
steps: (page) => openSiteDelete(page, 'Archive'),
},
}
const directoryLinks = {
camille: [
{ type: 'mastodon', url: 'https://mastodon.social/@camille' },
{ type: 'linkedin', url: 'linkedin.com/in/camille' },
],
ines: [{ type: 'linkedin', url: 'https://www.linkedin.com/in/ines-duarte' }],
}
interface DirectoryAuthor {
id: number
name: string
slug: string
bio: string
avatar_id: number | null
website: string | null
social_links: { type: string; url: string }[]
user_id: number | null
is_linked: boolean
article_count: number
user_email?: string
}
function directoryAuthors(
overrides: Record<string, Partial<DirectoryAuthor>> = {},
owner = true,
): DirectoryAuthor[] {
const base: DirectoryAuthor[] = [
{
id: 1,
name: 'Inès Duarte',
slug: 'ines-duarte',
bio: 'Site manager and restoration carpenter. Writes about timber and joinery.',
avatar_id: 12,
website: 'https://ines-duarte.example',
social_links: directoryLinks.ines,
user_id: 3,
is_linked: true,
article_count: 9,
user_email: 'ines.duarte@renew.example',
},
{
id: 2,
name: 'Jonas Pereira',
slug: 'jonas-pereira',
bio: 'Heat pumps, insulation and the numbers behind them.',
avatar_id: 13,
website: null,
social_links: [],
user_id: 2,
is_linked: true,
article_count: 6,
user_email: 'jonas@renew.example',
},
{
id: 3,
name: 'Camille Roche',
slug: 'camille-roche',
bio: 'Freelance writer on lime and stone.',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 3,
},
{
id: 4,
name: 'Paul Huber',
slug: 'paul-huber',
bio: '',
avatar_id: 14,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 2,
},
{
id: 5,
name: 'Léa Fontaine',
slug: 'lea-fontaine',
bio: '',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 0,
},
{
id: 6,
name: 'Dr. Alexandra Konstantinopoulou-Whitfield',
slug: 'dr-alexandra-konstantinopoulou-whitfield',
bio: 'Conservation architect.',
avatar_id: null,
website: null,
social_links: [],
user_id: null,
is_linked: false,
article_count: 1,
},
]
return base.map((author) => {
const merged = { ...author, ...(overrides[author.slug] ?? {}) }
if (!owner) {
delete merged.user_email
}
return merged
})
}
function authorListMock(authors: DirectoryAuthor[]): ApiMock {
return {
status: 200,
bodyFor: (url) => {
const q = (url.searchParams.get('q') ?? '').toLowerCase()
const matching = authors.filter((author) =>
`${author.name} ${author.slug}`.toLowerCase().includes(q),
)
const size = Number(url.searchParams.get('size') ?? 25)
const page = Number(url.searchParams.get('page') ?? 1)
return {
items: matching.slice((page - 1) * size, page * size),
total: matching.length,
page,
size,
}
},
}
}
const directoryUsers = [
{
kind: 'user',
id: 3,
username: 'ines',
display_name: 'Inès Duarte',
email: 'ines.duarte@renew.example',
role: 'admin',
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
},
{
kind: 'user',
id: 2,
username: 'jonas',
display_name: 'Jonas Pereira',
email: 'jonas@renew.example',
role: 'admin',
status: 'accepted',
created_at: '2026-04-02T09:00:00Z',
},
{
kind: 'user',
id: 1,
username: 'marta',
display_name: 'Marta Alves',
email: 'marta@renew.example',
role: 'owner',
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
},
{
kind: 'user',
id: 4,
username: 'rui',
display_name: 'Rui Santos',
email: 'rui@renew.example',
role: 'admin',
status: 'accepted',
created_at: '2026-05-02T09:00:00Z',
},
]
function authorsApi(
overrides: Record<string, ApiMock> = {},
options: { role?: string; authors?: DirectoryAuthor[] } = {},
): Record<string, ApiMock> {
const role = options.role ?? 'owner'
return {
'GET /v1/auth/me': directorySession(role),
'GET /v1/author/': authorListMock(options.authors ?? directoryAuthors({}, role !== 'user')),
'GET /v1/user/': { status: 200, body: directoryUsers },
...overrides,
}
}
async function openAuthorEdit(page: Page, name: string) {
await openDirectoryMenu(page, name)
await chooseDirectoryMenuItem(page, 'Edit')
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
}
async function openAuthorDelete(page: Page, name: string) {
await openDirectoryMenu(page, name)
await chooseDirectoryMenuItem(page, 'Delete…')
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}
const avatarPickerNames = [
'north-wall.jpg',
'kitchen-after.webp',
'kitchen-before.webp',
'logo-renew.svg',
'window-frame.jpg',
'lime-plaster.jpg',
'floorboards.jpg',
'bathroom-tiles.png',
]
const avatarPickerTones = ['0', '1', '3']
function avatarPickerList() {
const library = mediaItems()
const items = avatarPickerNames
.flatMap((name) => library.filter((item) => item.filename === name))
.map((item, index) => ({
...item,
content_type: 'image/jpeg',
sha256: `${avatarPickerTones[index % avatarPickerTones.length]}${item.sha256.slice(1)}`,
}))
return mediaList(items, pickerCounts)
}
const authorsStates: Record<string, VisualState> = {
Default: { path: '/authors', api: authorsApi() },
Loading: {
path: '/authors',
api: authorsApi({ 'GET /v1/author/': { status: 200, hang: true } }),
},
Error: { path: '/authors', api: authorsApi({ 'GET /v1/author/': { status: 500 } }) },
'Empty, first run': {
path: '/authors',
api: authorsApi({}, { authors: [] }),
},
'No results': { path: '/authors?q=zoe', api: authorsApi() },
'Row menu': {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openDirectoryMenu(page, 'Camille Roche')
await page.mouse.move(0, 0)
},
},
'Read-only list (other roles)': {
path: '/authors',
api: authorsApi({}, { role: 'user' }),
},
'Read-only detail': {
path: '/authors',
api: authorsApi({}, { role: 'user' }),
steps: async (page) => {
await page.getByRole('button', { name: 'View Inès Duarte', exact: true }).click()
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
},
},
'Add: empty': {
path: '/authors',
api: authorsApi(),
steps: (page) => openDirectoryAdd(page, 'Add author'),
},
'Slug from the name': {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openDirectoryAdd(page, 'Add author')
await page
.getByRole('dialog')
.getByPlaceholder('e.g. Camille Roche')
.fill('Camille Roche')
await page
.getByRole('dialog')
.getByPlaceholder('A sentence or two about the author.')
.fill('Freelance writer on lime and stone.')
},
},
'Slug collision': {
path: '/authors',
api: authorsApi({ 'POST /v1/author/': { status: 409, body: 'author_slug_taken' } }),
steps: async (page) => {
await openDirectoryAdd(page, 'Add author')
await page
.getByRole('dialog')
.getByPlaceholder('e.g. Camille Roche')
.fill('Camille Roche')
await page.getByRole('button', { name: 'Edit slug', exact: true }).click()
await submitDirectoryPanel(page, 'Add author')
await page.getByRole('dialog').getByText('already used').waitFor()
},
},
Validation: {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openDirectoryAdd(page, 'Add author')
await page
.getByRole('dialog')
.getByPlaceholder('A sentence or two about the author.')
.fill('x'.repeat(291))
await page.getByRole('dialog').getByPlaceholder('https://example.com').fill('example')
await submitDirectoryPanel(page, 'Add author')
await page.getByRole('dialog').getByText('Enter a name.').waitFor()
await scrollPanelToTop(page)
},
},
'Social links': {
path: '/authors',
api: authorsApi(
{},
{
authors: directoryAuthors({
'camille-roche': { social_links: directoryLinks.camille },
}),
},
),
steps: async (page) => {
await openAuthorEdit(page, 'Camille Roche')
await page.getByRole('button', { name: 'Add link', exact: true }).click()
await page.getByRole('dialog').getByLabel('Link URL 3').focus()
await scrollPanelToTop(page)
},
},
'Linked user: choose': {
path: '/authors',
api: authorsApi({}, { authors: directoryAuthors({ 'camille-roche': { bio: '' } }) }),
steps: async (page) => {
await openAuthorEdit(page, 'Camille Roche')
await page.getByRole('dialog').locator(listboxTrigger).click()
await page.getByRole('option').first().waitFor()
await scrollPanelToTop(page)
},
},
'Edit: linked author': {
path: '/authors',
api: authorsApi(),
steps: (page) => openAuthorEdit(page, 'Inès Duarte'),
},
'Avatar: picker': {
path: '/authors',
api: authorsApi(
{
'GET /v1/media/': { status: 200, bodyFor: avatarPickerList },
'GET /v1/image-variants': { status: 403 },
},
{ authors: directoryAuthors({ 'camille-roche': { bio: '' } }) },
),
steps: async (page) => {
await openAuthorEdit(page, 'Camille Roche')
await page.getByRole('button', { name: 'Choose image', exact: true }).click()
await waitForPickerTiles(page)
await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click()
await scrollPanelToTop(page)
await page.mouse.move(0, 0)
},
},
'Save failed': {
path: '/authors',
api: authorsApi(
{ 'PATCH /v1/author/3': { status: 500 } },
{ authors: directoryAuthors({ 'camille-roche': { bio: '' } }) },
),
steps: async (page) => {
await openAuthorEdit(page, 'Camille Roche')
await page
.getByRole('dialog')
.getByPlaceholder('A sentence or two about the author.')
.fill('Freelance writer on lime and stone.')
await submitDirectoryPanel(page, 'Save changes')
await page.getByRole('dialog').getByText("Couldn't save the author").waitFor()
},
},
'Unlink: confirm': {
path: '/authors',
api: authorsApi(
{},
{
authors: directoryAuthors({
'ines-duarte': { social_links: [], bio: '', website: null },
}),
},
),
steps: async (page) => {
await openAuthorEdit(page, 'Inès Duarte')
await page.getByRole('button', { name: 'Unlink', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await scrollPanelToTop(page)
},
},
'Re-link': {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openDirectoryMenu(page, 'Camille Roche')
await chooseDirectoryMenuItem(page, 'Link user')
await page.getByRole('alertdialog').waitFor()
await page.getByRole('alertdialog').locator(listboxTrigger).click()
await page.getByRole('option').first().waitFor()
},
},
'Reassign (recommended)': {
path: '/authors',
api: authorsApi(),
steps: (page) => openAuthorDelete(page, 'Camille Roche'),
},
'Choose who': {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openAuthorDelete(page, 'Camille Roche')
await page.getByRole('alertdialog').locator(listboxTrigger).click()
await page.getByRole('option').first().waitFor()
},
},
Nobody: {
path: '/authors',
api: authorsApi(),
steps: async (page) => {
await openAuthorDelete(page, 'Camille Roche')
await page.getByRole('alertdialog').locator(listboxTrigger).click()
await page.getByRole('option', { name: /Nobody/ }).click()
await page.mouse.move(0, 0)
},
},
'Failed (shared by site and author delete)': {
path: '/authors',
api: authorsApi({ 'DELETE /v1/author/3': { status: 500 } }),
steps: async (page) => {
await openAuthorDelete(page, 'Camille Roche')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete and reassign 3 articles', exact: true })
.click()
await page.getByRole('alertdialog').getByText("Couldn't delete the author").waitFor()
await page.mouse.move(0, 0)
},
},
'Server fallback (409)': {
path: '/authors',
api: authorsApi({ 'DELETE /v1/author/3': { status: 409, body: 'author_has_articles' } }),
steps: async (page) => {
await openAuthorDelete(page, 'Camille Roche')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete and reassign 3 articles', exact: true })
.click()
await page.getByRole('alertdialog').getByText("Can't delete this author").waitFor()
await page.mouse.move(0, 0)
},
},
'No articles': {
path: '/authors',
api: authorsApi(),
steps: (page) => openAuthorDelete(page, 'Léa Fontaine'),
},
}
export const visualStates: Record<string, Record<string, VisualState>> = {
sites: sitesStates,
authors: authorsStates,
activity: activityStates,
profile: {
'Account menu': {