feat(SPA): screens for Sites and Authors
This commit is contained in:
1 parent
b34a173d83
commit
3d81d2c717
82 files changed
+10676
-37
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function jsonInit(method: string, body: unknown): RequestInit {
|
||||
return {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
}
|
||||
@@ -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) : ''
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface LinkableUser {
|
||||
id: number
|
||||
label: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface DirectoryNotice {
|
||||
title?: string
|
||||
description: string
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ImageChoice {
|
||||
title: string
|
||||
description: string
|
||||
hint?: string
|
||||
confirmLabel: string
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]',
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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': {
|
||||
|
||||
Reference in new issue
Block a user