feat: pagination in the SPA for the Sites and Authors lists
This commit is contained in:
1 parent
500ad59458
commit
340a0ad461
34 files changed
+2833
-896
No files matched your search
@@ -0,0 +1,307 @@
|
|||||||
|
Feature: Sites and Authors list pagination
|
||||||
|
|
||||||
|
As a RenewCMS owner (Sites) or any signed-in role (Authors)
|
||||||
|
I want the Sites and Authors lists split into pages with a page-size menu, server-side search and shareable URLs
|
||||||
|
So that I can browse a long directory quickly and come back to the same page
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given /settings/sites (SitesView, owner only) and /authors (AuthorsView, every role) are the verified routes of features/sites_authors_screens.feature, per docs/design/routes.md, docs/design/screens/sites.md group "Sites: pagination" and docs/design/screens/authors.md group "Authors: pagination"
|
||||||
|
And the Paginator and the page-size select are the Articles and Media ones (AppPaginator, docs/design/components.md "Paginator"); nothing new is drawn except the single-page variant below
|
||||||
|
And the screens request GET /v1/site/?q=Q&page=P&size=S and GET /v1/author/?q=Q&page=P&size=S through useApi only, with relative URLs, always sending page and size, and q only when it is not empty
|
||||||
|
And the total is read from the X-Total-Count response header, and the body stays the array of sites (Sites) or the envelope {items,total,page,size} (Authors)
|
||||||
|
And a request that fails or carries no X-Total-Count is treated as a failed load
|
||||||
|
And page, size and q live in the URL as ?page=&size=&q=; sizes are 5, 25, 50, 100 and the default is 25; the defaults (page 1, size 25, no q) are omitted from the URL
|
||||||
|
And an invalid page (not a positive integer) or an invalid size (not 5, 25, 50 or 100) is replaced silently by the default with a history replace and no notice, and the request carries the default
|
||||||
|
And a change to search or to size resets page to 1 and drops it from the URL; search keeps its existing debounce
|
||||||
|
And the URL changes when a response succeeds, not when the request is sent, and Browser Back returns to the previous page of results
|
||||||
|
And a page past the last page (a 200 with items [] and total above 0) is replaced in the URL (no history entry) by ?page=<last>, <last> is requested, and the info notice "That page doesn't exist. Showing the last page." is shown
|
||||||
|
And the summary reads "Showing {from}–{to} of {total}" with locale-formatted numbers (thousands separators above 999), computed from total (the filtered count)
|
||||||
|
And the footer sits below the list: summary on the left, the page-size select next to it, the page controls right-aligned; below md the summary and the select share one row (wrapping onto two if needed), then Previous, "Page X of Y" and Next on one row with 44 px targets and a label that never wraps
|
||||||
|
And the page controls are First, Previous, page numbers with ellipses marking skipped ranges once there are more than 7 pages, Next and Last; the current page uses --rc-sand fill, --rc-on-sand text, a --ui-primary border and aria-current="page"; disabled controls are at 40 % opacity with aria-disabled
|
||||||
|
And on a single page (total <= size, total > 0) the controls and the page-size select are not rendered and the footer holds only the count line "{N} sites" or "{N} authors" in --ui-text-muted ("1 site", "1 author"); this replaces the AuthorsView rule that showed the pager only above 25 items
|
||||||
|
And the count line or the footer appears whenever the list has loaded with total above 0, including the existing "Default" frames
|
||||||
|
And the same footer is shown to owner, admin and read-only roles on Authors; Sites stays owner only (403 page for the others)
|
||||||
|
And SitesView gains a search field with the placeholder "Search by name or key", matching name, key and base URL on the server; Authors keeps "Search by name or slug"
|
||||||
|
And the places that need every site (deep link ?edit=<key>, duplicate-key check, ReassignDialog destinations, the form's default-site text) read the unpaged GET /v1/site/ and never the current page
|
||||||
|
And keyboard and aria are those of Articles: Tab reaches the page-size select then the controls, Enter or Space activates, ArrowDown opens the page-size list, Enter selects, Esc closes and returns focus; every control shows a 2 px --ui-primary focus outline with a 2 px offset
|
||||||
|
And every new visible string goes through t(...) with the English text as the key and its French entry in web/src/i18n/messages.ts; the placeholders are written {0}, as in the other messages; the new strings are "Search by name or key" (French "Rechercher par nom ou clé"), "{0} sites" and "1 site" ("{0} sites", "1 site"), "{0} authors" and "1 author" ("{0} auteurs", "1 auteur"), and 'No sites match "{0}"' (French 'Aucun site ne correspond à « {0} »'); the other strings reuse the Articles and Media entries
|
||||||
|
And the sites fixture is 61 sites (3 pages at size 25) and the authors fixture is 128 authors (6 pages at size 25), with generated rows, unless stated
|
||||||
|
|
||||||
|
Rule: Page states, one set per screen
|
||||||
|
|
||||||
|
Scenario Outline: 1 First page
|
||||||
|
Given <screen> resolves GET <path>?page=1&size=25 with 25 rows and X-Total-Count <total>
|
||||||
|
When the user navigates to <route>
|
||||||
|
Then the summary reads "<summary>", the page-size select reads "25 per page" and the controls read "Page 1 of <pages>"
|
||||||
|
And First and Previous are disabled with aria-disabled, page 1 is current, Next and Last are enabled
|
||||||
|
And below md Previous is disabled
|
||||||
|
And the URL has no page and no size
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| screen | path | route | total | pages | summary |
|
||||||
|
| Sites | /v1/site/ | /settings/sites | 61 | 3 | Showing 1–25 of 61 |
|
||||||
|
| Authors | /v1/author/ | /authors | 128 | 6 | Showing 1–25 of 128 |
|
||||||
|
|
||||||
|
Scenario Outline: 2 Middle page
|
||||||
|
Given <screen> resolves GET <path>?page=<page>&size=25 with 25 rows and X-Total-Count <total>
|
||||||
|
When the user navigates to <route>?page=<page>
|
||||||
|
Then the summary reads "<summary>" and the label reads "Page <page> of <pages>"
|
||||||
|
And page <page> is current with --rc-sand, --rc-on-sand, a --ui-primary border and aria-current="page"
|
||||||
|
And every control is enabled
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| screen | path | route | page | total | pages | summary |
|
||||||
|
| Sites | /v1/site/ | /settings/sites | 2 | 61 | 3 | Showing 26–50 of 61 |
|
||||||
|
| Authors | /v1/author/ | /authors | 3 | 128 | 6 | Showing 51–75 of 128 |
|
||||||
|
|
||||||
|
Scenario Outline: 3 Last page
|
||||||
|
Given <screen> resolves GET <path>?page=<pages>&size=25 with <rows> rows and X-Total-Count <total>
|
||||||
|
When the user navigates to <route>?page=<pages>
|
||||||
|
Then the summary reads "<summary>" and the label reads "Page <pages> of <pages>"
|
||||||
|
And Next and Last are disabled with aria-disabled and Previous and First are enabled
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| screen | path | route | pages | rows | total | summary |
|
||||||
|
| Sites | /v1/site/ | /settings/sites | 3 | 11 | 61 | Showing 51–61 of 61 |
|
||||||
|
| Authors | /v1/author/ | /authors | 6 | 3 | 128 | Showing 126–128 of 128 |
|
||||||
|
|
||||||
|
Scenario Outline: 4 Single page
|
||||||
|
Given <screen> resolves GET <path>?page=1&size=25 with <total> rows and X-Total-Count <total>
|
||||||
|
When the user navigates to <route>
|
||||||
|
Then the footer shows only "<line>"
|
||||||
|
And the page controls and the page-size select are not rendered
|
||||||
|
And no "Showing" summary is rendered
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| screen | path | route | total | line |
|
||||||
|
| Sites | /v1/site/ | /settings/sites | 4 | 4 sites |
|
||||||
|
| Authors | /v1/author/ | /authors | 6 | 6 authors |
|
||||||
|
|
||||||
|
Scenario Outline: 4b Singular count line
|
||||||
|
Given <screen> resolves its list with 1 row and X-Total-Count 1
|
||||||
|
When the user navigates to <route>
|
||||||
|
Then the footer shows only "<line>"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| screen | route | line |
|
||||||
|
| Sites | /settings/sites | 1 site |
|
||||||
|
| Authors | /authors | 1 author |
|
||||||
|
|
||||||
|
Scenario Outline: 4c Five per page with a few rows still pages
|
||||||
|
Given <screen> resolves GET <path>?size=5&page=1 with 5 rows and X-Total-Count <total>
|
||||||
|
When the user navigates to <route>?size=5
|
||||||
|
Then the summary "Showing 1–5 of <total>", the page-size select "5 per page" and the controls read "Page 1 of 2"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| screen | path | route | total |
|
||||||
|
| Sites | /v1/site/ | /settings/sites | 6 |
|
||||||
|
| Authors | /v1/author/ | /authors | 6 |
|
||||||
|
|
||||||
|
Scenario Outline: 5 Page size menu
|
||||||
|
Given the user is on <route>?page=<page>
|
||||||
|
When the user opens the page-size select (it opens upward) and chooses "<label>"
|
||||||
|
Then <path>?page=1&size=<size> is requested and, on success, the URL is <url>
|
||||||
|
And the list shows page 1
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| route | path | page | label | size | url |
|
||||||
|
| /settings/sites | /v1/site/ | 2 | 5 per page | 5 | /settings/sites?size=5 |
|
||||||
|
| /settings/sites | /v1/site/ | 2 | 25 per page | 25 | /settings/sites |
|
||||||
|
| /settings/sites | /v1/site/ | 2 | 50 per page | 50 | /settings/sites?size=50 |
|
||||||
|
| /settings/sites | /v1/site/ | 2 | 100 per page | 100 | /settings/sites?size=100 |
|
||||||
|
| /authors | /v1/author/ | 3 | 5 per page | 5 | /authors?size=5 |
|
||||||
|
| /authors | /v1/author/ | 3 | 25 per page | 25 | /authors |
|
||||||
|
| /authors | /v1/author/ | 3 | 50 per page | 50 | /authors?size=50 |
|
||||||
|
| /authors | /v1/author/ | 3 | 100 per page | 100 | /authors?size=100 |
|
||||||
|
|
||||||
|
Scenario Outline: 6 Loading a page
|
||||||
|
Given <route>?page=<page> has loaded and the request for page <next> has not resolved
|
||||||
|
When the user activates Next (or Previous, a page number, or changes the size)
|
||||||
|
Then the rows are replaced by skeleton rows (8 on desktop, 6 below md) and the list is aria-busy="true"
|
||||||
|
And the pager stays, at 40 % opacity, every control and the page-size select are disabled and not focusable
|
||||||
|
And the summary reads "Loading page <next>…" and the page number shown is still <page>
|
||||||
|
And the URL is unchanged until the request resolves
|
||||||
|
When the request resolves 200
|
||||||
|
Then the rows, the summary and the URL of page <next> appear
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| route | page | next |
|
||||||
|
| /settings/sites | 2 | 3 |
|
||||||
|
| /authors | 3 | 4 |
|
||||||
|
|
||||||
|
Scenario Outline: 7 Error loading a page keeps the previous rows
|
||||||
|
Given <route>?page=<page> is shown
|
||||||
|
When the user activates Next and the request for page <next> <failure>
|
||||||
|
Then the rows of page <page> stay, the pager keeps "Page <page>" and is enabled again, and the URL stays <route>?page=<page>
|
||||||
|
And an inline error above the pager reads "Couldn't load page <next>. Showing page <page>.", "Check your connection and try again." and a "Retry" action
|
||||||
|
When the user activates "Retry"
|
||||||
|
Then the request for page <next> is sent again and, on 200, page <next> replaces the rows, the URL becomes ?page=<next> and the error goes
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| route | page | next | failure |
|
||||||
|
| /settings/sites | 2 | 3 | resolves with a 500 response |
|
||||||
|
| /settings/sites | 2 | 3 | never reaches the server |
|
||||||
|
| /authors | 3 | 4 | resolves with a 500 response |
|
||||||
|
| /authors | 3 | 4 | never reaches the server |
|
||||||
|
|
||||||
|
Scenario Outline: 8 Empty after search
|
||||||
|
Given <path>?q=<q>&page=1&size=25 resolves 200 with [] and X-Total-Count 0
|
||||||
|
When the user searches "<q>"
|
||||||
|
Then the existing empty state shows "<title>", "Check the spelling, or search for a shorter part of the name." and "Clear search"
|
||||||
|
And the pager, the summary and the count line are not rendered
|
||||||
|
When the user activates "Clear search"
|
||||||
|
Then the URL is <route>, page 1 is requested and the list returns
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| route | path | q | title |
|
||||||
|
| /settings/sites | /v1/site/ | blog | No sites match "blog" |
|
||||||
|
| /authors | /v1/author/ | zoe | No authors match "zoe" |
|
||||||
|
|
||||||
|
Scenario Outline: 9 Search resets to page 1
|
||||||
|
Given the user is on <route>?page=<page>
|
||||||
|
When the user types "<q>" in the search field and the debounce elapses
|
||||||
|
Then <path>?q=<q>&page=1&size=25 is requested
|
||||||
|
And on success the URL is <route>?q=<q> (page omitted) and the summary reads "<summary>"
|
||||||
|
And a size change resets the page the same way
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| route | path | page | q | summary |
|
||||||
|
| /settings/sites | /v1/site/ | 4 | renew | Showing 1–25 of 27 |
|
||||||
|
| /authors | /v1/author/ | 5 | mar | Showing 1–25 of 31 |
|
||||||
|
|
||||||
|
Scenario Outline: 10 Long labels on mobile
|
||||||
|
Given the viewport is 390 px wide and <route>?page=<page> resolves with X-Total-Count <total>
|
||||||
|
Then the summary "<summary>" and the page-size select share one row and wrap onto two if needed
|
||||||
|
And Previous, the label "<label>" and Next stay on one row, the two buttons flex and the label never wraps
|
||||||
|
And totals above 999 use thousands separators in both labels
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| route | page | total | summary | label |
|
||||||
|
| /settings/sites | 6 | 312 | Showing 126–150 of 312 | Page 6 of 13 |
|
||||||
|
| /authors | 12 | 1284 | Showing 276–300 of 1,284 | Page 12 of 52 |
|
||||||
|
|
||||||
|
Rule: URL rules
|
||||||
|
|
||||||
|
Scenario Outline: Invalid values fall back silently
|
||||||
|
When the user navigates to <route>?size=30&page=abc
|
||||||
|
Then <path>?page=1&size=25 is requested and the URL is replaced by <route>
|
||||||
|
And no notice is shown
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| route | path |
|
||||||
|
| /settings/sites | /v1/site/ |
|
||||||
|
| /authors | /v1/author/ |
|
||||||
|
|
||||||
|
Scenario Outline: Page 0 and negative pages fall back
|
||||||
|
When the user navigates to <route>?page=<page>
|
||||||
|
Then page 1 is requested and the URL is replaced by <route>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| route | page |
|
||||||
|
| /settings/sites | 0 |
|
||||||
|
| /settings/sites | -2 |
|
||||||
|
| /authors | 0 |
|
||||||
|
| /authors | -2 |
|
||||||
|
|
||||||
|
Scenario Outline: A page out of range goes to the last page with a notice
|
||||||
|
Given <path>?page=99&size=25 resolves 200 with [] and X-Total-Count <total>
|
||||||
|
When the user navigates to <route>?page=99
|
||||||
|
Then the URL is replaced (no history entry) by <route>?page=<last> and page <last> is requested
|
||||||
|
And the info notice reads "That page doesn't exist. Showing the last page."
|
||||||
|
And the last-page state is shown
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| route | path | total | last |
|
||||||
|
| /settings/sites | /v1/site/ | 61 | 3 |
|
||||||
|
| /authors | /v1/author/ | 128 | 6 |
|
||||||
|
|
||||||
|
Scenario Outline: A page out of range with a total of 0 shows the empty state
|
||||||
|
Given <path>?page=5&size=25 resolves 200 with [] and X-Total-Count 0
|
||||||
|
When the user navigates to <route>?page=5
|
||||||
|
Then the empty state shows, there is no redirect, no notice, and the URL is <route>
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| route | path |
|
||||||
|
| /settings/sites | /v1/site/ |
|
||||||
|
| /authors | /v1/author/ |
|
||||||
|
|
||||||
|
Scenario Outline: Combined q and page in the URL
|
||||||
|
When the user navigates to <route>?q=<q>&page=2
|
||||||
|
Then <path>?q=<q>&page=2&size=25 is requested and the search field holds "<q>"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| route | path | q |
|
||||||
|
| /settings/sites | /v1/site/ | renew |
|
||||||
|
| /authors | /v1/author/ | mar |
|
||||||
|
|
||||||
|
Scenario Outline: Browser Back returns to the previous page of results
|
||||||
|
Given the user went from <route> to <route>?page=2 with Next
|
||||||
|
When the user uses Browser Back
|
||||||
|
Then the URL is <route> and page 1 is requested
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| route |
|
||||||
|
| /settings/sites |
|
||||||
|
| /authors |
|
||||||
|
|
||||||
|
Rule: Sites search
|
||||||
|
|
||||||
|
Scenario: The Sites search field
|
||||||
|
Given the owner is on /settings/sites
|
||||||
|
Then a search field with the placeholder "Search by name or key" is shown above the list
|
||||||
|
When the owner types "renew" and the debounce elapses
|
||||||
|
Then GET /v1/site/?q=renew&page=1&size=25 is requested and the list shows the matches
|
||||||
|
|
||||||
|
Scenario: A Sites search that matches the default site only shows what the API returns
|
||||||
|
Given GET /v1/site/?q=journal&page=1&size=25 resolves 200 with only "Journal" and X-Total-Count 1
|
||||||
|
When the owner searches "journal"
|
||||||
|
Then only "Journal" is listed and the footer shows "1 site"
|
||||||
|
|
||||||
|
Rule: Existing behaviour that must not regress
|
||||||
|
|
||||||
|
Scenario Outline: The unpaged site list still feeds the dependent features
|
||||||
|
Given the paged list shows page 2 of <total> sites
|
||||||
|
When the owner opens ?edit=<key>, blurs a duplicate key in the add form, or opens the delete dialog
|
||||||
|
Then GET /v1/site/ is requested without page and size and the result is used, not the paged rows
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| total | key |
|
||||||
|
| 61 | journal |
|
||||||
|
|
||||||
|
Scenario Outline: Read-only roles see the same Authors pager
|
||||||
|
Given the session role is "<role>"
|
||||||
|
When the user navigates to /authors?page=2
|
||||||
|
Then the same footer, summary and controls are shown, with no Add author button and no row menu
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| role |
|
||||||
|
| member |
|
||||||
|
|
||||||
|
Scenario Outline: Owner and admin see the same Authors pager with the management actions
|
||||||
|
Given the session role is "<role>"
|
||||||
|
When the user navigates to /authors?page=2
|
||||||
|
Then the same footer, summary and controls are shown, with the Add author button and the row menu
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| role |
|
||||||
|
| owner |
|
||||||
|
| admin |
|
||||||
|
|
||||||
|
Scenario: Deleting the last row of the last page
|
||||||
|
Given the user is on the last page of /authors with a single row and deletes it (204)
|
||||||
|
Then the previous page is requested and the URL is replaced by it
|
||||||
|
|
||||||
|
Scenario: Adding or deleting refreshes the total
|
||||||
|
Given a list of 25 rows (total 25, count line only)
|
||||||
|
When a row is added and the list reloads with total 26
|
||||||
|
Then the footer shows the pager and the summary "Showing 1–25 of 26"
|
||||||
|
|
||||||
|
Rule: Design conformance of the visual gate
|
||||||
|
|
||||||
|
Scenario: The Create author mobile states pass at the default budget
|
||||||
|
Given the editor states "Create author: dialog", "Create author: for me", "Create author: slug taken" and "Create author: failed" at 390x844
|
||||||
|
Then each capture is within 10 % of the board frame, with no per-state exception in web/visual/states.ts
|
||||||
|
|
||||||
|
Scenario: Integration "6. Errors" passes at the default budget
|
||||||
|
Given the board frame of Integration "6. Errors" is drawn with the 403 note
|
||||||
|
Then every capture of "6. Errors" is within 10 %, desktop and mobile, light and dark, with no maxPercent exception
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# QA: Sites and Authors list pagination (SPA)
|
||||||
|
|
||||||
|
SPA slice only. The Go API (page, size, q, X-Total-Count) already exists (features/sites_backend.feature, features/authors_backend.feature), so no new Go test is requested. If a contract below turns out wrong, report it as a backend defect. Un-run web tests are NOT RUN, never passing.
|
||||||
|
|
||||||
|
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||||
|
|
||||||
|
Mock `useApi` for GET `/v1/site/?...` and `/v1/author/?...` (the route key includes the query) per case, returning the body and the X-Total-Count header through the new headers-aware helper. Mount SitesView (owner session) and AuthorsView (owner, admin and member sessions) inside DashboardShell with a test router at /settings/sites and /authors, as in features/sites_authors_screens.qa.md. Fixtures: 61 sites at 25 per page (3 pages) and 128 authors at 25 per page (6 pages), built by helpers. 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. One test per scenario, Sites and Authors in the same file through `it.each`.
|
||||||
|
|
||||||
|
1. First page: no page/size in the URL, requests carry page=1&size=25, "Showing 1–25 of 61" and "Showing 1–25 of 128", First and Previous aria-disabled, page 1 aria-current, total read from the header (body length ignored).
|
||||||
|
2. Middle page (?page=2 on Sites, ?page=3 on Authors): summaries, aria-current on the page, every control enabled.
|
||||||
|
3. Last page (?page=3, ?page=6): "Showing 51–61 of 61" and "Showing 126–128 of 128", Next and Last disabled.
|
||||||
|
4. Single page: total 4 sites and 6 authors render the count line "4 sites" / "6 authors" only; no nav, no select, no "Showing" text. 4b: "1 site" and "1 author". 4c: size=5 with total 6 renders the full pager ("Page 1 of 2").
|
||||||
|
5. Page size menu: 5/25/50/100 -> request page=1&size=N, URL after the response (/settings/sites?size=5, no size for 25); ArrowDown opens, Enter selects, Esc closes and returns focus.
|
||||||
|
6. Loading a page: unresolved promise -> skeleton rows (8 desktop, 6 below md), aria-busy, pager 40 % and disabled, "Loading page 3…" / "Loading page 4…", page number unchanged, URL unchanged before the response; resolve -> rows and URL.
|
||||||
|
7. Error loading a page (500 and network rejection): rows and URL kept, "Couldn't load page 3. Showing page 2." (Authors: page 4/3), "Check your connection and try again.", Retry resends page N and success clears the error and sets the URL. A first-load failure still shows "Couldn't load sites" / "Couldn't load authors" with "Try again".
|
||||||
|
8. Empty after search: "No sites match "blog"" / "No authors match "zoe"", "Clear search" goes back to the bare route and page 1, no pager, summary or count line.
|
||||||
|
9. Search resets to page 1: from ?page=4 (Sites) and ?page=5 (Authors) type the term (fake timers past the debounce) -> request q=...&page=1, URL /settings/sites?q=renew and /authors?q=mar after the response, "Showing 1–25 of 27" and "Showing 1–25 of 31". A size change resets the page too.
|
||||||
|
10. Long labels on mobile: 312 sites page 6 and 1,284 authors page 12 -> "Showing 126–150 of 312", "Page 6 of 13", "Showing 276–300 of 1,284", "Page 12 of 52" with the thousands separator; label has no wrap class and Previous/Next carry the flex classes (the pixel layout is the visual check).
|
||||||
|
11. URL rules: ?size=30&page=abc, ?page=0 and ?page=-2 -> default request and router.replace (assert no extra history entry and no notice). Out of range ?page=99 with total 61 / 128 -> router.replace to the last page, the second request for it and the notice "That page doesn't exist. Showing the last page."; with total 0 -> empty state, no redirect, no notice. ?q=renew&page=2 loads with the field filled. Browser Back returns to page 1.
|
||||||
|
12. Sites search field: placeholder "Search by name or key" with its French string; q is sent, encoded; matches the API contract of features/sites_backend.feature.
|
||||||
|
13. Unpaged site list for dependents: with page 2 shown, ?edit=journal, the duplicate-key blur and the ReassignDialog destinations call GET /v1/site/ with no page and no size and never use the paged rows.
|
||||||
|
14. Roles: member on /authors?page=2 sees the same footer with no Add author button and no row menu; owner and admin see the same footer with the Add author button and the row menu.
|
||||||
|
15. Delete the last row of the last page reloads the previous page and replaces the URL; adding the 26th item turns the count line into the pager.
|
||||||
|
16. Authors regression: the old threshold behaviour (pager above 25 only) is gone; a test asserts the count line instead of the pager when total <= size.
|
||||||
|
17. useApi: a unit test for the new headers-aware helper (header read, missing header treated as a failed load, 401/403/429 behaviour unchanged, `request` untouched).
|
||||||
|
18. i18n: the new strings have a French entry ("Rechercher par nom ou clé", "{0} sites", "1 site", "{0} auteurs", "1 auteur", 'Aucun site ne correspond à « {0} »'); the existing key-parity spec in web/src/i18n/ must stay green.
|
||||||
|
19. Existing specs amended (list them in the diff): SitesView and AuthorsView specs and their test kits in web/src/views/__tests__/directoryTestKit.ts (the list mock now answers a page and a total through the header), and the sites_authors_screens specs whose assumptions about the loaded list change (item 14 duplicate-key check, the deep link).
|
||||||
|
|
||||||
|
## Visual (`task visual:check -- sites`, `-- authors`, `-- editor`, `-- integration`)
|
||||||
|
|
||||||
|
Run all four. The gate is hard: every capture within 10 % at 1440x900 and 390x844, in light and dark. Add no new exception. A failure, a missing setup or an unmocked /v1 call is listed for the user and not hidden.
|
||||||
|
|
||||||
|
Sites and Authors: add one setup per state in `web/visual/states.ts`, reusing the board's own sample data from the groups "Sites: pagination" and "Authors: pagination" in `docs/design/source/RenewCMS Screens Back-office.dc.html` (generated rows, totals 61/128, summaries, page labels). Take layout from the frame markup. States for each of the two screens: First page, Middle page, Last page, Single page, Page size menu, Loading a page, Error loading a page, Empty after search, Search resets to page 1, Long labels on mobile (10 each, 20 in total). None of them show a date or time, so none is `skip: 'clock-sensitive'`. The page-size menu opens upward. Mock `GET /v1/site/?...` and `/v1/author/?...` with the right X-Total-Count; the Loading state keeps its request pending.
|
||||||
|
|
||||||
|
Existing Sites and Authors states ("Default", "Only the default site", loading, error, row menu, forms, delete, read-only) are re-run: the count line now appears under every loaded list, so some may exceed 10 %. Report any that do, with the diff percentage, to the user.
|
||||||
|
|
||||||
|
Editor: remove the `withMobileException` wrapper and its `boardOmitsMobileDialogReason` constant from the four "Create author" states (dialog, for me, slug taken, failed) now that the board draws the mobile dialog; update the setups only where needed to match the frame, and do not hide the dialog. If a mobile capture still fails, list it.
|
||||||
|
|
||||||
|
Integration: remove the `maxPercent: { desktop: 16, mobile: 22 }` exception from "6. Errors" (the board now draws the 403 note). Remove no other Integration exception unless the user names it.
|
||||||
|
|
||||||
|
## Manual
|
||||||
|
|
||||||
|
- Keyboard order and focus return: Tab to the page-size select, then First, Previous, each page, Next, Last; Enter and Space; ArrowDown, Enter, Esc; the 2 px --ui-primary focus ring with a 2 px offset on every control.
|
||||||
|
- Hover, motion and the reduced-motion behaviour of the skeleton pulse.
|
||||||
|
- Real round trip with `task dev`: seed more than 25 sites and more than 25 authors; page, size and q survive a reload; browser Back after a size change; the footer count line on a single page.
|
||||||
|
- Out-of-range fallback against a real shrinking list (delete rows in another tab).
|
||||||
|
- A small real list: owner and non-owner views of /authors keep the same footer.
|
||||||
@@ -4,7 +4,7 @@ import { t, formatNumber } from '@/i18n'
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useMediaQuery } from '@vueuse/core'
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
import { pageItems, rangeFor } from '@/lib/pagination'
|
import { pageItems, rangeFor } from '@/lib/pagination'
|
||||||
import { ARTICLE_PAGE_SIZES } from '@/lib/articleListQuery'
|
import PageSizeSelect from '@/components/PageSizeSelect.vue'
|
||||||
|
|
||||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ const props = defineProps<{
|
|||||||
total: number
|
total: number
|
||||||
size: number
|
size: number
|
||||||
loadingPage: number | null
|
loadingPage: number | null
|
||||||
|
singlePageLabel?: string
|
||||||
|
flush?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -27,6 +29,7 @@ const busy = computed(() => props.loadingPage !== null)
|
|||||||
const atStart = computed(() => busy.value || props.page <= 1)
|
const atStart = computed(() => busy.value || props.page <= 1)
|
||||||
const atEnd = computed(() => busy.value || props.page >= props.pageCount)
|
const atEnd = computed(() => busy.value || props.page >= props.pageCount)
|
||||||
const showControls = computed(() => props.pageCount > 1)
|
const showControls = computed(() => props.pageCount > 1)
|
||||||
|
const countOnly = computed(() => props.singlePageLabel !== undefined && props.pageCount <= 1)
|
||||||
const items = computed(() => pageItems(props.page, props.pageCount))
|
const items = computed(() => pageItems(props.page, props.pageCount))
|
||||||
|
|
||||||
const summary = computed(() => {
|
const summary = computed(() => {
|
||||||
@@ -41,13 +44,6 @@ const summary = computed(() => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
const sizeItems = computed(() =>
|
|
||||||
ARTICLE_PAGE_SIZES.map((value) => ({
|
|
||||||
label: t('{0} per page', [value]),
|
|
||||||
value,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const FOCUS_RING =
|
const FOCUS_RING =
|
||||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
|
||||||
const CONTROL = `justify-center rounded-none border border-accented bg-default p-0 text-highlighted ring-0 disabled:opacity-40 aria-disabled:opacity-40 ${FOCUS_RING}`
|
const CONTROL = `justify-center rounded-none border border-accented bg-default p-0 text-highlighted ring-0 disabled:opacity-40 aria-disabled:opacity-40 ${FOCUS_RING}`
|
||||||
@@ -61,30 +57,25 @@ function numberClass(number: number) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="isDesktop" data-testid="paginator" class="flex items-center gap-4 pt-3">
|
<p
|
||||||
|
v-if="countOnly"
|
||||||
|
data-testid="paginator-count"
|
||||||
|
class="text-muted max-md:pb-4"
|
||||||
|
:class="{ 'pt-3': !flush }"
|
||||||
|
>
|
||||||
|
{{ singlePageLabel }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="isDesktop"
|
||||||
|
data-testid="paginator"
|
||||||
|
class="flex items-center gap-4"
|
||||||
|
:class="{ 'pt-3': !flush }"
|
||||||
|
>
|
||||||
<span aria-live="polite" data-testid="paginator-summary" class="flex-1 text-muted">{{
|
<span aria-live="polite" data-testid="paginator-summary" class="flex-1 text-muted">{{
|
||||||
summary
|
summary
|
||||||
}}</span>
|
}}</span>
|
||||||
<USelectMenu
|
<PageSizeSelect :size="size" :disabled="busy" @update:size="emit('update:size', $event)" />
|
||||||
:model-value="size"
|
|
||||||
:items="sizeItems"
|
|
||||||
value-key="value"
|
|
||||||
:search-input="false"
|
|
||||||
:disabled="busy"
|
|
||||||
:aria-label="t('Rows per page')"
|
|
||||||
:content="{ side: 'top', align: 'start' }"
|
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
class="w-30"
|
|
||||||
:ui="{
|
|
||||||
base: `h-8 rounded-none border border-accented bg-default pl-3 pr-2 text-sm ring-0 focus-visible:ring-0 ${FOCUS_RING}`,
|
|
||||||
trailingIcon: 'size-3.5 text-muted',
|
|
||||||
content: 'w-40 rounded-none',
|
|
||||||
itemTrailingIcon: 'hidden',
|
|
||||||
item: 'h-8 rounded-none data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand)',
|
|
||||||
}"
|
|
||||||
@update:model-value="emit('update:size', $event as number)"
|
|
||||||
/>
|
|
||||||
<nav v-if="showControls" :aria-label="t('Pagination')" class="flex items-center gap-1">
|
<nav v-if="showControls" :aria-label="t('Pagination')" class="flex items-center gap-1">
|
||||||
<UButton
|
<UButton
|
||||||
color="neutral"
|
color="neutral"
|
||||||
@@ -144,30 +135,21 @@ function numberClass(number: number) {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else data-testid="paginator" class="flex flex-col gap-3 pt-3 pb-4">
|
<div
|
||||||
|
v-else
|
||||||
|
data-testid="paginator"
|
||||||
|
class="flex flex-col gap-3 pb-4"
|
||||||
|
:class="{ 'pt-3': !flush }"
|
||||||
|
>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<span aria-live="polite" data-testid="paginator-summary" class="text-muted">{{
|
<span aria-live="polite" data-testid="paginator-summary" class="text-muted">{{
|
||||||
summary
|
summary
|
||||||
}}</span>
|
}}</span>
|
||||||
<USelectMenu
|
<PageSizeSelect
|
||||||
:model-value="size"
|
:size="size"
|
||||||
:items="sizeItems"
|
|
||||||
value-key="value"
|
|
||||||
:search-input="false"
|
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
:aria-label="t('Rows per page')"
|
touch
|
||||||
:content="{ side: 'top', align: 'end' }"
|
@update:size="emit('update:size', $event)"
|
||||||
color="neutral"
|
|
||||||
variant="outline"
|
|
||||||
class="w-30"
|
|
||||||
:ui="{
|
|
||||||
base: `h-11 rounded-none border border-accented bg-default pl-3 pr-2 text-sm ring-0 focus-visible:ring-0 ${FOCUS_RING}`,
|
|
||||||
trailingIcon: 'size-3.5 text-muted',
|
|
||||||
content: 'w-40 rounded-none',
|
|
||||||
itemTrailingIcon: 'hidden',
|
|
||||||
item: 'h-11 rounded-none data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand)',
|
|
||||||
}"
|
|
||||||
@update:model-value="emit('update:size', $event as number)"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<nav v-if="showControls" :aria-label="t('Pagination')" class="flex items-center gap-2">
|
<nav v-if="showControls" :aria-label="t('Pagination')" class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
defineProps<{ title: string }>()
|
||||||
|
|
||||||
|
defineEmits<{ retry: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UAlert
|
||||||
|
role="alert"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
orientation="vertical"
|
||||||
|
:close="false"
|
||||||
|
icon="i-lucide-circle-x"
|
||||||
|
:title="title"
|
||||||
|
: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="$emit('retry')"
|
||||||
|
>
|
||||||
|
{{ t('Try again') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
defineProps<{ title: string }>()
|
||||||
|
|
||||||
|
defineEmits<{ clear: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UEmpty
|
||||||
|
variant="naked"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
:title="title"
|
||||||
|
: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="$emit('clear')"
|
||||||
|
>
|
||||||
|
{{ t('Clear search') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UEmpty>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import type { DirectoryNotice } from '@/composables/useDirectoryPages'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
notice: DirectoryNotice
|
||||||
|
shownPage: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
retry: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UAlert
|
||||||
|
v-if="notice.kind === 'error'"
|
||||||
|
role="alert"
|
||||||
|
color="error"
|
||||||
|
variant="soft"
|
||||||
|
orientation="vertical"
|
||||||
|
:close="false"
|
||||||
|
icon="i-lucide-circle-x"
|
||||||
|
:title="t('Couldn\'t load page {0}. Showing page {1}.', [notice.query.page, shownPage])"
|
||||||
|
:description="t('Check your connection and try again.')"
|
||||||
|
:ui="{ actions: 'mt-2 flex-col items-stretch' }"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-rotate-cw"
|
||||||
|
class="h-11 w-full justify-center 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('Retry') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
<UAlert
|
||||||
|
v-else
|
||||||
|
role="status"
|
||||||
|
color="info"
|
||||||
|
variant="soft"
|
||||||
|
orientation="horizontal"
|
||||||
|
:close="false"
|
||||||
|
icon="i-lucide-info"
|
||||||
|
:description="t('That page doesn\'t exist. Showing the last page.')"
|
||||||
|
:ui="{
|
||||||
|
root: 'items-center gap-2.5 px-3.5 py-2.5',
|
||||||
|
icon: 'size-4',
|
||||||
|
description: 'text-toned',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AppPaginator from '@/components/AppPaginator.vue'
|
||||||
|
import DirectoryPageNotice from '@/components/DirectoryPageNotice.vue'
|
||||||
|
import type { DirectoryNotice } from '@/composables/useDirectoryPages'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
notice: DirectoryNotice | null
|
||||||
|
showPaginator: boolean
|
||||||
|
shownPage: number
|
||||||
|
pageCount: number
|
||||||
|
total: number
|
||||||
|
size: number
|
||||||
|
loadingPage: number | null
|
||||||
|
singlePageLabel: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
retry: []
|
||||||
|
'update:page': [page: number]
|
||||||
|
'update:size': [size: number]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="notice?.kind === 'error' || showPaginator" class="flex flex-col gap-3">
|
||||||
|
<DirectoryPageNotice
|
||||||
|
v-if="notice?.kind === 'error'"
|
||||||
|
:notice="notice"
|
||||||
|
:shown-page="shownPage"
|
||||||
|
@retry="$emit('retry')"
|
||||||
|
/>
|
||||||
|
<AppPaginator
|
||||||
|
v-if="showPaginator"
|
||||||
|
:page="shownPage"
|
||||||
|
:page-count="pageCount"
|
||||||
|
:total="total"
|
||||||
|
:size="size"
|
||||||
|
:loading-page="loadingPage"
|
||||||
|
:single-page-label="singlePageLabel"
|
||||||
|
flush
|
||||||
|
@update:page="$emit('update:page', $event)"
|
||||||
|
@update:size="$emit('update:size', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { PAGE_SIZES } from '@/lib/pageQuery'
|
||||||
|
|
||||||
|
const FOCUS_RING =
|
||||||
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
size: number
|
||||||
|
disabled: boolean
|
||||||
|
touch?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:size': [size: number] }>()
|
||||||
|
|
||||||
|
const sizeItems = computed(() =>
|
||||||
|
PAGE_SIZES.map((value) => ({
|
||||||
|
label: t('{0} per page', [value]),
|
||||||
|
value,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const height = computed(() => (props.touch ? 'h-11' : 'h-8'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<USelectMenu
|
||||||
|
:model-value="size"
|
||||||
|
:items="sizeItems"
|
||||||
|
value-key="value"
|
||||||
|
:search-input="false"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-label="t('Rows per page')"
|
||||||
|
:content="{ side: 'top', align: touch ? 'end' : 'start' }"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
class="w-30"
|
||||||
|
:ui="{
|
||||||
|
base: `${height} rounded-none border border-accented bg-default pl-3 pr-2 text-sm ring-0 focus-visible:ring-0 disabled:opacity-40 ${FOCUS_RING}`,
|
||||||
|
trailingIcon: 'size-3.5 text-muted',
|
||||||
|
content: 'w-40 rounded-none',
|
||||||
|
itemTrailingIcon: 'hidden',
|
||||||
|
item: `${height} rounded-none data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand)`,
|
||||||
|
}"
|
||||||
|
@update:model-value="emit('update:size', $event as number)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -2,9 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { defineComponent, h } from 'vue'
|
import { defineComponent, h } from 'vue'
|
||||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { ApiError } from '@/composables/useApi'
|
|
||||||
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||||
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
|
|
||||||
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
|
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
|
||||||
import { useDirectoryView } from '@/composables/useDirectoryView'
|
import { useDirectoryView } from '@/composables/useDirectoryView'
|
||||||
import { useSlugField } from '@/composables/useSlugField'
|
import { useSlugField } from '@/composables/useSlugField'
|
||||||
@@ -56,72 +54,6 @@ describe('useDelayedSkeleton', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
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', () => {
|
describe('useDirectoryDelete', () => {
|
||||||
it('runs once, clears the notice and reports failures to the handler', async () => {
|
it('runs once, clears the notice and reports failures to the handler', async () => {
|
||||||
const removal = useDirectoryDelete<{ id: number }>()
|
const removal = useDirectoryDelete<{ id: number }>()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { flushPromises } from '@vue/test-utils'
|
|||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
|
import { ApiError, MissingTotalError, NetworkError, isOffline, useApi } from '@/composables/useApi'
|
||||||
|
|
||||||
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -273,3 +273,51 @@ describe('useApi abort and blob handling', () => {
|
|||||||
expect(router.currentRoute.value.query.reason).toBe('expired')
|
expect(router.currentRoute.value.query.reason).toBe('expired')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('useApi requestPaged', () => {
|
||||||
|
it('returns the body and the total read from X-Total-Count', async () => {
|
||||||
|
fetchMock.mockResolvedValue(mockResponse(200, [{ id: 1 }], { 'X-Total-Count': '61' }))
|
||||||
|
|
||||||
|
const result = await useApi().requestPaged('/v1/site/?page=1&size=25')
|
||||||
|
|
||||||
|
expect(result).toEqual({ body: [{ id: 1 }], total: 61 })
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'/v1/site/?page=1&size=25',
|
||||||
|
expect.objectContaining({ credentials: 'include' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([[null], ['abc'], ['-1']])(
|
||||||
|
'treats the X-Total-Count header %s as a failed load',
|
||||||
|
async (header) => {
|
||||||
|
const headers = header === null ? {} : { 'X-Total-Count': header }
|
||||||
|
fetchMock.mockResolvedValue(mockResponse(200, [], headers))
|
||||||
|
|
||||||
|
await expect(useApi().requestPaged('/v1/site/')).rejects.toBeInstanceOf(
|
||||||
|
MissingTotalError,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('keeps the 401, 403 and 429 behaviour of request', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(mockResponse(403))
|
||||||
|
await expectApiError(useApi().requestPaged('/v1/site/', { onForbidden: 'passthrough' }), {
|
||||||
|
status: 403,
|
||||||
|
})
|
||||||
|
fetchMock.mockResolvedValueOnce(mockResponse(429, null, { 'Retry-After': '7' }))
|
||||||
|
await expectApiError(useApi().requestPaged('/v1/site/'), {
|
||||||
|
status: 429,
|
||||||
|
retryAfter: 7,
|
||||||
|
})
|
||||||
|
fetchMock.mockResolvedValueOnce(mockResponse(401))
|
||||||
|
await expectApiError(useApi().requestPaged('/v1/site/'), { status: 401 })
|
||||||
|
await settle()
|
||||||
|
expect(router.currentRoute.value.path).toBe('/login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves request returning the body only', async () => {
|
||||||
|
fetchMock.mockResolvedValue(mockResponse(200, [{ id: 1 }], { 'X-Total-Count': '61' }))
|
||||||
|
|
||||||
|
await expect(useApi().request('/v1/site/')).resolves.toEqual([{ id: 1 }])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -21,6 +21,17 @@ export class NetworkError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MissingTotalError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Response carries no X-Total-Count')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagedResponse<T> {
|
||||||
|
body: T
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
export type UserRole = 'owner' | 'admin'
|
export type UserRole = 'owner' | 'admin'
|
||||||
|
|
||||||
export interface MeAuthor {
|
export interface MeAuthor {
|
||||||
@@ -62,14 +73,17 @@ function syncOfflineFlagWithNavigator() {
|
|||||||
isOffline.value = !isNavigatorOnLine()
|
isOffline.value = !isNavigatorOnLine()
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRetryAfterSeconds(response: Response): number | undefined {
|
function parseCount(header: string | null): number | undefined {
|
||||||
const header = response.headers.get('Retry-After')
|
|
||||||
if (header === null || !/^\d+$/.test(header)) {
|
if (header === null || !/^\d+$/.test(header)) {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
return Number.parseInt(header, 10)
|
return Number.parseInt(header, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseRetryAfterSeconds(response: Response): number | undefined {
|
||||||
|
return parseCount(response.headers.get('Retry-After'))
|
||||||
|
}
|
||||||
|
|
||||||
async function readErrorBody(response: Response): Promise<string | undefined> {
|
async function readErrorBody(response: Response): Promise<string | undefined> {
|
||||||
if (typeof response.text !== 'function') {
|
if (typeof response.text !== 'function') {
|
||||||
return undefined
|
return undefined
|
||||||
@@ -159,6 +173,18 @@ export function useApi() {
|
|||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestPaged<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestOptions = {},
|
||||||
|
): Promise<PagedResponse<T>> {
|
||||||
|
const response = await send(path, options)
|
||||||
|
const total = parseCount(response.headers.get('X-Total-Count'))
|
||||||
|
if (total === undefined) {
|
||||||
|
throw new MissingTotalError()
|
||||||
|
}
|
||||||
|
return { body: (await response.json()) as T, total }
|
||||||
|
}
|
||||||
|
|
||||||
async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
|
async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
|
||||||
const response = await send(path, options)
|
const response = await send(path, options)
|
||||||
return response.blob()
|
return response.blob()
|
||||||
@@ -185,5 +211,5 @@ export function useApi() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getMe, request, requestBlob }
|
return { getMe, request, requestPaged, requestBlob }
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
useArticles,
|
useArticles,
|
||||||
type Article,
|
type Article,
|
||||||
@@ -7,27 +7,28 @@ import {
|
|||||||
type ArticleStatus,
|
type ArticleStatus,
|
||||||
} from '@/composables/useArticles'
|
} from '@/composables/useArticles'
|
||||||
import {
|
import {
|
||||||
ARTICLE_PAGE_SIZES,
|
usePagedQuery,
|
||||||
|
useSearchInput,
|
||||||
|
type PagedNotice,
|
||||||
|
type PagedOutcome,
|
||||||
|
type UrlMode,
|
||||||
|
} from '@/composables/usePagedQuery'
|
||||||
|
import {
|
||||||
SEO_FILTER,
|
SEO_FILTER,
|
||||||
nextSort,
|
nextSort,
|
||||||
parseArticleQuery,
|
parseArticleQuery,
|
||||||
sameArticleQuery,
|
sameArticleQuery,
|
||||||
sameRouteQuery,
|
|
||||||
toRouteQuery,
|
toRouteQuery,
|
||||||
type ArticleListQuery,
|
type ArticleListQuery,
|
||||||
type ArticleSortField,
|
type ArticleSortField,
|
||||||
} from '@/lib/articleListQuery'
|
} from '@/lib/articleListQuery'
|
||||||
import { pageCountFor } from '@/lib/pagination'
|
import { PAGE_SIZES } from '@/lib/pageQuery'
|
||||||
import { ARTICLES_PAGE_SIZE_KEY, readStoredPageSize, storePageSize } from '@/lib/pageSizeMemory'
|
import { ARTICLES_PAGE_SIZE_KEY, readStoredPageSize, storePageSize } from '@/lib/pageSizeMemory'
|
||||||
|
|
||||||
const SKELETON_DELAY_MS = 150
|
|
||||||
const SEARCH_DEBOUNCE_MS = 120
|
|
||||||
const ARTICLES_PATH = '/article'
|
const ARTICLES_PATH = '/article'
|
||||||
|
|
||||||
export type UrlMode = 'push' | 'replace'
|
export type { UrlMode }
|
||||||
export type ArticleListNotice =
|
export type ArticleListNotice = PagedNotice<ArticleListQuery>
|
||||||
| { kind: 'range' }
|
|
||||||
| { kind: 'error'; query: ArticleListQuery; mode: UrlMode }
|
|
||||||
|
|
||||||
interface ArticleListOptions {
|
interface ArticleListOptions {
|
||||||
sitesSettled: () => boolean
|
sitesSettled: () => boolean
|
||||||
@@ -36,7 +37,6 @@ interface ArticleListOptions {
|
|||||||
|
|
||||||
export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions) {
|
export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions) {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
|
||||||
const { listArticles } = useArticles()
|
const { listArticles } = useArticles()
|
||||||
|
|
||||||
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
|
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
|
||||||
@@ -44,136 +44,66 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
|
|||||||
if (query.size !== undefined) {
|
if (query.size !== undefined) {
|
||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
const stored = readStoredPageSize(ARTICLES_PAGE_SIZE_KEY, ARTICLE_PAGE_SIZES)
|
const stored = readStoredPageSize(ARTICLES_PAGE_SIZE_KEY, PAGE_SIZES)
|
||||||
return stored === null ? parsed : { ...parsed, size: stored }
|
return stored === null ? parsed : { ...parsed, size: stored }
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = ref<Article[]>([])
|
const items = ref<Article[]>([])
|
||||||
const total = ref(0)
|
|
||||||
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
|
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
|
||||||
const shown = ref<ArticleListQuery>(parseWithStoredSize(route.query))
|
|
||||||
const desired = ref<ArticleListQuery>(shown.value)
|
|
||||||
const hasLoaded = ref(false)
|
|
||||||
const loading = ref(true)
|
|
||||||
const loadFailed = ref(false)
|
const loadFailed = ref(false)
|
||||||
const pendingPage = ref<number | null>(null)
|
|
||||||
const notice = ref<ArticleListNotice | null>(null)
|
|
||||||
const showSkeleton = ref(false)
|
|
||||||
const searchInput = ref(shown.value.q)
|
|
||||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let requestToken = 0
|
|
||||||
let syncedTarget: Record<string, string> | null = null
|
|
||||||
|
|
||||||
const needsAttentionCount = computed(() => counts.value.suggestions ?? null)
|
|
||||||
const settled = computed(() => hasLoaded.value && !loadFailed.value)
|
|
||||||
const skeletonActive = computed(() => loading.value && showSkeleton.value)
|
|
||||||
const showPaginator = computed(() => settled.value && total.value > 0)
|
|
||||||
const pageCount = computed(() => pageCountFor(total.value, shown.value.size))
|
|
||||||
|
|
||||||
function clearSearchTimer() {
|
|
||||||
if (searchDebounceTimer !== null) {
|
|
||||||
clearTimeout(searchDebounceTimer)
|
|
||||||
searchDebounceTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSkeletonTimer() {
|
|
||||||
if (skeletonTimer !== null) {
|
|
||||||
clearTimeout(skeletonTimer)
|
|
||||||
skeletonTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(searchInput, (value) => {
|
|
||||||
clearSearchTimer()
|
|
||||||
searchDebounceTimer = setTimeout(() => changeSearch(value), SEARCH_DEBOUNCE_MS)
|
|
||||||
})
|
|
||||||
|
|
||||||
async function syncUrl(query: ArticleListQuery, mode: UrlMode) {
|
|
||||||
if (route.path !== ARTICLES_PATH) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const target = toRouteQuery(query)
|
|
||||||
if (sameRouteQuery(route.query, target)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
syncedTarget = target
|
|
||||||
await router[mode]({ query: target })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load(
|
|
||||||
query: ArticleListQuery,
|
|
||||||
mode: UrlMode,
|
|
||||||
successNotice: ArticleListNotice | null = null,
|
|
||||||
) {
|
|
||||||
const token = ++requestToken
|
|
||||||
desired.value = query
|
|
||||||
loading.value = true
|
|
||||||
showSkeleton.value = false
|
|
||||||
pendingPage.value = hasLoaded.value ? query.page : null
|
|
||||||
clearSkeletonTimer()
|
|
||||||
skeletonTimer = setTimeout(() => {
|
|
||||||
showSkeleton.value = true
|
|
||||||
}, SKELETON_DELAY_MS)
|
|
||||||
|
|
||||||
|
async function request(query: ArticleListQuery, context: { loaded: boolean }) {
|
||||||
try {
|
try {
|
||||||
const response = await listArticles(query)
|
const response = await listArticles(query)
|
||||||
if (token !== requestToken) {
|
return {
|
||||||
return
|
kind: 'loaded',
|
||||||
}
|
total: response.total,
|
||||||
const lastPage = pageCountFor(response.total, query.size)
|
count: response.items.length,
|
||||||
if (
|
apply: () => {
|
||||||
successNotice === null &&
|
items.value = response.items
|
||||||
response.items.length === 0 &&
|
counts.value = response.counts
|
||||||
response.total > 0 &&
|
loadFailed.value = false
|
||||||
query.page > lastPage
|
},
|
||||||
) {
|
} satisfies PagedOutcome
|
||||||
await load({ ...query, page: lastPage }, 'replace', {
|
|
||||||
kind: 'range',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const effective = {
|
|
||||||
...query,
|
|
||||||
page: response.total === 0 ? 1 : query.page,
|
|
||||||
}
|
|
||||||
items.value = response.items
|
|
||||||
total.value = response.total
|
|
||||||
counts.value = response.counts
|
|
||||||
shown.value = effective
|
|
||||||
desired.value = effective
|
|
||||||
hasLoaded.value = true
|
|
||||||
loadFailed.value = false
|
|
||||||
notice.value = successNotice
|
|
||||||
await syncUrl(effective, mode)
|
|
||||||
} catch {
|
} catch {
|
||||||
if (token !== requestToken) {
|
if (!context.loaded) {
|
||||||
return
|
|
||||||
}
|
|
||||||
if (hasLoaded.value) {
|
|
||||||
notice.value = { kind: 'error', query, mode }
|
|
||||||
} else {
|
|
||||||
items.value = []
|
items.value = []
|
||||||
loadFailed.value = true
|
loadFailed.value = true
|
||||||
}
|
}
|
||||||
} finally {
|
return { kind: 'failed', notice: context.loaded } satisfies PagedOutcome
|
||||||
if (token === requestToken) {
|
|
||||||
loading.value = false
|
|
||||||
pendingPage.value = null
|
|
||||||
clearSkeletonTimer()
|
|
||||||
showSkeleton.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function changePage(page: number) {
|
const paged = usePagedQuery<ArticleListQuery>({
|
||||||
load({ ...desired.value, page }, 'push')
|
path: ARTICLES_PATH,
|
||||||
}
|
initial: parseWithStoredSize(route.query),
|
||||||
|
initialLoading: true,
|
||||||
|
request,
|
||||||
|
parseRoute: (query, ownReplace) => {
|
||||||
|
const parsed = ownReplace ? parseArticleQuery(query) : parseWithStoredSize(query)
|
||||||
|
return sitesSettled() ? usableSite(parsed) : parsed
|
||||||
|
},
|
||||||
|
toRoute: toRouteQuery,
|
||||||
|
same: sameArticleQuery,
|
||||||
|
onRouteQuery: (query) => {
|
||||||
|
search.searchInput.value = query.q
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { shown, desired, hasLoaded, total, load } = paged
|
||||||
|
|
||||||
|
const search = useSearchInput(
|
||||||
|
shown.value.q,
|
||||||
|
() => desired.value.q,
|
||||||
|
(q) => load({ ...desired.value, q, page: 1 }, 'replace'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const needsAttentionCount = computed(() => counts.value.suggestions ?? null)
|
||||||
|
const settled = computed(() => hasLoaded.value && !loadFailed.value)
|
||||||
|
const showPaginator = computed(() => settled.value && total.value > 0)
|
||||||
|
|
||||||
function changeSize(size: number) {
|
function changeSize(size: number) {
|
||||||
storePageSize(ARTICLES_PAGE_SIZE_KEY, size)
|
storePageSize(ARTICLES_PAGE_SIZE_KEY, size)
|
||||||
load({ ...desired.value, size, page: 1 }, 'push')
|
return paged.changeSize(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeStatus(status: '' | ArticleStatus) {
|
function changeStatus(status: '' | ArticleStatus) {
|
||||||
@@ -195,60 +125,9 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
|
|||||||
}
|
}
|
||||||
|
|
||||||
function changeSort(field: ArticleSortField) {
|
function changeSort(field: ArticleSortField) {
|
||||||
load(
|
load({ ...desired.value, sort: nextSort(desired.value.sort, field), page: 1 }, 'push')
|
||||||
{
|
|
||||||
...desired.value,
|
|
||||||
sort: nextSort(desired.value.sort, field),
|
|
||||||
page: 1,
|
|
||||||
},
|
|
||||||
'push',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeSearch(value: string) {
|
|
||||||
const q = value.trim()
|
|
||||||
if (q === desired.value.q) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
load({ ...desired.value, q, page: 1 }, 'replace')
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSearch() {
|
|
||||||
clearSearchTimer()
|
|
||||||
searchInput.value = ''
|
|
||||||
changeSearch('')
|
|
||||||
}
|
|
||||||
|
|
||||||
function retryNotice() {
|
|
||||||
if (notice.value?.kind === 'error') {
|
|
||||||
load(notice.value.query, notice.value.mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.query,
|
|
||||||
() => {
|
|
||||||
if (route.path !== ARTICLES_PATH) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ownReplace = syncedTarget !== null && sameRouteQuery(route.query, syncedTarget)
|
|
||||||
syncedTarget = null
|
|
||||||
const parsed = ownReplace
|
|
||||||
? parseArticleQuery(route.query)
|
|
||||||
: parseWithStoredSize(route.query)
|
|
||||||
const next = sitesSettled() ? usableSite(parsed) : parsed
|
|
||||||
if (hasLoaded.value && sameArticleQuery(next, shown.value)) {
|
|
||||||
syncUrl(shown.value, 'replace')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (sameArticleQuery(next, desired.value) && loading.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
searchInput.value = next.q
|
|
||||||
load(next, 'replace')
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async function start(sitesRequest: Promise<unknown>) {
|
async function start(sitesRequest: Promise<unknown>) {
|
||||||
if (shown.value.site) {
|
if (shown.value.site) {
|
||||||
await sitesRequest
|
await sitesRequest
|
||||||
@@ -259,12 +138,6 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
|
|||||||
load(shown.value, 'replace')
|
load(shown.value, 'replace')
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
requestToken++
|
|
||||||
clearSearchTimer()
|
|
||||||
clearSkeletonTimer()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
total,
|
total,
|
||||||
@@ -272,27 +145,27 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
|
|||||||
shown,
|
shown,
|
||||||
desired,
|
desired,
|
||||||
hasLoaded,
|
hasLoaded,
|
||||||
loading,
|
loading: paged.loading,
|
||||||
loadFailed,
|
loadFailed,
|
||||||
pendingPage,
|
pendingPage: paged.pendingPage,
|
||||||
notice,
|
notice: paged.notice,
|
||||||
showSkeleton,
|
showSkeleton: paged.showSkeleton,
|
||||||
searchInput,
|
searchInput: search.searchInput,
|
||||||
needsAttentionCount,
|
needsAttentionCount,
|
||||||
settled,
|
settled,
|
||||||
skeletonActive,
|
skeletonActive: paged.skeletonActive,
|
||||||
showPaginator,
|
showPaginator,
|
||||||
pageCount,
|
pageCount: paged.pageCount,
|
||||||
load,
|
load,
|
||||||
start,
|
start,
|
||||||
changePage,
|
changePage: paged.changePage,
|
||||||
changeSize,
|
changeSize,
|
||||||
changeStatus,
|
changeStatus,
|
||||||
changeSite,
|
changeSite,
|
||||||
changeSeo,
|
changeSeo,
|
||||||
changeSort,
|
changeSort,
|
||||||
changeSearch,
|
changeSearch: search.changeSearch,
|
||||||
clearSearch,
|
clearSearch: search.clearSearch,
|
||||||
retryNotice,
|
retryNotice: paged.retryNotice,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ import { useSites, type Site } from '@/composables/useSites'
|
|||||||
import type { ArticleListQuery } from '@/lib/articleListQuery'
|
import type { ArticleListQuery } from '@/lib/articleListQuery'
|
||||||
|
|
||||||
export function useArticleSites(shownSite: () => string) {
|
export function useArticleSites(shownSite: () => string) {
|
||||||
const { listSites } = useSites()
|
const { listAllSites } = useSites()
|
||||||
const sites = ref<Site[]>([])
|
const sites = ref<Site[]>([])
|
||||||
const sitesSettled = ref(false)
|
const sitesSettled = ref(false)
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export function useArticleSites(shownSite: () => string) {
|
|||||||
|
|
||||||
async function fetchSites() {
|
async function fetchSites() {
|
||||||
try {
|
try {
|
||||||
sites.value = await listSites()
|
sites.value = await listAllSites()
|
||||||
} catch {
|
} catch {
|
||||||
sites.value = []
|
sites.value = []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useUsers } from '@/composables/useUsers'
|
import { useUsers } from '@/composables/useUsers'
|
||||||
import { reassignQuery } from '@/lib/directory'
|
import { reassignQuery } from '@/lib/directory'
|
||||||
import { jsonInit } from '@/lib/directoryRequest'
|
import { directoryListPath, jsonInit } from '@/lib/directoryRequest'
|
||||||
import type { LinkableUser } from '@/lib/directoryTypes'
|
import type { LinkableUser } from '@/lib/directoryTypes'
|
||||||
import type { SocialType } from '@/lib/socialLinks'
|
import type { SocialType } from '@/lib/socialLinks'
|
||||||
|
|
||||||
@@ -64,22 +64,22 @@ export type ReassignTarget = number | 'none' | undefined
|
|||||||
const OPTION_PAGE_SIZE = 100
|
const OPTION_PAGE_SIZE = 100
|
||||||
|
|
||||||
export function authorListPath(query: AuthorListQuery) {
|
export function authorListPath(query: AuthorListQuery) {
|
||||||
const parts = [
|
return directoryListPath('/v1/author/', query)
|
||||||
...(query.q ? [`q=${encodeURIComponent(query.q)}`] : []),
|
|
||||||
`page=${query.page}`,
|
|
||||||
`size=${query.size}`,
|
|
||||||
]
|
|
||||||
return `/v1/author/?${parts.join('&')}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuthors() {
|
export function useAuthors() {
|
||||||
const { request } = useApi()
|
const { request, requestPaged } = useApi()
|
||||||
const { listLinkableUsers: listUsersWithoutAuthor } = useUsers()
|
const { listLinkableUsers: listUsersWithoutAuthor } = useUsers()
|
||||||
|
|
||||||
function listAuthors(query: AuthorListQuery): Promise<AuthorPage> {
|
function listAuthors(query: AuthorListQuery): Promise<AuthorPage> {
|
||||||
return request<AuthorPage>(authorListPath(query))
|
return request<AuthorPage>(authorListPath(query))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listAuthorPage(query: AuthorListQuery) {
|
||||||
|
const { body, total } = await requestPaged<AuthorPage>(authorListPath(query))
|
||||||
|
return { items: body.items, total }
|
||||||
|
}
|
||||||
|
|
||||||
async function listAllAuthors(): Promise<Author[]> {
|
async function listAllAuthors(): Promise<Author[]> {
|
||||||
const all: Author[] = []
|
const all: Author[] = []
|
||||||
let page = 1
|
let page = 1
|
||||||
@@ -125,6 +125,7 @@ export function useAuthors() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
listAuthors,
|
listAuthors,
|
||||||
|
listAuthorPage,
|
||||||
listAllAuthors,
|
listAllAuthors,
|
||||||
listLinkableUsers,
|
listLinkableUsers,
|
||||||
createAuthor,
|
createAuthor,
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
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,46 @@
|
|||||||
|
import { computed, type ComputedRef, type Ref } from 'vue'
|
||||||
|
|
||||||
|
const PAGE_SKELETON_ROWS_DESKTOP = 8
|
||||||
|
const PAGE_SKELETON_ROWS_MOBILE = 6
|
||||||
|
|
||||||
|
interface DirectoryListStateSource {
|
||||||
|
total: Ref<number>
|
||||||
|
shown: Ref<{ q: string }>
|
||||||
|
hasLoaded: Ref<boolean>
|
||||||
|
showSkeleton: Ref<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectoryListStateOptions {
|
||||||
|
list: DirectoryListStateSource
|
||||||
|
loadState: ComputedRef<'loading' | 'ready' | 'error' | 'forbidden'>
|
||||||
|
isDesktop: Ref<boolean>
|
||||||
|
initialRows: number
|
||||||
|
singleLabel: (total: number) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDirectoryListState({
|
||||||
|
list,
|
||||||
|
loadState,
|
||||||
|
isDesktop,
|
||||||
|
initialRows,
|
||||||
|
singleLabel,
|
||||||
|
}: DirectoryListStateOptions) {
|
||||||
|
const noResults = computed(
|
||||||
|
() => loadState.value === 'ready' && list.total.value === 0 && list.shown.value.q !== '',
|
||||||
|
)
|
||||||
|
const showList = computed(
|
||||||
|
() =>
|
||||||
|
(loadState.value === 'ready' && list.total.value > 0) ||
|
||||||
|
(loadState.value === 'loading' && list.showSkeleton.value),
|
||||||
|
)
|
||||||
|
const skeletonRows = computed(() => {
|
||||||
|
if (!list.hasLoaded.value) {
|
||||||
|
return initialRows
|
||||||
|
}
|
||||||
|
return isDesktop.value ? PAGE_SKELETON_ROWS_DESKTOP : PAGE_SKELETON_ROWS_MOBILE
|
||||||
|
})
|
||||||
|
const showPaginator = computed(() => loadState.value === 'ready' && list.total.value > 0)
|
||||||
|
const singlePageLabel = computed(() => singleLabel(list.total.value))
|
||||||
|
|
||||||
|
return { noResults, showList, skeletonRows, showPaginator, singlePageLabel }
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { computed, ref, watch, type Ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import {
|
||||||
|
usePagedQuery,
|
||||||
|
useSearchInput,
|
||||||
|
type PagedNotice,
|
||||||
|
type PagedOutcome,
|
||||||
|
type UrlMode,
|
||||||
|
} from '@/composables/usePagedQuery'
|
||||||
|
import { isSessionError, statusOf } from '@/lib/directoryErrors'
|
||||||
|
import {
|
||||||
|
parseDirectoryQuery,
|
||||||
|
toDirectoryRouteQuery,
|
||||||
|
type DirectoryQuery,
|
||||||
|
} from '@/lib/directoryQuery'
|
||||||
|
import { pageCountFor } from '@/lib/pagination'
|
||||||
|
|
||||||
|
export type { UrlMode }
|
||||||
|
export type DirectoryNotice = PagedNotice<DirectoryQuery>
|
||||||
|
|
||||||
|
export interface DirectoryPage<T> {
|
||||||
|
items: T[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectoryPagesOptions<T> {
|
||||||
|
loadPage: (query: DirectoryQuery) => Promise<DirectoryPage<T>>
|
||||||
|
ready: () => boolean
|
||||||
|
onLoaded?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDirectoryPages<T>(options: DirectoryPagesOptions<T>) {
|
||||||
|
const route = useRoute()
|
||||||
|
const items = ref<T[]>([]) as Ref<T[]>
|
||||||
|
const failure = ref<'none' | 'error' | 'forbidden'>('none')
|
||||||
|
let started = false
|
||||||
|
|
||||||
|
async function request(query: DirectoryQuery, context: { loaded: boolean }) {
|
||||||
|
if (!context.loaded) {
|
||||||
|
failure.value = 'none'
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await options.loadPage(query)
|
||||||
|
return {
|
||||||
|
kind: 'loaded',
|
||||||
|
total: response.total,
|
||||||
|
count: response.items.length,
|
||||||
|
apply: () => {
|
||||||
|
items.value = response.items
|
||||||
|
failure.value = 'none'
|
||||||
|
},
|
||||||
|
} satisfies PagedOutcome
|
||||||
|
} catch (error) {
|
||||||
|
if (isSessionError(error)) {
|
||||||
|
return { kind: 'failed', notice: false } satisfies PagedOutcome
|
||||||
|
}
|
||||||
|
if (!context.loaded) {
|
||||||
|
items.value = []
|
||||||
|
failure.value = statusOf(error) === 403 ? 'forbidden' : 'error'
|
||||||
|
}
|
||||||
|
return { kind: 'failed', notice: context.loaded } satisfies PagedOutcome
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const paged = usePagedQuery<DirectoryQuery>({
|
||||||
|
path: route.path,
|
||||||
|
initial: parseDirectoryQuery(route.query),
|
||||||
|
request,
|
||||||
|
parseRoute: (query) => parseDirectoryQuery(query),
|
||||||
|
toRoute: toDirectoryRouteQuery,
|
||||||
|
same: sameQuery,
|
||||||
|
enabled: () => started,
|
||||||
|
onRouteQuery: (query) => {
|
||||||
|
search.searchInput.value = query.q
|
||||||
|
},
|
||||||
|
onLoaded: options.onLoaded,
|
||||||
|
})
|
||||||
|
const { shown, desired, hasLoaded, total, load } = paged
|
||||||
|
|
||||||
|
const search = useSearchInput(
|
||||||
|
shown.value.q,
|
||||||
|
() => desired.value.q,
|
||||||
|
(q) => load({ ...desired.value, q, page: 1 }, 'replace'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const state = computed(() => {
|
||||||
|
if (failure.value !== 'none') {
|
||||||
|
return failure.value
|
||||||
|
}
|
||||||
|
return hasLoaded.value ? ('ready' as const) : ('loading' as const)
|
||||||
|
})
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
return load(shown.value, 'replace')
|
||||||
|
}
|
||||||
|
|
||||||
|
function retry() {
|
||||||
|
if (paged.notice.value?.kind === 'error') {
|
||||||
|
return paged.retryNotice()
|
||||||
|
}
|
||||||
|
return load(desired.value, 'replace')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeById(id: number, identify: (item: T) => number) {
|
||||||
|
const remaining = items.value.filter((item) => identify(item) !== id)
|
||||||
|
if (remaining.length === items.value.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items.value = remaining
|
||||||
|
total.value = Math.max(0, total.value - 1)
|
||||||
|
if (remaining.length === 0 && total.value > 0) {
|
||||||
|
const page = Math.min(shown.value.page, pageCountFor(total.value, shown.value.size))
|
||||||
|
load({ ...shown.value, page }, 'replace')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
options.ready,
|
||||||
|
(ready) => {
|
||||||
|
if (ready && !started) {
|
||||||
|
started = true
|
||||||
|
load(shown.value, 'replace')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
shown,
|
||||||
|
desired,
|
||||||
|
hasLoaded,
|
||||||
|
loading: paged.loading,
|
||||||
|
state,
|
||||||
|
pendingPage: paged.pendingPage,
|
||||||
|
notice: paged.notice,
|
||||||
|
searchInput: search.searchInput,
|
||||||
|
pageCount: paged.pageCount,
|
||||||
|
showSkeleton: paged.showSkeleton,
|
||||||
|
skeletonActive: paged.skeletonActive,
|
||||||
|
changePage: paged.changePage,
|
||||||
|
changeSize: paged.changeSize,
|
||||||
|
clearSearch: search.clearSearch,
|
||||||
|
reload,
|
||||||
|
retry,
|
||||||
|
removeById,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameQuery(left: DirectoryQuery, right: DirectoryQuery) {
|
||||||
|
return left.q === right.q && left.page === right.page && left.size === right.size
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { computed, onBeforeUnmount, ref, watch, type Ref } from 'vue'
|
||||||
|
import { useRoute, useRouter, type LocationQuery } from 'vue-router'
|
||||||
|
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||||
|
import { pageCountFor } from '@/lib/pagination'
|
||||||
|
import { sameRouteQuery } from '@/lib/pageQuery'
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 120
|
||||||
|
|
||||||
|
export type UrlMode = 'push' | 'replace'
|
||||||
|
|
||||||
|
export interface PagedQuery {
|
||||||
|
q: string
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PagedNotice<Q> = { kind: 'range' } | { kind: 'error'; query: Q; mode: UrlMode }
|
||||||
|
|
||||||
|
export type PagedOutcome =
|
||||||
|
| {
|
||||||
|
kind: 'loaded'
|
||||||
|
total: number
|
||||||
|
count: number
|
||||||
|
page?: number
|
||||||
|
redirected?: boolean
|
||||||
|
apply?: () => void
|
||||||
|
}
|
||||||
|
| { kind: 'failed'; notice: boolean; settled?: boolean; resetDesired?: boolean }
|
||||||
|
| { kind: 'stale' }
|
||||||
|
|
||||||
|
interface PagedContext<Q> {
|
||||||
|
loaded: boolean
|
||||||
|
shown: Q
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PagedQueryOptions<Q extends PagedQuery> {
|
||||||
|
path: string
|
||||||
|
initial: Q
|
||||||
|
request: (query: Q, context: PagedContext<Q>) => Promise<PagedOutcome>
|
||||||
|
parseRoute: (query: LocationQuery, ownReplace: boolean) => Q
|
||||||
|
toRoute: (query: Q, current: LocationQuery) => Record<string, string>
|
||||||
|
same: (left: Q, right: Q) => boolean
|
||||||
|
sameRoute?: (current: LocationQuery, target: Record<string, string>) => boolean
|
||||||
|
enabled?: () => boolean
|
||||||
|
isLoading?: () => boolean
|
||||||
|
pendingPageFor?: (query: Q, loaded: boolean) => number | null
|
||||||
|
initialLoading?: boolean
|
||||||
|
onRouteQuery?: (query: Q) => void
|
||||||
|
onLoaded?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePagedQuery<Q extends PagedQuery>(options: PagedQueryOptions<Q>) {
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const skeleton = useDelayedSkeleton()
|
||||||
|
const sameRoute = options.sameRoute ?? sameRouteQuery
|
||||||
|
|
||||||
|
const total = ref(0)
|
||||||
|
const shown = ref(options.initial) as Ref<Q>
|
||||||
|
const desired = ref(options.initial) as Ref<Q>
|
||||||
|
const hasLoaded = ref(false)
|
||||||
|
const loadedOnce = ref(false)
|
||||||
|
const loading = ref(options.initialLoading ?? false)
|
||||||
|
const pendingPage = ref<number | null>(null)
|
||||||
|
const notice = ref<PagedNotice<Q> | null>(null)
|
||||||
|
let token = 0
|
||||||
|
let syncedTarget: Record<string, string> | null = null
|
||||||
|
|
||||||
|
const pageCount = computed(() => pageCountFor(total.value, shown.value.size))
|
||||||
|
const skeletonActive = computed(() => loading.value && skeleton.visible.value)
|
||||||
|
|
||||||
|
async function syncUrl(query: Q, mode: UrlMode) {
|
||||||
|
if (route.path !== options.path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const target = options.toRoute(query, route.query)
|
||||||
|
if (sameRoute(route.query, target)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
syncedTarget = target
|
||||||
|
await router[mode]({ query: target })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(query: Q, mode: UrlMode, successNotice: PagedNotice<Q> | null = null) {
|
||||||
|
const current = ++token
|
||||||
|
desired.value = query
|
||||||
|
loading.value = true
|
||||||
|
pendingPage.value = options.pendingPageFor
|
||||||
|
? options.pendingPageFor(query, loadedOnce.value)
|
||||||
|
: loadedOnce.value
|
||||||
|
? query.page
|
||||||
|
: null
|
||||||
|
skeleton.start()
|
||||||
|
const outcome = await options.request(query, {
|
||||||
|
loaded: loadedOnce.value,
|
||||||
|
shown: shown.value,
|
||||||
|
})
|
||||||
|
if (current !== token || outcome.kind === 'stale') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (outcome.kind === 'failed') {
|
||||||
|
finish()
|
||||||
|
if (outcome.settled) {
|
||||||
|
hasLoaded.value = true
|
||||||
|
}
|
||||||
|
if (outcome.resetDesired) {
|
||||||
|
desired.value = shown.value
|
||||||
|
}
|
||||||
|
if (outcome.notice) {
|
||||||
|
notice.value = { kind: 'error', query, mode }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const lastPage = pageCountFor(outcome.total, query.size)
|
||||||
|
if (
|
||||||
|
successNotice === null &&
|
||||||
|
!outcome.redirected &&
|
||||||
|
outcome.count === 0 &&
|
||||||
|
outcome.total > 0 &&
|
||||||
|
query.page > lastPage
|
||||||
|
) {
|
||||||
|
await load({ ...query, page: lastPage }, 'replace', { kind: 'range' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const effective = {
|
||||||
|
...query,
|
||||||
|
page: outcome.page ?? (outcome.total === 0 ? 1 : query.page),
|
||||||
|
}
|
||||||
|
outcome.apply?.()
|
||||||
|
total.value = outcome.total
|
||||||
|
shown.value = effective
|
||||||
|
desired.value = effective
|
||||||
|
hasLoaded.value = true
|
||||||
|
loadedOnce.value = true
|
||||||
|
notice.value = successNotice ?? (outcome.redirected ? { kind: 'range' } : null)
|
||||||
|
finish()
|
||||||
|
await syncUrl(effective, outcome.redirected ? 'replace' : mode)
|
||||||
|
options.onLoaded?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
loading.value = false
|
||||||
|
pendingPage.value = null
|
||||||
|
skeleton.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(page: number) {
|
||||||
|
return load({ ...desired.value, page }, 'push')
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSize(size: number) {
|
||||||
|
return load({ ...desired.value, size, page: 1 }, 'push')
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryNotice() {
|
||||||
|
const current = notice.value as PagedNotice<Q> | null
|
||||||
|
if (current?.kind === 'error') {
|
||||||
|
return load(current.query, current.mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
() => {
|
||||||
|
if (route.path !== options.path || options.enabled?.() === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ownReplace = syncedTarget !== null && sameRoute(route.query, syncedTarget)
|
||||||
|
syncedTarget = null
|
||||||
|
const next = options.parseRoute(route.query, ownReplace)
|
||||||
|
if (hasLoaded.value && options.same(next, shown.value)) {
|
||||||
|
syncUrl(shown.value, 'replace')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const busy = options.isLoading ? options.isLoading() : loading.value
|
||||||
|
if (options.same(next, desired.value) && busy) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options.onRouteQuery?.(next)
|
||||||
|
load(next, 'replace')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
token++
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
shown,
|
||||||
|
desired,
|
||||||
|
hasLoaded,
|
||||||
|
loadedOnce,
|
||||||
|
loading,
|
||||||
|
pendingPage,
|
||||||
|
notice,
|
||||||
|
pageCount,
|
||||||
|
showSkeleton: skeleton.visible,
|
||||||
|
skeletonActive,
|
||||||
|
load,
|
||||||
|
syncUrl,
|
||||||
|
changePage,
|
||||||
|
changeSize,
|
||||||
|
retryNotice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearchInput(
|
||||||
|
initial: string,
|
||||||
|
current: () => string,
|
||||||
|
commit: (query: string) => unknown,
|
||||||
|
) {
|
||||||
|
const searchInput = ref(initial)
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSearch(value: string) {
|
||||||
|
const q = value.trim()
|
||||||
|
if (q !== current()) {
|
||||||
|
commit(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
cancel()
|
||||||
|
searchInput.value = ''
|
||||||
|
changeSearch('')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(searchInput, (value) => {
|
||||||
|
cancel()
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null
|
||||||
|
changeSearch(value)
|
||||||
|
}, SEARCH_DEBOUNCE_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(cancel)
|
||||||
|
|
||||||
|
return { searchInput, changeSearch, clearSearch }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { jsonInit } from '@/lib/directoryRequest'
|
import { directoryListPath, jsonInit } from '@/lib/directoryRequest'
|
||||||
|
import type { DirectoryQuery } from '@/lib/directoryQuery'
|
||||||
import { reassignQuery } from '@/lib/directory'
|
import { reassignQuery } from '@/lib/directory'
|
||||||
|
|
||||||
export type SiteRobots = 'index' | 'noindex'
|
export type SiteRobots = 'index' | 'noindex'
|
||||||
@@ -44,12 +45,17 @@ export interface SitePatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useSites() {
|
export function useSites() {
|
||||||
const { request } = useApi()
|
const { request, requestPaged } = useApi()
|
||||||
|
|
||||||
function listSites(): Promise<Site[]> {
|
function listAllSites(): Promise<Site[]> {
|
||||||
return request<Site[]>('/v1/site/')
|
return request<Site[]>('/v1/site/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listSitePage(query: DirectoryQuery) {
|
||||||
|
const { body, total } = await requestPaged<Site[]>(directoryListPath('/v1/site/', query))
|
||||||
|
return { items: body, total }
|
||||||
|
}
|
||||||
|
|
||||||
function createSite(site: NewSite): Promise<Site> {
|
function createSite(site: NewSite): Promise<Site> {
|
||||||
return request<Site>('/v1/site/', jsonInit('POST', site))
|
return request<Site>('/v1/site/', jsonInit('POST', site))
|
||||||
}
|
}
|
||||||
@@ -68,5 +74,5 @@ export function useSites() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { listSites, createSite, updateSite, setDefaultSite, deleteSite }
|
return { listAllSites, listSitePage, createSite, updateSite, setDefaultSite, deleteSite }
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ const SOURCES = [
|
|||||||
'components/DirectoryDialog.vue',
|
'components/DirectoryDialog.vue',
|
||||||
'components/DirectoryField.vue',
|
'components/DirectoryField.vue',
|
||||||
'components/DirectoryList.vue',
|
'components/DirectoryList.vue',
|
||||||
|
'components/DirectoryPageNotice.vue',
|
||||||
'components/DirectoryPanel.vue',
|
'components/DirectoryPanel.vue',
|
||||||
'components/DirectoryRowMenu.vue',
|
'components/DirectoryRowMenu.vue',
|
||||||
'components/LinkUserDialog.vue',
|
'components/LinkUserDialog.vue',
|
||||||
@@ -43,6 +44,8 @@ const SAME_IN_FRENCH = new Set([
|
|||||||
'Noindex',
|
'Noindex',
|
||||||
'Site',
|
'Site',
|
||||||
'Sites',
|
'Sites',
|
||||||
|
'1 site',
|
||||||
|
'{0} sites',
|
||||||
'Guest',
|
'Guest',
|
||||||
'Linked',
|
'Linked',
|
||||||
'Description',
|
'Description',
|
||||||
|
|||||||
@@ -1792,6 +1792,12 @@ export const messages = {
|
|||||||
'Retirez le chemin. Utilisez seulement l’hôte, comme 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',
|
'Reset to match the name': 'Rétablir selon le nom',
|
||||||
'Search by name or slug': 'Rechercher par nom ou identifiant',
|
'Search by name or slug': 'Rechercher par nom ou identifiant',
|
||||||
|
'Search by name or key': 'Rechercher par nom ou clé',
|
||||||
|
'No sites match "{0}"': 'Aucun site ne correspond à « {0} »',
|
||||||
|
'{0} sites': '{0} sites',
|
||||||
|
'1 site': '1 site',
|
||||||
|
'{0} authors': '{0} auteurs',
|
||||||
|
'1 author': '1 auteur',
|
||||||
"Search engines may list this site's pages.":
|
"Search engines may list this site's pages.":
|
||||||
'Les moteurs de recherche peuvent référencer les pages de ce site.',
|
'Les moteurs de recherche peuvent référencer les pages de ce site.',
|
||||||
'Set as default': 'Définir par défaut',
|
'Set as default': 'Définir par défaut',
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import type { LocationQuery } from 'vue-router'
|
import type { LocationQuery } from 'vue-router'
|
||||||
|
import { SITE_KEY_MAX } from '@/lib/directory'
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
PAGE_SIZES,
|
||||||
|
SEARCH_MAX_LENGTH,
|
||||||
|
firstQueryValue,
|
||||||
|
parsePositiveInteger,
|
||||||
|
} from '@/lib/pageQuery'
|
||||||
|
|
||||||
export type ArticleStatus = 'online' | 'draft'
|
export type ArticleStatus = 'online' | 'draft'
|
||||||
|
|
||||||
export const ARTICLE_PAGE_SIZES = [5, 25, 50, 100]
|
|
||||||
export const DEFAULT_ARTICLE_PAGE_SIZE = 25
|
|
||||||
export const ARTICLE_SEARCH_MAX_LENGTH = 200
|
|
||||||
export const DEFAULT_ARTICLE_SORT = 'updated_at:desc'
|
export const DEFAULT_ARTICLE_SORT = 'updated_at:desc'
|
||||||
export const ARTICLE_SORT_FIELDS = ['title', 'status', 'created_at', 'updated_at'] as const
|
export const ARTICLE_SORT_FIELDS = ['title', 'status', 'created_at', 'updated_at'] as const
|
||||||
export type ArticleSortField = (typeof ARTICLE_SORT_FIELDS)[number]
|
export type ArticleSortField = (typeof ARTICLE_SORT_FIELDS)[number]
|
||||||
|
|
||||||
export const SEO_FILTER = 'suggestions'
|
export const SEO_FILTER = 'suggestions'
|
||||||
|
|
||||||
const SITE_KEY_MAX = 32
|
|
||||||
const POSITIVE_INTEGER = /^[1-9]\d*$/
|
|
||||||
|
|
||||||
export interface ArticleListQuery {
|
export interface ArticleListQuery {
|
||||||
page: number
|
page: number
|
||||||
size: number
|
size: number
|
||||||
@@ -24,11 +26,6 @@ export interface ArticleListQuery {
|
|||||||
seo: '' | typeof SEO_FILTER
|
seo: '' | typeof SEO_FILTER
|
||||||
}
|
}
|
||||||
|
|
||||||
function first(value: LocationQuery[string] | undefined) {
|
|
||||||
const single = Array.isArray(value) ? value[0] : value
|
|
||||||
return single ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const SORT_PATTERN = new RegExp(`^(${ARTICLE_SORT_FIELDS.join('|')}):(asc|desc)$`)
|
const SORT_PATTERN = new RegExp(`^(${ARTICLE_SORT_FIELDS.join('|')}):(asc|desc)$`)
|
||||||
|
|
||||||
function validSort(value: string) {
|
function validSort(value: string) {
|
||||||
@@ -45,17 +42,17 @@ export function nextSort(current: string, field: ArticleSortField) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseArticleQuery(query: LocationQuery): ArticleListQuery {
|
export function parseArticleQuery(query: LocationQuery): ArticleListQuery {
|
||||||
const page = first(query.page)
|
const page = firstQueryValue(query.page)
|
||||||
const size = Number(first(query.size))
|
const size = Number(firstQueryValue(query.size))
|
||||||
const status = first(query.status)
|
const status = firstQueryValue(query.status)
|
||||||
return {
|
return {
|
||||||
page: POSITIVE_INTEGER.test(page) ? Number(page) : 1,
|
page: parsePositiveInteger(page) ?? 1,
|
||||||
size: ARTICLE_PAGE_SIZES.includes(size) ? size : DEFAULT_ARTICLE_PAGE_SIZE,
|
size: PAGE_SIZES.includes(size) ? size : DEFAULT_PAGE_SIZE,
|
||||||
q: first(query.q).slice(0, ARTICLE_SEARCH_MAX_LENGTH),
|
q: firstQueryValue(query.q).slice(0, SEARCH_MAX_LENGTH),
|
||||||
status: status === 'online' || status === 'draft' ? status : '',
|
status: status === 'online' || status === 'draft' ? status : '',
|
||||||
sort: validSort(first(query.sort)),
|
sort: validSort(firstQueryValue(query.sort)),
|
||||||
site: first(query.site).slice(0, SITE_KEY_MAX),
|
site: firstQueryValue(query.site).slice(0, SITE_KEY_MAX),
|
||||||
seo: first(query.seo) === SEO_FILTER ? SEO_FILTER : '',
|
seo: firstQueryValue(query.seo) === SEO_FILTER ? SEO_FILTER : '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +61,7 @@ export function toRouteQuery(query: ArticleListQuery) {
|
|||||||
if (query.page !== 1) {
|
if (query.page !== 1) {
|
||||||
route.page = String(query.page)
|
route.page = String(query.page)
|
||||||
}
|
}
|
||||||
if (query.size !== DEFAULT_ARTICLE_PAGE_SIZE) {
|
if (query.size !== DEFAULT_PAGE_SIZE) {
|
||||||
route.size = String(query.size)
|
route.size = String(query.size)
|
||||||
}
|
}
|
||||||
if (query.q) {
|
if (query.q) {
|
||||||
@@ -96,11 +93,3 @@ export function sameArticleQuery(left: ArticleListQuery, right: ArticleListQuery
|
|||||||
effectiveSort(left.sort) === effectiveSort(right.sort)
|
effectiveSort(left.sort) === effectiveSort(right.sort)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sameRouteQuery(left: LocationQuery, right: Record<string, string>) {
|
|
||||||
const leftKeys = Object.keys(left)
|
|
||||||
return (
|
|
||||||
leftKeys.length === Object.keys(right).length &&
|
|
||||||
leftKeys.every((key) => first(left[key]) === right[key])
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { LocationQuery } from 'vue-router'
|
import type { LocationQuery } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
ARTICLE_PAGE_SIZES,
|
DEFAULT_PAGE_SIZE,
|
||||||
ARTICLE_SEARCH_MAX_LENGTH,
|
PAGE_SIZES,
|
||||||
DEFAULT_ARTICLE_PAGE_SIZE,
|
SEARCH_MAX_LENGTH,
|
||||||
} from '@/lib/articleListQuery'
|
firstQueryValue,
|
||||||
|
parsePositiveInteger,
|
||||||
|
} from '@/lib/pageQuery'
|
||||||
|
|
||||||
export interface DirectoryQuery {
|
export interface DirectoryQuery {
|
||||||
q: string
|
q: string
|
||||||
@@ -11,20 +13,13 @@ export interface DirectoryQuery {
|
|||||||
size: 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 {
|
export function parseDirectoryQuery(query: LocationQuery): DirectoryQuery {
|
||||||
const page = first(query.page)
|
const page = firstQueryValue(query.page)
|
||||||
const size = Number(first(query.size))
|
const size = Number(firstQueryValue(query.size))
|
||||||
return {
|
return {
|
||||||
q: first(query.q).slice(0, ARTICLE_SEARCH_MAX_LENGTH),
|
q: firstQueryValue(query.q).slice(0, SEARCH_MAX_LENGTH),
|
||||||
page: POSITIVE_INTEGER.test(page) ? Number(page) : 1,
|
page: parsePositiveInteger(page) ?? 1,
|
||||||
size: ARTICLE_PAGE_SIZES.includes(size) ? size : DEFAULT_ARTICLE_PAGE_SIZE,
|
size: PAGE_SIZES.includes(size) ? size : DEFAULT_PAGE_SIZE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +27,7 @@ export function toDirectoryRouteQuery(query: DirectoryQuery, rest: LocationQuery
|
|||||||
const route: Record<string, string> = {}
|
const route: Record<string, string> = {}
|
||||||
for (const [key, value] of Object.entries(rest)) {
|
for (const [key, value] of Object.entries(rest)) {
|
||||||
if (key !== 'q' && key !== 'page' && key !== 'size') {
|
if (key !== 'q' && key !== 'page' && key !== 'size') {
|
||||||
route[key] = first(value)
|
route[key] = firstQueryValue(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (query.q) {
|
if (query.q) {
|
||||||
@@ -41,7 +36,7 @@ export function toDirectoryRouteQuery(query: DirectoryQuery, rest: LocationQuery
|
|||||||
if (query.page !== 1) {
|
if (query.page !== 1) {
|
||||||
route.page = String(query.page)
|
route.page = String(query.page)
|
||||||
}
|
}
|
||||||
if (query.size !== DEFAULT_ARTICLE_PAGE_SIZE) {
|
if (query.size !== DEFAULT_PAGE_SIZE) {
|
||||||
route.size = String(query.size)
|
route.size = String(query.size)
|
||||||
}
|
}
|
||||||
return route
|
return route
|
||||||
|
|||||||
@@ -5,3 +5,12 @@ export function jsonInit(method: string, body: unknown): RequestInit {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function directoryListPath(base: string, query: { q: string; page: number; size: number }) {
|
||||||
|
const parts = [
|
||||||
|
...(query.q ? [`q=${encodeURIComponent(query.q)}`] : []),
|
||||||
|
`page=${query.page}`,
|
||||||
|
`size=${query.size}`,
|
||||||
|
]
|
||||||
|
return `${base}?${parts.join('&')}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { LocationQuery } from 'vue-router'
|
||||||
|
|
||||||
|
export const PAGE_SIZES = [5, 25, 50, 100]
|
||||||
|
export const DEFAULT_PAGE_SIZE = 25
|
||||||
|
export const SEARCH_MAX_LENGTH = 200
|
||||||
|
|
||||||
|
const POSITIVE_INTEGER = /^[1-9]\d*$/
|
||||||
|
|
||||||
|
export function firstQueryValue(value: LocationQuery[string] | undefined) {
|
||||||
|
const single = Array.isArray(value) ? value[0] : value
|
||||||
|
return single ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePositiveInteger(value: string) {
|
||||||
|
return POSITIVE_INTEGER.test(value) ? Number(value) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameRouteQuery(left: LocationQuery, right: Record<string, string>) {
|
||||||
|
const leftKeys = Object.keys(left)
|
||||||
|
return (
|
||||||
|
leftKeys.length === Object.keys(right).length &&
|
||||||
|
leftKeys.every((key) => firstQueryValue(left[key]) === right[key])
|
||||||
|
)
|
||||||
|
}
|
||||||
+66
-218
@@ -1,11 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t } from '@/i18n'
|
import { t, formatNumber } from '@/i18n'
|
||||||
|
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useMediaQuery } from '@vueuse/core'
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
import { useLocalizedOverlay } from '@/i18n/presentation'
|
import { useLocalizedOverlay } from '@/i18n/presentation'
|
||||||
import AppPaginator from '@/components/AppPaginator.vue'
|
|
||||||
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
import AuthorAvatar from '@/components/AuthorAvatar.vue'
|
||||||
import AuthorFormPanel from '@/components/AuthorFormPanel.vue'
|
import AuthorFormPanel from '@/components/AuthorFormPanel.vue'
|
||||||
import AuthorViewPanel from '@/components/AuthorViewPanel.vue'
|
import AuthorViewPanel from '@/components/AuthorViewPanel.vue'
|
||||||
@@ -14,31 +13,27 @@ import DashboardShell from '@/components/DashboardShell.vue'
|
|||||||
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
|
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
|
||||||
import DirectoryBadge from '@/components/DirectoryBadge.vue'
|
import DirectoryBadge from '@/components/DirectoryBadge.vue'
|
||||||
import DirectoryList from '@/components/DirectoryList.vue'
|
import DirectoryList from '@/components/DirectoryList.vue'
|
||||||
|
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
|
||||||
|
import DirectoryNoResults from '@/components/DirectoryNoResults.vue'
|
||||||
|
import DirectoryPageNotice from '@/components/DirectoryPageNotice.vue'
|
||||||
|
import DirectoryPagination from '@/components/DirectoryPagination.vue'
|
||||||
import LinkUserDialog from '@/components/LinkUserDialog.vue'
|
import LinkUserDialog from '@/components/LinkUserDialog.vue'
|
||||||
import ReassignDialog, { type ReassignOption } from '@/components/ReassignDialog.vue'
|
import ReassignDialog, { type ReassignOption } from '@/components/ReassignDialog.vue'
|
||||||
import { useAuthors, type Author } from '@/composables/useAuthors'
|
import { useAuthors, type Author } from '@/composables/useAuthors'
|
||||||
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
|
|
||||||
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
|
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
|
||||||
|
import { useDirectoryListState } from '@/composables/useDirectoryListState'
|
||||||
|
import { useDirectoryPages } from '@/composables/useDirectoryPages'
|
||||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||||
import { useDirectoryView } from '@/composables/useDirectoryView'
|
import { useDirectoryView } from '@/composables/useDirectoryView'
|
||||||
import { sameRouteQuery } from '@/lib/articleListQuery'
|
|
||||||
import { pluralArticles } from '@/lib/directory'
|
import { pluralArticles } from '@/lib/directory'
|
||||||
import { copy } from '@/lib/directoryCopy'
|
import { copy } from '@/lib/directoryCopy'
|
||||||
import { classifyFailure } from '@/lib/directoryErrors'
|
import { classifyFailure } from '@/lib/directoryErrors'
|
||||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||||
import {
|
|
||||||
parseDirectoryQuery,
|
|
||||||
toDirectoryRouteQuery,
|
|
||||||
type DirectoryQuery,
|
|
||||||
} from '@/lib/directoryQuery'
|
|
||||||
import { pageCountFor } from '@/lib/pagination'
|
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 120
|
|
||||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||||
const LIST_COLUMNS = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px'
|
const LIST_COLUMNS = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px'
|
||||||
const SKELETON_ROWS = 6
|
const INITIAL_SKELETON_ROWS = 6
|
||||||
const PAGINATOR_THRESHOLD = 25
|
|
||||||
const NOBODY = 'none'
|
const NOBODY = 'none'
|
||||||
const LOOKUP_SIZE = 100
|
const LOOKUP_SIZE = 100
|
||||||
|
|
||||||
@@ -48,51 +43,43 @@ type PanelTarget =
|
|||||||
| { kind: 'view'; author: Author }
|
| { kind: 'view'; author: Author }
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const overlay = useLocalizedOverlay()
|
const overlay = useLocalizedOverlay()
|
||||||
const toasts = useDirectoryToasts()
|
const toasts = useDirectoryToasts()
|
||||||
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
|
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
|
||||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||||
const { listAuthors, listAllAuthors, deleteAuthor } = useAuthors()
|
const { listAuthors, listAuthorPage, listAllAuthors, deleteAuthor } = useAuthors()
|
||||||
const collection = useDirectoryCollection(listAuthors)
|
|
||||||
const removal = useDirectoryDelete<Author>()
|
const removal = useDirectoryDelete<Author>()
|
||||||
|
|
||||||
const { data: result, showSkeleton } = collection
|
const sessionReady = computed(() => session.status === 'ready')
|
||||||
|
const list = useDirectoryPages<Author>({
|
||||||
|
loadPage: listAuthorPage,
|
||||||
|
ready: () => sessionReady.value,
|
||||||
|
onLoaded: () => followLink(),
|
||||||
|
})
|
||||||
|
const { items, total, shown, notice, searchInput, pageCount } = list
|
||||||
const { target: deleteTarget, pending: deletePending, notice: deleteNotice } = removal
|
const { target: deleteTarget, pending: deletePending, notice: deleteNotice } = removal
|
||||||
const loadState = computed(() =>
|
const loadState = computed(() => (list.state.value === 'forbidden' ? 'error' : list.state.value))
|
||||||
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 panel = ref<PanelTarget | null>(null)
|
||||||
const linkTarget = ref<Author | null>(null)
|
const linkTarget = ref<Author | null>(null)
|
||||||
const deleteOptions = ref<ReassignOption[]>([])
|
const deleteOptions = ref<ReassignOption[]>([])
|
||||||
const destination = ref<string | number | undefined>(undefined)
|
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 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(
|
const firstRun = computed(
|
||||||
() => loadState.value === 'ready' && total.value === 0 && query.value.q === '',
|
() => loadState.value === 'ready' && total.value === 0 && shown.value.q === '',
|
||||||
)
|
)
|
||||||
const noResults = computed(
|
const { noResults, showList, skeletonRows, showPaginator, singlePageLabel } = useDirectoryListState(
|
||||||
() => loadState.value === 'ready' && total.value === 0 && query.value.q !== '',
|
{
|
||||||
|
list,
|
||||||
|
loadState,
|
||||||
|
isDesktop,
|
||||||
|
initialRows: INITIAL_SKELETON_ROWS,
|
||||||
|
singleLabel: (count) =>
|
||||||
|
count === 1 ? t('1 author') : t('{0} authors', [formatNumber(count)]),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
const showToolbar = computed(() => loadState.value !== 'error' && !firstRun.value)
|
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(
|
const overlayOpen = computed(
|
||||||
() => panel.value !== null || linkTarget.value !== null || deleteTarget.value !== null,
|
() => panel.value !== null || linkTarget.value !== null || deleteTarget.value !== null,
|
||||||
)
|
)
|
||||||
@@ -113,82 +100,6 @@ const headings = computed(() => [t('Author'), t('Slug'), t('Account'), t('Articl
|
|||||||
const ownSlug = computed(() => session.session?.author?.slug ?? '')
|
const ownSlug = computed(() => session.session?.author?.slug ?? '')
|
||||||
const knownSlugs = computed(() => items.value.map((author) => 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(
|
watch(
|
||||||
() => route.query.edit,
|
() => route.query.edit,
|
||||||
() => {
|
() => {
|
||||||
@@ -198,22 +109,6 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
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) {
|
async function findBySlug(slug: string) {
|
||||||
const loaded = items.value.find((author) => author.slug === slug)
|
const loaded = items.value.find((author) => author.slug === slug)
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
@@ -267,42 +162,25 @@ function closePanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function replaceItem(author: Author) {
|
function replaceItem(author: Author) {
|
||||||
if (!result.value) {
|
items.value = items.value.map((item) =>
|
||||||
return
|
item.id === author.id
|
||||||
}
|
? {
|
||||||
result.value = {
|
...author,
|
||||||
...result.value,
|
user_email: author.user_email ?? item.user_email,
|
||||||
items: result.value.items.map((item) =>
|
}
|
||||||
item.id === author.id
|
: item,
|
||||||
? {
|
)
|
||||||
...author,
|
|
||||||
user_email: author.user_email ?? item.user_email,
|
|
||||||
}
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(id: number) {
|
function removeItem(id: number) {
|
||||||
if (!result.value) {
|
list.removeById(id, (item) => item.id)
|
||||||
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) {
|
function onSaved(author: Author) {
|
||||||
if (items.value.some((item) => item.id === author.id)) {
|
if (items.value.some((item) => item.id === author.id)) {
|
||||||
replaceItem(author)
|
replaceItem(author)
|
||||||
} else {
|
} else {
|
||||||
load(query.value)
|
list.reload()
|
||||||
}
|
}
|
||||||
closePanel()
|
closePanel()
|
||||||
}
|
}
|
||||||
@@ -552,7 +430,7 @@ function menuItems(author: Author): DirectoryMenuItem[] {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q]))
|
const noResultsTitle = computed(() => t('No authors match "{0}"', [shown.value.q]))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -572,40 +450,26 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q
|
|||||||
|
|
||||||
<UInput
|
<UInput
|
||||||
v-if="showToolbar"
|
v-if="showToolbar"
|
||||||
v-model="searchText"
|
v-model="searchInput"
|
||||||
type="search"
|
type="search"
|
||||||
icon="i-lucide-search"
|
icon="i-lucide-search"
|
||||||
:placeholder="t('Search by name or slug')"
|
:placeholder="t('Search by name or slug')"
|
||||||
:aria-label="t('Search by name or slug')"
|
:aria-label="t('Search by name or slug')"
|
||||||
class="w-full sm:w-70"
|
class="w-full sm:w-70"
|
||||||
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
|
:ui="{ base: 'h-11 text-lg ps-8.25 sm:h-8 sm:text-sm', leadingIcon: 'size-3.5' }"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UAlert
|
<DirectoryPageNotice
|
||||||
|
v-if="notice?.kind === 'range'"
|
||||||
|
:notice="notice"
|
||||||
|
:shown-page="shown.page"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DirectoryLoadError
|
||||||
v-if="loadState === 'error'"
|
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')"
|
:title="t('Couldn\'t load authors')"
|
||||||
:description="t('Check your connection and try again.')"
|
@retry="list.retry"
|
||||||
: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
|
<UEmpty
|
||||||
v-else-if="firstRun"
|
v-else-if="firstRun"
|
||||||
@@ -629,38 +493,19 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q
|
|||||||
</template>
|
</template>
|
||||||
</UEmpty>
|
</UEmpty>
|
||||||
|
|
||||||
<UEmpty
|
<DirectoryNoResults
|
||||||
v-else-if="noResults"
|
v-else-if="noResults"
|
||||||
variant="naked"
|
|
||||||
icon="i-lucide-search"
|
|
||||||
:title="noResultsTitle"
|
:title="noResultsTitle"
|
||||||
:description="t('Check the spelling, or search for a shorter part of the name.')"
|
@clear="list.clearSearch"
|
||||||
: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
|
<DirectoryList
|
||||||
v-else-if="showList"
|
v-else-if="showList"
|
||||||
:items="items"
|
:items="items"
|
||||||
:columns="LIST_COLUMNS"
|
:columns="LIST_COLUMNS"
|
||||||
:headings="headings"
|
:headings="headings"
|
||||||
:loading="loadState === 'loading'"
|
:loading="list.skeletonActive.value"
|
||||||
:skeleton-rows="SKELETON_ROWS"
|
:skeleton-rows="skeletonRows"
|
||||||
:read-only="!canManage"
|
:read-only="!canManage"
|
||||||
:label="t('Authors')"
|
:label="t('Authors')"
|
||||||
:row-title="(author: Author) => author.name"
|
:row-title="(author: Author) => author.name"
|
||||||
@@ -697,15 +542,18 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [query.value.q
|
|||||||
</template>
|
</template>
|
||||||
</DirectoryList>
|
</DirectoryList>
|
||||||
|
|
||||||
<AppPaginator
|
<DirectoryPagination
|
||||||
v-if="showPaginator"
|
:notice="notice"
|
||||||
:page="query.page"
|
:show-paginator="showPaginator"
|
||||||
|
:shown-page="shown.page"
|
||||||
:page-count="pageCount"
|
:page-count="pageCount"
|
||||||
:total="total"
|
:total="total"
|
||||||
:size="query.size"
|
:size="shown.size"
|
||||||
:loading-page="pendingPage"
|
:loading-page="list.pendingPage.value"
|
||||||
@update:page="changePage"
|
:single-page-label="singlePageLabel"
|
||||||
@update:size="changeSize"
|
@retry="list.retry"
|
||||||
|
@update:page="list.changePage"
|
||||||
|
@update:size="list.changeSize"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const router = useRouter()
|
|||||||
const { request } = useApi()
|
const { request } = useApi()
|
||||||
const toast = useLocalizedToast()
|
const toast = useLocalizedToast()
|
||||||
const directoryToasts = useDirectoryToasts()
|
const directoryToasts = useDirectoryToasts()
|
||||||
const { listSites } = useSites()
|
const { listAllSites } = useSites()
|
||||||
const lifecycle = useArticleLifecycle()
|
const lifecycle = useArticleLifecycle()
|
||||||
const {
|
const {
|
||||||
editorRef,
|
editorRef,
|
||||||
@@ -137,7 +137,7 @@ function clearSkeletonTimer() {
|
|||||||
|
|
||||||
async function fetchSites() {
|
async function fetchSites() {
|
||||||
try {
|
try {
|
||||||
const list = await listSites()
|
const list = await listAllSites()
|
||||||
return Array.isArray(list) ? list : []
|
return Array.isArray(list) ? list : []
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
|
|||||||
+51
-93
@@ -2,7 +2,7 @@
|
|||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter, type LocationQuery } from 'vue-router'
|
import { useRoute, type LocationQuery } from 'vue-router'
|
||||||
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
|
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
|
||||||
import { useLocalizedToast } from '@/i18n/presentation'
|
import { useLocalizedToast } from '@/i18n/presentation'
|
||||||
import AppPaginator from '@/components/AppPaginator.vue'
|
import AppPaginator from '@/components/AppPaginator.vue'
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from '@/composables/useMedia'
|
} from '@/composables/useMedia'
|
||||||
import { useMediaSelection } from '@/composables/useMediaSelection'
|
import { useMediaSelection } from '@/composables/useMediaSelection'
|
||||||
import { useMediaUpload } from '@/composables/useMediaUpload'
|
import { useMediaUpload } from '@/composables/useMediaUpload'
|
||||||
|
import { usePagedQuery, type PagedOutcome, type UrlMode } from '@/composables/usePagedQuery'
|
||||||
import type { PurgeResult } from '@/composables/useMediaPurge'
|
import type { PurgeResult } from '@/composables/useMediaPurge'
|
||||||
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
|
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
|
||||||
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
|
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
|
||||||
@@ -45,7 +46,6 @@ import {
|
|||||||
toRouteQuery,
|
toRouteQuery,
|
||||||
type MediaControls,
|
type MediaControls,
|
||||||
} from '@/lib/mediaQuery'
|
} from '@/lib/mediaQuery'
|
||||||
import { sameRouteQuery } from '@/lib/articleListQuery'
|
|
||||||
import { MEDIA_PAGE_SIZE_KEY, storePageSize } from '@/lib/pageSizeMemory'
|
import { MEDIA_PAGE_SIZE_KEY, storePageSize } from '@/lib/pageSizeMemory'
|
||||||
import { pageCountFor } from '@/lib/pagination'
|
import { pageCountFor } from '@/lib/pagination'
|
||||||
import { purgedToast } from '@/lib/mediaPurge'
|
import { purgedToast } from '@/lib/mediaPurge'
|
||||||
@@ -78,11 +78,7 @@ const MERGE_CONFLICT_COPY =
|
|||||||
const OFFLINE_COPY =
|
const OFFLINE_COPY =
|
||||||
"You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection."
|
"You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection."
|
||||||
|
|
||||||
type UrlMode = 'push' | 'replace'
|
|
||||||
type Notice = { kind: 'range' } | { kind: 'error'; query: MediaControls; mode: UrlMode }
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
|
||||||
const toast = useLocalizedToast()
|
const toast = useLocalizedToast()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const media = useMedia()
|
const media = useMedia()
|
||||||
@@ -91,15 +87,55 @@ const upload = useMediaUpload({ onAdded: refreshInBackground })
|
|||||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||||
const online = useOnline()
|
const online = useOnline()
|
||||||
|
|
||||||
const shown = ref<MediaControls>(parseMediaControls(route.query))
|
function differsOnlyByPage(query: MediaControls, current: MediaControls) {
|
||||||
const desired = ref<MediaControls>(shown.value)
|
return sameMediaControls({ ...query, page: current.page }, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameOrderedQuery(current: LocationQuery, target: Record<string, string>) {
|
||||||
|
const flatten = (query: LocationQuery) =>
|
||||||
|
Object.entries(query).map(([key, value]) => [key, Array.isArray(value) ? value[0] : value])
|
||||||
|
return JSON.stringify(flatten(current)) === JSON.stringify(Object.entries(target))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestPage(
|
||||||
|
query: MediaControls,
|
||||||
|
context: { loaded: boolean; shown: MediaControls },
|
||||||
|
): Promise<PagedOutcome> {
|
||||||
|
const keepOnError = context.loaded && differsOnlyByPage(query, context.shown)
|
||||||
|
const outcome = await media.load(query, { keepOnError })
|
||||||
|
if (outcome.kind === 'stale') {
|
||||||
|
return { kind: 'stale' }
|
||||||
|
}
|
||||||
|
if (outcome.kind === 'failed') {
|
||||||
|
const showNotice = keepOnError && !outcome.handled
|
||||||
|
return { kind: 'failed', settled: true, notice: showNotice, resetDesired: showNotice }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'loaded',
|
||||||
|
total: media.total.value,
|
||||||
|
count: media.items.value.length,
|
||||||
|
page: outcome.page,
|
||||||
|
redirected: outcome.redirected,
|
||||||
|
apply: selection.clear,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const paged = usePagedQuery<MediaControls>({
|
||||||
|
path: MEDIA_PATH,
|
||||||
|
initial: parseMediaControls(route.query),
|
||||||
|
request: requestPage,
|
||||||
|
parseRoute: (query, ownReplace) => parseMediaControls(query, !ownReplace),
|
||||||
|
toRoute: (query) => toRouteQuery(query),
|
||||||
|
same: sameMediaControls,
|
||||||
|
sameRoute: sameOrderedQuery,
|
||||||
|
isLoading: () => media.status.value === 'loading',
|
||||||
|
pendingPageFor: (query, loaded) => (loaded && !isFullSet(query) ? query.page : null),
|
||||||
|
onLoaded: selectPending,
|
||||||
|
})
|
||||||
|
const { shown, desired, hasLoaded, loadedOnce, pendingPage, notice, load, syncUrl, changePage } =
|
||||||
|
paged
|
||||||
const controls = computed(() => shown.value)
|
const controls = computed(() => shown.value)
|
||||||
const offline = computed(() => !online.value)
|
const offline = computed(() => !online.value)
|
||||||
|
|
||||||
const hasLoaded = ref(false)
|
|
||||||
const loadedOnce = ref(false)
|
|
||||||
const pendingPage = ref<number | null>(null)
|
|
||||||
const notice = ref<Notice | null>(null)
|
|
||||||
const heading = ref<HTMLElement | null>(null)
|
const heading = ref<HTMLElement | null>(null)
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const activeId = ref<number | null>(null)
|
const activeId = ref<number | null>(null)
|
||||||
@@ -123,8 +159,6 @@ let bulkDeletedTotal = 0
|
|||||||
let bulkFailureRevision = 0
|
let bulkFailureRevision = 0
|
||||||
let bulkFailureToastId = BULK_FAILED_TOAST_ID
|
let bulkFailureToastId = BULK_FAILED_TOAST_ID
|
||||||
let failureToastShown = false
|
let failureToastShown = false
|
||||||
let requestToken = 0
|
|
||||||
let syncedTarget: Record<string, string> | null = null
|
|
||||||
|
|
||||||
const activeItem = computed(
|
const activeItem = computed(
|
||||||
() => media.items.value.find((item) => item.id === activeId.value) ?? null,
|
() => media.items.value.find((item) => item.id === activeId.value) ?? null,
|
||||||
@@ -224,56 +258,6 @@ watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), {
|
|||||||
})
|
})
|
||||||
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
|
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
|
||||||
|
|
||||||
function sameOrderedQuery(current: LocationQuery, target: Record<string, string>) {
|
|
||||||
const flatten = (query: LocationQuery) =>
|
|
||||||
Object.entries(query).map(([key, value]) => [key, Array.isArray(value) ? value[0] : value])
|
|
||||||
return JSON.stringify(flatten(current)) === JSON.stringify(Object.entries(target))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncUrl(query: MediaControls, mode: UrlMode) {
|
|
||||||
if (route.path !== MEDIA_PATH) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const target = toRouteQuery(query)
|
|
||||||
if (sameOrderedQuery(route.query, target)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
syncedTarget = target
|
|
||||||
await router[mode]({ query: target })
|
|
||||||
}
|
|
||||||
|
|
||||||
function differsOnlyByPage(query: MediaControls, current: MediaControls) {
|
|
||||||
return sameMediaControls({ ...query, page: current.page }, current)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load(query: MediaControls, mode: UrlMode) {
|
|
||||||
const token = ++requestToken
|
|
||||||
desired.value = query
|
|
||||||
pendingPage.value = loadedOnce.value && !isFullSet(query) ? query.page : null
|
|
||||||
const keepOnError = loadedOnce.value && differsOnlyByPage(query, shown.value)
|
|
||||||
const outcome = await media.load(query, { keepOnError })
|
|
||||||
if (token !== requestToken || outcome.kind === 'stale') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pendingPage.value = null
|
|
||||||
hasLoaded.value = true
|
|
||||||
if (outcome.kind === 'failed') {
|
|
||||||
if (keepOnError && !outcome.handled) {
|
|
||||||
desired.value = shown.value
|
|
||||||
notice.value = { kind: 'error', query, mode }
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loadedOnce.value = true
|
|
||||||
const effective = { ...query, page: outcome.page }
|
|
||||||
selection.clear()
|
|
||||||
shown.value = effective
|
|
||||||
desired.value = effective
|
|
||||||
notice.value = outcome.redirected ? { kind: 'range' } : null
|
|
||||||
await syncUrl(effective, outcome.redirected ? 'replace' : mode)
|
|
||||||
selectPending()
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateControls(patch: Partial<MediaControls>, mode: UrlMode = 'replace') {
|
function updateControls(patch: Partial<MediaControls>, mode: UrlMode = 'replace') {
|
||||||
const changed = Object.entries(patch).some(
|
const changed = Object.entries(patch).some(
|
||||||
([key, value]) => desired.value[key as keyof MediaControls] !== value,
|
([key, value]) => desired.value[key as keyof MediaControls] !== value,
|
||||||
@@ -283,19 +267,13 @@ function updateControls(patch: Partial<MediaControls>, mode: UrlMode = 'replace'
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function changePage(page: number) {
|
|
||||||
load({ ...desired.value, page }, 'push')
|
|
||||||
}
|
|
||||||
|
|
||||||
function changeSize(size: number) {
|
function changeSize(size: number) {
|
||||||
storePageSize(MEDIA_PAGE_SIZE_KEY, size)
|
storePageSize(MEDIA_PAGE_SIZE_KEY, size)
|
||||||
load({ ...desired.value, size, page: 1 }, 'push')
|
paged.changeSize(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryNotice() {
|
function retryNotice() {
|
||||||
if (notice.value?.kind === 'error') {
|
paged.retryNotice()
|
||||||
load(notice.value.query, notice.value.mode)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFilters() {
|
function clearFilters() {
|
||||||
@@ -312,26 +290,6 @@ watch(activeItem, (item) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.query,
|
|
||||||
() => {
|
|
||||||
if (route.path !== MEDIA_PATH) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ownReplace = syncedTarget !== null && sameRouteQuery(route.query, syncedTarget)
|
|
||||||
syncedTarget = null
|
|
||||||
const next = parseMediaControls(route.query, !ownReplace)
|
|
||||||
if (hasLoaded.value && sameMediaControls(next, shown.value)) {
|
|
||||||
syncUrl(shown.value, 'replace')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (sameMediaControls(next, desired.value) && media.status.value === 'loading') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
load(next, 'replace')
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
load(shown.value, 'replace')
|
load(shown.value, 'replace')
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
+118
-79
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t } from '@/i18n'
|
import { t, formatNumber } from '@/i18n'
|
||||||
|
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
@@ -10,11 +10,16 @@ import DashboardShell from '@/components/DashboardShell.vue'
|
|||||||
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
|
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
|
||||||
import DirectoryBadge from '@/components/DirectoryBadge.vue'
|
import DirectoryBadge from '@/components/DirectoryBadge.vue'
|
||||||
import DirectoryList from '@/components/DirectoryList.vue'
|
import DirectoryList from '@/components/DirectoryList.vue'
|
||||||
|
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
|
||||||
|
import DirectoryNoResults from '@/components/DirectoryNoResults.vue'
|
||||||
|
import DirectoryPageNotice from '@/components/DirectoryPageNotice.vue'
|
||||||
|
import DirectoryPagination from '@/components/DirectoryPagination.vue'
|
||||||
import ReassignDialog, { type ReassignOption } from '@/components/ReassignDialog.vue'
|
import ReassignDialog, { type ReassignOption } from '@/components/ReassignDialog.vue'
|
||||||
import SiteFormPanel from '@/components/SiteFormPanel.vue'
|
import SiteFormPanel from '@/components/SiteFormPanel.vue'
|
||||||
import SitesForbidden from '@/components/SitesForbidden.vue'
|
import SitesForbidden from '@/components/SitesForbidden.vue'
|
||||||
import { useDirectoryCollection } from '@/composables/useDirectoryCollection'
|
|
||||||
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
|
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
|
||||||
|
import { useDirectoryListState } from '@/composables/useDirectoryListState'
|
||||||
|
import { useDirectoryPages } from '@/composables/useDirectoryPages'
|
||||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||||
import { useDirectoryView } from '@/composables/useDirectoryView'
|
import { useDirectoryView } from '@/composables/useDirectoryView'
|
||||||
import { useSites, type Site } from '@/composables/useSites'
|
import { useSites, type Site } from '@/composables/useSites'
|
||||||
@@ -26,7 +31,7 @@ import { useSessionStore } from '@/stores/session'
|
|||||||
|
|
||||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||||
const LIST_COLUMNS = 'minmax(0,1.1fr) minmax(0,1.5fr) 110px 44px'
|
const LIST_COLUMNS = 'minmax(0,1.1fr) minmax(0,1.5fr) 110px 44px'
|
||||||
const SKELETON_ROWS = 3
|
const INITIAL_SKELETON_ROWS = 3
|
||||||
|
|
||||||
type PanelTarget = { mode: 'add' } | { mode: 'edit'; site: Site }
|
type PanelTarget = { mode: 'add' } | { mode: 'edit'; site: Site }
|
||||||
|
|
||||||
@@ -37,41 +42,55 @@ const overlay = useLocalizedOverlay()
|
|||||||
const toasts = useDirectoryToasts()
|
const toasts = useDirectoryToasts()
|
||||||
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
|
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
|
||||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||||
const { listSites, setDefaultSite, deleteSite } = useSites()
|
const { listAllSites, listSitePage, setDefaultSite, deleteSite } = useSites()
|
||||||
const collection = useDirectoryCollection(listSites)
|
|
||||||
const removal = useDirectoryDelete<Site>()
|
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 sessionReady = computed(() => session.status === 'ready')
|
||||||
const isOwner = computed(() => session.isOwner)
|
const isOwner = computed(() => session.isOwner)
|
||||||
|
const list = useDirectoryPages<Site>({
|
||||||
|
loadPage: listSitePage,
|
||||||
|
ready: () => sessionReady.value && isOwner.value,
|
||||||
|
onLoaded: () => followLink(),
|
||||||
|
})
|
||||||
|
const { items: sites, total, shown, notice, searchInput, pageCount } = list
|
||||||
|
const loadState = list.state
|
||||||
|
const { target: deleteTarget, pending: deletePending, notice: deleteNotice } = removal
|
||||||
|
const allSites = ref<Site[]>([])
|
||||||
|
const panel = ref<PanelTarget | null>(null)
|
||||||
|
const destinationId = ref<number | undefined>(undefined)
|
||||||
|
|
||||||
const forbidden = computed(
|
const forbidden = computed(
|
||||||
() => (sessionReady.value && !isOwner.value) || loadState.value === 'forbidden',
|
() => (sessionReady.value && !isOwner.value) || loadState.value === 'forbidden',
|
||||||
)
|
)
|
||||||
const showList = computed(
|
const { noResults, showList, skeletonRows, showPaginator, singlePageLabel } = useDirectoryListState(
|
||||||
() => loadState.value === 'ready' || (loadState.value === 'loading' && showSkeleton.value),
|
{
|
||||||
|
list,
|
||||||
|
loadState,
|
||||||
|
isDesktop,
|
||||||
|
initialRows: INITIAL_SKELETON_ROWS,
|
||||||
|
singleLabel: (count) => (count === 1 ? t('1 site') : t('{0} sites', [formatNumber(count)])),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
const onlyDefault = computed(
|
const onlyDefault = computed(
|
||||||
() => loadState.value === 'ready' && sites.value.length === 1 && sites.value[0]!.is_default,
|
() =>
|
||||||
|
loadState.value === 'ready' &&
|
||||||
|
shown.value.q === '' &&
|
||||||
|
total.value === 1 &&
|
||||||
|
sites.value.length === 1 &&
|
||||||
|
sites.value[0]!.is_default,
|
||||||
)
|
)
|
||||||
const overlayOpen = computed(() => panel.value !== null || deleteTarget.value !== null)
|
const overlayOpen = computed(() => panel.value !== null || deleteTarget.value !== null)
|
||||||
const defaultSite = computed(() => sites.value.find((site) => site.is_default))
|
const defaultSite = computed(() => allSites.value.find((site) => site.is_default))
|
||||||
const panelSite = computed(() => {
|
const panelSite = computed(() => {
|
||||||
const target = panel.value
|
const target = panel.value
|
||||||
if (!target || target.mode === 'add') {
|
if (!target || target.mode === 'add') {
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
return sites.value.find((site) => site.id === target.site.id) ?? target.site
|
return (
|
||||||
|
allSites.value.find((site) => site.id === target.site.id) ??
|
||||||
|
sites.value.find((site) => site.id === target.site.id) ??
|
||||||
|
target.site
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const destinationOptions = computed<ReassignOption[]>(() => {
|
const destinationOptions = computed<ReassignOption[]>(() => {
|
||||||
@@ -79,7 +98,7 @@ const destinationOptions = computed<ReassignOption[]>(() => {
|
|||||||
if (!target || target.article_count === 0) {
|
if (!target || target.article_count === 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return sites.value
|
return allSites.value
|
||||||
.filter((site) => site.id !== target.id)
|
.filter((site) => site.id !== target.id)
|
||||||
.map((site) => ({
|
.map((site) => ({
|
||||||
value: site.id,
|
value: site.id,
|
||||||
@@ -88,7 +107,7 @@ const destinationOptions = computed<ReassignOption[]>(() => {
|
|||||||
display: `${site.name} · ${hostOf(site.base_url)}`,
|
display: `${site.name} · ${hostOf(site.base_url)}`,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
const destination = computed(() => sites.value.find((site) => site.id === destinationId.value))
|
const destination = computed(() => allSites.value.find((site) => site.id === destinationId.value))
|
||||||
|
|
||||||
const deleteDescription = computed(() => {
|
const deleteDescription = computed(() => {
|
||||||
const target = deleteTarget.value
|
const target = deleteTarget.value
|
||||||
@@ -142,24 +161,15 @@ const deletePendingLabel = computed(() =>
|
|||||||
: t('Deleting…'),
|
: t('Deleting…'),
|
||||||
)
|
)
|
||||||
|
|
||||||
async function loadSites() {
|
async function loadAllSites() {
|
||||||
const outcome = await collection.load([])
|
try {
|
||||||
if (outcome === 'ready') {
|
allSites.value = await listAllSites()
|
||||||
followLink()
|
} catch {
|
||||||
|
return allSites.value
|
||||||
}
|
}
|
||||||
|
return allSites.value
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
|
||||||
[sessionReady, isOwner],
|
|
||||||
([ready, owner]) => {
|
|
||||||
if (ready && owner && !requested) {
|
|
||||||
requested = true
|
|
||||||
loadSites()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query.edit,
|
() => route.query.edit,
|
||||||
() => {
|
() => {
|
||||||
@@ -174,7 +184,7 @@ function followLink() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
return followEditLink(
|
return followEditLink(
|
||||||
(key) => sites.value.find((candidate) => candidate.key === key) ?? null,
|
async (key) => (await loadAllSites()).find((candidate) => candidate.key === key) ?? null,
|
||||||
(site) => {
|
(site) => {
|
||||||
panel.value = { mode: 'edit', site }
|
panel.value = { mode: 'edit', site }
|
||||||
},
|
},
|
||||||
@@ -184,11 +194,13 @@ function followLink() {
|
|||||||
|
|
||||||
function openAdd() {
|
function openAdd() {
|
||||||
rememberOpener()
|
rememberOpener()
|
||||||
|
loadAllSites()
|
||||||
panel.value = { mode: 'add' }
|
panel.value = { mode: 'add' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(site: Site, trigger: HTMLElement | null) {
|
function openEdit(site: Site, trigger: HTMLElement | null) {
|
||||||
rememberOpener(trigger)
|
rememberOpener(trigger)
|
||||||
|
loadAllSites()
|
||||||
panel.value = { mode: 'edit', site }
|
panel.value = { mode: 'edit', site }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,28 +210,33 @@ function closePanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function upsert(site: Site) {
|
function upsert(site: Site) {
|
||||||
const exists = sites.value.some((candidate) => candidate.id === site.id)
|
const replaceSite = (candidate: Site) => (candidate.id === site.id ? site : candidate)
|
||||||
sites.value = exists
|
sites.value = sites.value.map(replaceSite)
|
||||||
? sites.value.map((candidate) => (candidate.id === site.id ? site : candidate))
|
allSites.value = allSites.value.some((candidate) => candidate.id === site.id)
|
||||||
: [...sites.value, site]
|
? allSites.value.map(replaceSite)
|
||||||
|
: [...allSites.value, site]
|
||||||
if (site.is_default) {
|
if (site.is_default) {
|
||||||
markDefault(site.id)
|
markDefault(site.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function markDefault(id: number) {
|
function markDefault(id: number) {
|
||||||
sites.value = sites.value.map((site) => ({
|
const mark = (site: Site) => ({ ...site, is_default: site.id === id })
|
||||||
...site,
|
sites.value = sites.value.map(mark)
|
||||||
is_default: site.id === id,
|
allSites.value = allSites.value.map(mark)
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeSite(id: number) {
|
function removeSite(id: number) {
|
||||||
sites.value = sites.value.filter((site) => site.id !== id)
|
allSites.value = allSites.value.filter((site) => site.id !== id)
|
||||||
|
list.removeById(id, (site) => site.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSaved(site: Site) {
|
function onSaved(site: Site) {
|
||||||
|
const added = panel.value?.mode === 'add'
|
||||||
upsert(site)
|
upsert(site)
|
||||||
|
if (added) {
|
||||||
|
list.reload()
|
||||||
|
}
|
||||||
closePanel()
|
closePanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,8 +293,9 @@ function confirmDefault(site: Site, trigger: HTMLElement | null) {
|
|||||||
.then(restoreFocus)
|
.then(restoreFocus)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDelete(site: Site, trigger: HTMLElement | null) {
|
async function openDelete(site: Site, trigger: HTMLElement | null) {
|
||||||
rememberOpener(trigger)
|
rememberOpener(trigger)
|
||||||
|
await loadAllSites()
|
||||||
removal.open(site)
|
removal.open(site)
|
||||||
const options = destinationOptions.value
|
const options = destinationOptions.value
|
||||||
const preferred = options.find((option) => option.value === defaultSite.value?.id)
|
const preferred = options.find((option) => option.value === defaultSite.value?.id)
|
||||||
@@ -291,8 +309,8 @@ function closeDelete() {
|
|||||||
|
|
||||||
async function refreshTarget(id: number) {
|
async function refreshTarget(id: number) {
|
||||||
try {
|
try {
|
||||||
sites.value = await listSites()
|
allSites.value = await listAllSites()
|
||||||
removal.target.value = sites.value.find((site) => site.id === id) ?? null
|
removal.target.value = allSites.value.find((site) => site.id === id) ?? null
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -304,6 +322,9 @@ async function runDelete(target: Site) {
|
|||||||
await deleteSite(target.id, to?.id)
|
await deleteSite(target.id, to?.id)
|
||||||
removeSite(target.id)
|
removeSite(target.id)
|
||||||
if (to) {
|
if (to) {
|
||||||
|
sites.value = sites.value.map((site) =>
|
||||||
|
site.id === to.id ? { ...site, article_count: site.article_count + moved } : site,
|
||||||
|
)
|
||||||
to.article_count += moved
|
to.article_count += moved
|
||||||
}
|
}
|
||||||
toasts.siteDeleted(moved, to?.name ?? '')
|
toasts.siteDeleted(moved, to?.name ?? '')
|
||||||
@@ -372,6 +393,7 @@ function menuItems(site: Site): DirectoryMenuItem[] {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noResultsTitle = computed(() => t('No sites match "{0}"', [shown.value.q]))
|
||||||
const headings = computed(() => [t('Site'), t('Base URL'), t('Articles'), ''])
|
const headings = computed(() => [t('Site'), t('Base URL'), t('Articles'), ''])
|
||||||
|
|
||||||
function backToSettings() {
|
function backToSettings() {
|
||||||
@@ -419,39 +441,42 @@ function backToSettings() {
|
|||||||
</DirectoryAddButton>
|
</DirectoryAddButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UAlert
|
<UInput
|
||||||
|
v-if="loadState === 'ready'"
|
||||||
|
v-model="searchInput"
|
||||||
|
type="search"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
:placeholder="t('Search by name or key')"
|
||||||
|
:aria-label="t('Search by name or key')"
|
||||||
|
class="w-full sm:w-70"
|
||||||
|
:ui="{ base: 'h-11 text-lg ps-8.25 sm:h-8 sm:text-sm', leadingIcon: 'size-3.5' }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DirectoryPageNotice
|
||||||
|
v-if="notice?.kind === 'range'"
|
||||||
|
:notice="notice"
|
||||||
|
:shown-page="shown.page"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DirectoryLoadError
|
||||||
v-if="loadState === 'error'"
|
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')"
|
:title="t('Couldn\'t load sites')"
|
||||||
:description="t('Check your connection and try again.')"
|
@retry="list.retry"
|
||||||
:ui="{ actions: 'mt-2' }"
|
/>
|
||||||
>
|
|
||||||
<template #actions>
|
<DirectoryNoResults
|
||||||
<UButton
|
v-else-if="noResults"
|
||||||
color="neutral"
|
:title="noResultsTitle"
|
||||||
variant="outline"
|
@clear="list.clearSearch"
|
||||||
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
|
<DirectoryList
|
||||||
v-if="showList"
|
v-else-if="showList"
|
||||||
:items="sites"
|
:items="sites"
|
||||||
:columns="LIST_COLUMNS"
|
:columns="LIST_COLUMNS"
|
||||||
:headings="headings"
|
:headings="headings"
|
||||||
:loading="loadState === 'loading'"
|
:loading="list.skeletonActive.value"
|
||||||
:skeleton-rows="SKELETON_ROWS"
|
:skeleton-rows="skeletonRows"
|
||||||
:label="t('Sites')"
|
:label="t('Sites')"
|
||||||
:row-title="(site: Site) => site.name"
|
:row-title="(site: Site) => site.name"
|
||||||
:menu-items="menuItems"
|
:menu-items="menuItems"
|
||||||
@@ -506,6 +531,20 @@ function backToSettings() {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<DirectoryPagination
|
||||||
|
:notice="notice"
|
||||||
|
:show-paginator="showPaginator"
|
||||||
|
:shown-page="shown.page"
|
||||||
|
:page-count="pageCount"
|
||||||
|
:total="total"
|
||||||
|
:size="shown.size"
|
||||||
|
:loading-page="list.pendingPage.value"
|
||||||
|
:single-page-label="singlePageLabel"
|
||||||
|
@retry="list.retry"
|
||||||
|
@update:page="list.changePage"
|
||||||
|
@update:size="list.changeSize"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DirectoryAddButton
|
<DirectoryAddButton
|
||||||
@@ -521,7 +560,7 @@ function backToSettings() {
|
|||||||
v-if="panel"
|
v-if="panel"
|
||||||
:key="panel.mode === 'edit' ? panel.site.id : 'add'"
|
:key="panel.mode === 'edit' ? panel.site.id : 'add'"
|
||||||
:site="panelSite"
|
:site="panelSite"
|
||||||
:sites="sites"
|
:sites="allSites"
|
||||||
@close="closePanel"
|
@close="closePanel"
|
||||||
@saved="onSaved"
|
@saved="onSaved"
|
||||||
@updated="upsert"
|
@updated="upsert"
|
||||||
|
|||||||
@@ -0,0 +1,771 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import router from '@/router'
|
||||||
|
import { MissingTotalError } from '@/composables/useApi'
|
||||||
|
import { setLocale } from '@/i18n'
|
||||||
|
import type { Author } from '@/composables/useAuthors'
|
||||||
|
import type { Site } from '@/composables/useSites'
|
||||||
|
import { DESKTOP_WIDTH, MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
|
import {
|
||||||
|
authorsFixture,
|
||||||
|
blur,
|
||||||
|
callsTo,
|
||||||
|
click,
|
||||||
|
createBackend,
|
||||||
|
dialog,
|
||||||
|
failWith,
|
||||||
|
field,
|
||||||
|
labelled,
|
||||||
|
mountDirectory,
|
||||||
|
networkFailure,
|
||||||
|
paged,
|
||||||
|
pending,
|
||||||
|
rows,
|
||||||
|
sitesFixture,
|
||||||
|
textOf,
|
||||||
|
typeInto,
|
||||||
|
unmountDirectory,
|
||||||
|
type Backend,
|
||||||
|
} from './directoryTestKit'
|
||||||
|
|
||||||
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
|
return { ...actual, useApi: vi.fn() }
|
||||||
|
})
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 120
|
||||||
|
const SKELETON_DELAY_MS = 150
|
||||||
|
|
||||||
|
interface Screen {
|
||||||
|
name: string
|
||||||
|
view: 'sites' | 'authors'
|
||||||
|
route: string
|
||||||
|
path: string
|
||||||
|
noun: string
|
||||||
|
nounOne: string
|
||||||
|
total: number
|
||||||
|
pages: number
|
||||||
|
middle: number
|
||||||
|
middleSummary: string
|
||||||
|
lastRows: number
|
||||||
|
lastSummary: string
|
||||||
|
searchTerm: string
|
||||||
|
searchPlaceholder: string
|
||||||
|
emptyTitle: string
|
||||||
|
emptyTerm: string
|
||||||
|
searchFrom: number
|
||||||
|
searchTotal: number
|
||||||
|
longPage: number
|
||||||
|
longTotal: number
|
||||||
|
longSummary: string
|
||||||
|
longLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCREENS: Screen[] = [
|
||||||
|
{
|
||||||
|
name: 'Sites',
|
||||||
|
view: 'sites',
|
||||||
|
route: '/settings/sites',
|
||||||
|
path: '/v1/site/',
|
||||||
|
noun: 'sites',
|
||||||
|
nounOne: 'site',
|
||||||
|
total: 61,
|
||||||
|
pages: 3,
|
||||||
|
middle: 2,
|
||||||
|
middleSummary: 'Showing 26–50 of 61',
|
||||||
|
lastRows: 11,
|
||||||
|
lastSummary: 'Showing 51–61 of 61',
|
||||||
|
searchTerm: 'renew',
|
||||||
|
searchPlaceholder: 'Search by name or key',
|
||||||
|
emptyTitle: 'No sites match "blog"',
|
||||||
|
emptyTerm: 'blog',
|
||||||
|
searchFrom: 4,
|
||||||
|
searchTotal: 27,
|
||||||
|
longPage: 6,
|
||||||
|
longTotal: 312,
|
||||||
|
longSummary: 'Showing 126–150 of 312',
|
||||||
|
longLabel: 'Page 6 of 13',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Authors',
|
||||||
|
view: 'authors',
|
||||||
|
route: '/authors',
|
||||||
|
path: '/v1/author/',
|
||||||
|
noun: 'authors',
|
||||||
|
nounOne: 'author',
|
||||||
|
total: 128,
|
||||||
|
pages: 6,
|
||||||
|
middle: 3,
|
||||||
|
middleSummary: 'Showing 51–75 of 128',
|
||||||
|
lastRows: 3,
|
||||||
|
lastSummary: 'Showing 126–128 of 128',
|
||||||
|
searchTerm: 'mar',
|
||||||
|
searchPlaceholder: 'Search by name or slug',
|
||||||
|
emptyTitle: 'No authors match "zoe"',
|
||||||
|
emptyTerm: 'zoe',
|
||||||
|
searchFrom: 5,
|
||||||
|
searchTotal: 31,
|
||||||
|
longPage: 12,
|
||||||
|
longTotal: 1284,
|
||||||
|
longSummary: 'Showing 276–300 of 1,284',
|
||||||
|
longLabel: 'Page 12 of 52',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function generatedSites(count: number, prefix = 'Site'): Site[] {
|
||||||
|
const template = sitesFixture()[0]!
|
||||||
|
return Array.from({ length: count }, (_, index) => ({
|
||||||
|
...template,
|
||||||
|
id: index + 1,
|
||||||
|
name: `${prefix} ${index + 1}`,
|
||||||
|
key: `${prefix.toLowerCase()}-${index + 1}`,
|
||||||
|
base_url: `https://${prefix.toLowerCase()}-${index + 1}.example`,
|
||||||
|
is_default: index === 0,
|
||||||
|
article_count: index,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatedAuthors(count: number, prefix = 'Author'): Author[] {
|
||||||
|
const template = authorsFixture()[1]!
|
||||||
|
return Array.from({ length: count }, (_, index) => ({
|
||||||
|
...template,
|
||||||
|
id: index + 1,
|
||||||
|
name: `${prefix} ${index + 1}`,
|
||||||
|
slug: `${prefix.toLowerCase()}-${index + 1}`,
|
||||||
|
user_id: null,
|
||||||
|
is_linked: false,
|
||||||
|
article_count: 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function backendFor(screen: Screen, total = screen.total): Backend {
|
||||||
|
const backend = createBackend()
|
||||||
|
if (screen.view === 'sites') {
|
||||||
|
backend.sites = generatedSites(total)
|
||||||
|
} else {
|
||||||
|
backend.authors = generatedAuthors(total)
|
||||||
|
}
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
function listCalls(backend: Backend, screen: Screen) {
|
||||||
|
return callsTo(backend, 'GET', screen.path).filter((call) => call.query.has('page'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function unpagedCalls(backend: Backend, screen: Screen) {
|
||||||
|
return callsTo(backend, 'GET', screen.path).filter((call) => !call.query.has('page'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function summary() {
|
||||||
|
return document.body.querySelector('[data-testid="paginator-summary"]')?.textContent?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLine() {
|
||||||
|
return document.body.querySelector('[data-testid="paginator-count"]')?.textContent?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function control(label: string) {
|
||||||
|
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageNav() {
|
||||||
|
return document.body.querySelector('nav[aria-label="Pagination"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPage() {
|
||||||
|
return pageNav()?.querySelector('[aria-current="page"]')?.textContent?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sizeTrigger() {
|
||||||
|
return document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertText() {
|
||||||
|
return textOf(document.body.querySelector('[role="alert"]'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseSize(label: string) {
|
||||||
|
sizeTrigger()?.click()
|
||||||
|
await flushPromises()
|
||||||
|
const option = Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]')).find(
|
||||||
|
(element) => element.textContent?.trim() === label,
|
||||||
|
)
|
||||||
|
option!.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchFor(term: string) {
|
||||||
|
const input = document.body.querySelector<HTMLInputElement>('input[type="search"]')!
|
||||||
|
await typeInto(input, term)
|
||||||
|
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS)
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
setLocale('en')
|
||||||
|
await unmountDirectory()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.each(SCREENS)('$name: pagination', (screen) => {
|
||||||
|
it('1 First page', async () => {
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
await mountDirectory(screen.view, screen.route, { backend })
|
||||||
|
|
||||||
|
expect(listCalls(backend, screen).map((call) => call.raw)).toEqual([
|
||||||
|
`${screen.path}?page=1&size=25`,
|
||||||
|
])
|
||||||
|
expect(summary()).toBe(`Showing 1–25 of ${screen.total}`)
|
||||||
|
expect(rows()).toHaveLength(25)
|
||||||
|
expect(sizeTrigger()?.textContent).toContain('25 per page')
|
||||||
|
expect(control('First page')?.disabled).toBe(true)
|
||||||
|
expect(control('Previous page')?.disabled).toBe(true)
|
||||||
|
expect(control('Next page')?.disabled).toBe(false)
|
||||||
|
expect(control('Last page')?.disabled).toBe(false)
|
||||||
|
expect(currentPage()).toBe('1')
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(screen.route)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('1 First page, below md: Page 1 of N with Previous disabled', async () => {
|
||||||
|
await mountDirectory(screen.view, screen.route, {
|
||||||
|
backend: backendFor(screen),
|
||||||
|
width: MOBILE_WIDTH,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(textOf(pageNav())).toContain(`Page 1 of ${screen.pages}`)
|
||||||
|
expect(control('Previous page')?.disabled).toBe(true)
|
||||||
|
expect(control('Next page')?.disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('2 Middle page', async () => {
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
await mountDirectory(screen.view, `${screen.route}?page=${screen.middle}`, { backend })
|
||||||
|
|
||||||
|
expect(summary()).toBe(screen.middleSummary)
|
||||||
|
expect(currentPage()).toBe(String(screen.middle))
|
||||||
|
for (const label of ['First page', 'Previous page', 'Next page', 'Last page']) {
|
||||||
|
expect(control(label)?.disabled).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('3 Last page', async () => {
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
await mountDirectory(screen.view, `${screen.route}?page=${screen.pages}`, { backend })
|
||||||
|
|
||||||
|
expect(summary()).toBe(screen.lastSummary)
|
||||||
|
expect(rows()).toHaveLength(screen.lastRows)
|
||||||
|
expect(control('Next page')?.disabled).toBe(true)
|
||||||
|
expect(control('Last page')?.disabled).toBe(true)
|
||||||
|
expect(control('Previous page')?.disabled).toBe(false)
|
||||||
|
expect(control('First page')?.disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4 Single page', async () => {
|
||||||
|
const total = screen.view === 'sites' ? 4 : 6
|
||||||
|
await mountDirectory(screen.view, screen.route, { backend: backendFor(screen, total) })
|
||||||
|
|
||||||
|
expect(countLine()).toBe(`${total} ${screen.noun}`)
|
||||||
|
expect(pageNav()).toBeNull()
|
||||||
|
expect(sizeTrigger()).toBeNull()
|
||||||
|
expect(summary()).toBeUndefined()
|
||||||
|
expect(document.body.textContent).not.toContain('Showing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4b Singular count line', async () => {
|
||||||
|
await mountDirectory(screen.view, screen.route, { backend: backendFor(screen, 1) })
|
||||||
|
|
||||||
|
expect(countLine()).toBe(`1 ${screen.nounOne}`)
|
||||||
|
expect(sizeTrigger()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4b Count line in French', async () => {
|
||||||
|
setLocale('fr')
|
||||||
|
const total = 6
|
||||||
|
await mountDirectory(screen.view, screen.route, { backend: backendFor(screen, total) })
|
||||||
|
|
||||||
|
expect(countLine()).toBe(screen.view === 'sites' ? '6 sites' : '6 auteurs')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4c Five per page with a few rows still pages', async () => {
|
||||||
|
const backend = backendFor(screen, 6)
|
||||||
|
await mountDirectory(screen.view, `${screen.route}?size=5`, { backend })
|
||||||
|
|
||||||
|
expect(listCalls(backend, screen)[0]!.raw).toBe(`${screen.path}?page=1&size=5`)
|
||||||
|
expect(summary()).toBe('Showing 1–5 of 6')
|
||||||
|
expect(sizeTrigger()?.textContent).toContain('5 per page')
|
||||||
|
expect(pageNav()?.querySelectorAll('button[aria-current]')).toHaveLength(1)
|
||||||
|
expect(control('Next page')?.disabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['5 per page', 5, 'size=5'],
|
||||||
|
['25 per page', 25, ''],
|
||||||
|
['50 per page', 50, 'size=50'],
|
||||||
|
['100 per page', 100, 'size=100'],
|
||||||
|
])('5 Page size menu: %s', async (label, size, search) => {
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
await mountDirectory(screen.view, `${screen.route}?page=${screen.middle}`, { backend })
|
||||||
|
|
||||||
|
await chooseSize(label)
|
||||||
|
|
||||||
|
expect(listCalls(backend, screen).at(-1)!.raw).toBe(`${screen.path}?page=1&size=${size}`)
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(
|
||||||
|
search ? `${screen.route}?${search}` : screen.route,
|
||||||
|
)
|
||||||
|
expect(router.currentRoute.value.query.page).toBeUndefined()
|
||||||
|
expect(pageNav() === null || currentPage() === '1').toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('5 Page size menu: ArrowDown opens, Escape closes', async () => {
|
||||||
|
await mountDirectory(screen.view, screen.route, { backend: backendFor(screen) })
|
||||||
|
const trigger = sizeTrigger()!
|
||||||
|
trigger.focus()
|
||||||
|
|
||||||
|
trigger.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
|
||||||
|
)
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.querySelector('[role="listbox"]')).not.toBeNull()
|
||||||
|
;(document.activeElement ?? document.body).dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
|
||||||
|
)
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['desktop', DESKTOP_WIDTH, 8],
|
||||||
|
['below md', MOBILE_WIDTH, 6],
|
||||||
|
])('6 Loading a page on %s', async (_name, width, skeletonRows) => {
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
await mountDirectory(screen.view, `${screen.route}?page=${screen.middle}`, {
|
||||||
|
backend,
|
||||||
|
width,
|
||||||
|
})
|
||||||
|
const next = screen.middle + 1
|
||||||
|
const hold = pending<unknown>()
|
||||||
|
backend.responders[`GET ${screen.path}?page=${next}&size=25`] = () => hold.promise
|
||||||
|
|
||||||
|
control('Next page')!.click()
|
||||||
|
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
|
||||||
|
).toHaveLength(skeletonRows)
|
||||||
|
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||||
|
expect(summary()).toBe(`Loading page ${next}…`)
|
||||||
|
expect(sizeTrigger()?.hasAttribute('disabled')).toBe(true)
|
||||||
|
expect(control('Next page')?.disabled).toBe(true)
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=${screen.middle}`)
|
||||||
|
if (width === DESKTOP_WIDTH) {
|
||||||
|
expect(currentPage()).toBe(String(screen.middle))
|
||||||
|
} else {
|
||||||
|
expect(textOf(pageNav())).toContain(`Page ${screen.middle} of`)
|
||||||
|
}
|
||||||
|
|
||||||
|
hold.resolve(
|
||||||
|
paged(
|
||||||
|
screen.view === 'sites'
|
||||||
|
? generatedSites(25, 'Next')
|
||||||
|
: { items: generatedAuthors(25, 'Next'), total: screen.total },
|
||||||
|
screen.total,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=${next}`)
|
||||||
|
expect(rows()).toHaveLength(25)
|
||||||
|
expect(summary()).toContain(`of ${screen.total}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a 500 response', failWith(500)],
|
||||||
|
['a network failure', networkFailure],
|
||||||
|
])('7 Error loading a page with %s keeps the previous rows', async (_name, failure) => {
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
await mountDirectory(screen.view, `${screen.route}?page=${screen.middle}`, { backend })
|
||||||
|
const next = screen.middle + 1
|
||||||
|
backend.responders[`GET ${screen.path}?page=${next}&size=25`] = failure
|
||||||
|
|
||||||
|
control('Next page')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(rows()).toHaveLength(25)
|
||||||
|
expect(currentPage()).toBe(String(screen.middle))
|
||||||
|
expect(control('Next page')?.disabled).toBe(false)
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=${screen.middle}`)
|
||||||
|
expect(alertText()).toContain(`Couldn't load page ${next}. Showing page ${screen.middle}.`)
|
||||||
|
expect(alertText()).toContain('Check your connection and try again.')
|
||||||
|
|
||||||
|
delete backend.responders[`GET ${screen.path}?page=${next}&size=25`]
|
||||||
|
await click('Retry')
|
||||||
|
|
||||||
|
expect(listCalls(backend, screen).at(-1)!.raw).toBe(`${screen.path}?page=${next}&size=25`)
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=${next}`)
|
||||||
|
expect(currentPage()).toBe(String(next))
|
||||||
|
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('7 A first-load failure keeps its own alert', async () => {
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
backend.responders[`GET ${screen.path}`] = failWith(500)
|
||||||
|
await mountDirectory(screen.view, screen.route, { backend })
|
||||||
|
|
||||||
|
expect(alertText()).toContain(`Couldn't load ${screen.noun}`)
|
||||||
|
expect(alertText()).toContain('Try again')
|
||||||
|
expect(rows()).toHaveLength(0)
|
||||||
|
expect(summary()).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('7 A response without X-Total-Count is a failed load', async () => {
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
backend.responders[`GET ${screen.path}`] = () => {
|
||||||
|
throw new MissingTotalError()
|
||||||
|
}
|
||||||
|
await mountDirectory(screen.view, screen.route, { backend })
|
||||||
|
|
||||||
|
expect(alertText()).toContain(`Couldn't load ${screen.noun}`)
|
||||||
|
expect(rows()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('8 Empty after search', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
await mountDirectory(screen.view, screen.route, { backend })
|
||||||
|
backend.responders[`GET ${screen.path}?q=${screen.emptyTerm}&page=1&size=25`] = () =>
|
||||||
|
screen.view === 'sites' ? paged([], 0) : { items: [], total: 0, page: 1, size: 25 }
|
||||||
|
|
||||||
|
await searchFor(screen.emptyTerm)
|
||||||
|
|
||||||
|
const text = textOf(document.body)
|
||||||
|
expect(text).toContain(screen.emptyTitle)
|
||||||
|
expect(text).toContain('Check the spelling, or search for a shorter part of the name.')
|
||||||
|
expect(text).toContain('Clear search')
|
||||||
|
expect(summary()).toBeUndefined()
|
||||||
|
expect(countLine()).toBeUndefined()
|
||||||
|
expect(pageNav()).toBeNull()
|
||||||
|
|
||||||
|
delete backend.responders[`GET ${screen.path}?q=${screen.emptyTerm}&page=1&size=25`]
|
||||||
|
await click('Clear search')
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(screen.route)
|
||||||
|
expect(listCalls(backend, screen).at(-1)!.raw).toBe(`${screen.path}?page=1&size=25`)
|
||||||
|
expect(rows().length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('9 Search resets to page 1', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
await mountDirectory(screen.view, `${screen.route}?page=${screen.searchFrom}`, {
|
||||||
|
backend,
|
||||||
|
})
|
||||||
|
const matches = screen.searchTotal
|
||||||
|
backend.responders[`GET ${screen.path}?q=${screen.searchTerm}&page=1&size=25`] = () =>
|
||||||
|
screen.view === 'sites'
|
||||||
|
? paged(generatedSites(25, 'Match'), matches)
|
||||||
|
: { items: generatedAuthors(25, 'Match'), total: matches, page: 1, size: 25 }
|
||||||
|
|
||||||
|
await searchFor(screen.searchTerm)
|
||||||
|
|
||||||
|
expect(listCalls(backend, screen).at(-1)!.raw).toBe(
|
||||||
|
`${screen.path}?q=${screen.searchTerm}&page=1&size=25`,
|
||||||
|
)
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?q=${screen.searchTerm}`)
|
||||||
|
expect(summary()).toBe(`Showing 1–25 of ${matches}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('9 A size change resets the page', async () => {
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
await mountDirectory(screen.view, `${screen.route}?page=${screen.middle}`, { backend })
|
||||||
|
|
||||||
|
await chooseSize('50 per page')
|
||||||
|
|
||||||
|
expect(listCalls(backend, screen).at(-1)!.raw).toBe(`${screen.path}?page=1&size=50`)
|
||||||
|
expect(router.currentRoute.value.query.page).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('10 Long labels on mobile', async () => {
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
backend.responders[`GET ${screen.path}?page=${screen.longPage}&size=25`] = () =>
|
||||||
|
screen.view === 'sites'
|
||||||
|
? paged(generatedSites(25, 'Long'), screen.longTotal)
|
||||||
|
: {
|
||||||
|
items: generatedAuthors(25, 'Long'),
|
||||||
|
total: screen.longTotal,
|
||||||
|
page: screen.longPage,
|
||||||
|
size: 25,
|
||||||
|
}
|
||||||
|
await mountDirectory(screen.view, `${screen.route}?page=${screen.longPage}`, {
|
||||||
|
backend,
|
||||||
|
width: MOBILE_WIDTH,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(summary()).toBe(screen.longSummary)
|
||||||
|
const label = Array.from(pageNav()!.querySelectorAll('span')).find(
|
||||||
|
(element) => element.textContent?.trim() === screen.longLabel,
|
||||||
|
)!
|
||||||
|
expect(label.className).toContain('whitespace-nowrap')
|
||||||
|
expect(control('Previous page')?.className).toContain('flex-1')
|
||||||
|
expect(control('Next page')?.className).toContain('flex-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe.each(SCREENS)('$name: URL rules', (screen) => {
|
||||||
|
it('Invalid values fall back silently', async () => {
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
const replace = vi.spyOn(router, 'replace')
|
||||||
|
const push = vi.spyOn(router, 'push')
|
||||||
|
await mountDirectory(screen.view, `${screen.route}?size=30&page=abc`, { backend })
|
||||||
|
const pushesAfterMount = push.mock.calls.length
|
||||||
|
|
||||||
|
expect(listCalls(backend, screen).map((call) => call.raw)).toEqual([
|
||||||
|
`${screen.path}?page=1&size=25`,
|
||||||
|
])
|
||||||
|
expect(replace).toHaveBeenCalledWith({ query: {} })
|
||||||
|
expect(push.mock.calls.length).toBe(pushesAfterMount)
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(screen.route)
|
||||||
|
expect(document.body.querySelector('[role="status"]')).toBeNull()
|
||||||
|
replace.mockRestore()
|
||||||
|
push.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['0', '-2'])('Page %s falls back to page 1', async (page) => {
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
await mountDirectory(screen.view, `${screen.route}?page=${page}`, { backend })
|
||||||
|
|
||||||
|
expect(listCalls(backend, screen)[0]!.raw).toBe(`${screen.path}?page=1&size=25`)
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(screen.route)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('A page out of range goes to the last page with a notice', async () => {
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
const replace = vi.spyOn(router, 'replace')
|
||||||
|
await mountDirectory(screen.view, `${screen.route}?page=99`, { backend })
|
||||||
|
|
||||||
|
expect(listCalls(backend, screen).map((call) => call.raw)).toEqual([
|
||||||
|
`${screen.path}?page=99&size=25`,
|
||||||
|
`${screen.path}?page=${screen.pages}&size=25`,
|
||||||
|
])
|
||||||
|
expect(replace).toHaveBeenCalledWith({ query: { page: String(screen.pages) } })
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=${screen.pages}`)
|
||||||
|
expect(textOf(document.body.querySelector('[role="status"]'))).toContain(
|
||||||
|
"That page doesn't exist. Showing the last page.",
|
||||||
|
)
|
||||||
|
expect(summary()).toBe(screen.lastSummary)
|
||||||
|
replace.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('A page out of range with a total of 0 shows the empty state', async () => {
|
||||||
|
const backend = backendFor(screen, 0)
|
||||||
|
await mountDirectory(screen.view, `${screen.route}?page=5`, { backend })
|
||||||
|
|
||||||
|
expect(listCalls(backend, screen)).toHaveLength(1)
|
||||||
|
expect(document.body.querySelector('[role="status"]')).toBeNull()
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(screen.route)
|
||||||
|
expect(rows()).toHaveLength(0)
|
||||||
|
expect(pageNav()).toBeNull()
|
||||||
|
expect(countLine()).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Combined q and page in the URL', async () => {
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
await mountDirectory(screen.view, `${screen.route}?q=${screen.searchTerm}&page=2`, {
|
||||||
|
backend,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(listCalls(backend, screen)[0]!.raw).toBe(
|
||||||
|
`${screen.path}?q=${screen.searchTerm}&page=2&size=25`,
|
||||||
|
)
|
||||||
|
expect(document.body.querySelector<HTMLInputElement>('input[type="search"]')!.value).toBe(
|
||||||
|
screen.searchTerm,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Browser Back returns to the previous page of results', async () => {
|
||||||
|
const backend = backendFor(screen)
|
||||||
|
await mountDirectory(screen.view, screen.route, { backend })
|
||||||
|
control('Next page')!.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(`${screen.route}?page=2`)
|
||||||
|
|
||||||
|
router.back()
|
||||||
|
await flushPromises()
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe(screen.route)
|
||||||
|
expect(listCalls(backend, screen).at(-1)!.raw).toBe(`${screen.path}?page=1&size=25`)
|
||||||
|
expect(currentPage()).toBe('1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Sites: search', () => {
|
||||||
|
it('shows the Sites search field and sends q', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||||
|
const backend = backendFor(SCREENS[0]!)
|
||||||
|
await mountDirectory('sites', '/settings/sites', { backend })
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('input[type="search"]')?.getAttribute('placeholder'),
|
||||||
|
).toBe('Search by name or key')
|
||||||
|
|
||||||
|
await searchFor('renew')
|
||||||
|
|
||||||
|
expect(listCalls(backend, SCREENS[0]!).at(-1)!.raw).toBe('/v1/site/?q=renew&page=1&size=25')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a French placeholder', async () => {
|
||||||
|
setLocale('fr')
|
||||||
|
await mountDirectory('sites', '/settings/sites', { backend: backendFor(SCREENS[0]!) })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('input[type="search"]')?.getAttribute('placeholder'),
|
||||||
|
).toBe('Rechercher par nom ou clé')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a search that matches only one site lists what the API returns', async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||||
|
const backend = backendFor(SCREENS[0]!)
|
||||||
|
backend.responders['GET /v1/site/?q=journal&page=1&size=25'] = () =>
|
||||||
|
paged([{ ...generatedSites(1)[0]!, name: 'Journal', is_default: false }], 1)
|
||||||
|
await mountDirectory('sites', '/settings/sites', { backend })
|
||||||
|
|
||||||
|
await searchFor('journal')
|
||||||
|
|
||||||
|
expect(rows()).toHaveLength(1)
|
||||||
|
expect(textOf(rows()[0])).toContain('Journal')
|
||||||
|
expect(countLine()).toBe('1 site')
|
||||||
|
expect(document.body.textContent).not.toContain('Add another site')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Sites: the unpaged list feeds the dependents', () => {
|
||||||
|
it('?edit=journal reads GET /v1/site/ without page and size', async () => {
|
||||||
|
const backend = backendFor(SCREENS[0]!)
|
||||||
|
backend.sites = [
|
||||||
|
...generatedSites(60),
|
||||||
|
{
|
||||||
|
...generatedSites(1)[0]!,
|
||||||
|
id: 900,
|
||||||
|
name: 'Journal',
|
||||||
|
key: 'journal',
|
||||||
|
is_default: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
await mountDirectory('sites', '/settings/sites?page=2&edit=journal', { backend })
|
||||||
|
|
||||||
|
expect(unpagedCalls(backend, SCREENS[0]!)).toHaveLength(1)
|
||||||
|
expect(unpagedCalls(backend, SCREENS[0]!)[0]!.raw).toBe('/v1/site/')
|
||||||
|
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull()
|
||||||
|
expect(textOf(document.body.querySelector('[role="dialog"]'))).toContain('Edit Journal')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the add form checks duplicate keys against the unpaged list', async () => {
|
||||||
|
const backend = backendFor(SCREENS[0]!)
|
||||||
|
backend.sites = [
|
||||||
|
...generatedSites(60),
|
||||||
|
{
|
||||||
|
...generatedSites(1)[0]!,
|
||||||
|
id: 900,
|
||||||
|
name: 'Journal',
|
||||||
|
key: 'journal',
|
||||||
|
is_default: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
await mountDirectory('sites', '/settings/sites?page=3', { backend })
|
||||||
|
|
||||||
|
await click('Add site')
|
||||||
|
await typeInto(field('e.g. Journal'), 'Journal')
|
||||||
|
await typeInto(field('https://blog.example.com'), 'https://journal2.example')
|
||||||
|
const key = labelled('Key') as HTMLInputElement
|
||||||
|
await blur(key)
|
||||||
|
|
||||||
|
expect(unpagedCalls(backend, SCREENS[0]!)).toHaveLength(1)
|
||||||
|
expect(textOf(dialog())).toContain(
|
||||||
|
'A site with the key "journal" already exists. Choose another key.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the delete dialog offers destinations from the unpaged list', async () => {
|
||||||
|
const backend = backendFor(SCREENS[0]!)
|
||||||
|
await mountDirectory('sites', '/settings/sites?page=2', { backend })
|
||||||
|
const before = unpagedCalls(backend, SCREENS[0]!).length
|
||||||
|
|
||||||
|
const trigger = document.body.querySelector<HTMLElement>('[aria-label^="Actions for"]')!
|
||||||
|
trigger.click()
|
||||||
|
await flushPromises()
|
||||||
|
const remove = Array.from(
|
||||||
|
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||||
|
).find((item) => textOf(item) === 'Delete…')!
|
||||||
|
remove.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(unpagedCalls(backend, SCREENS[0]!).length).toBe(before + 1)
|
||||||
|
expect(unpagedCalls(backend, SCREENS[0]!).at(-1)!.raw).toBe('/v1/site/')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Authors: roles', () => {
|
||||||
|
it('member sees the same footer with no Add author button and no row menu', async () => {
|
||||||
|
await mountDirectory('authors', '/authors?page=2', {
|
||||||
|
backend: backendFor(SCREENS[1]!),
|
||||||
|
role: 'member',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(summary()).toBe('Showing 26–50 of 128')
|
||||||
|
expect(currentPage()).toBe('2')
|
||||||
|
expect(document.body.textContent).not.toContain('Add author')
|
||||||
|
expect(document.body.querySelector('[aria-label^="Actions for"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('admin sees the same footer with the Add author button and the row menu', async () => {
|
||||||
|
await mountDirectory('authors', '/authors?page=2', {
|
||||||
|
backend: backendFor(SCREENS[1]!),
|
||||||
|
role: 'admin',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(summary()).toBe('Showing 26–50 of 128')
|
||||||
|
expect(currentPage()).toBe('2')
|
||||||
|
expect(document.body.textContent).toContain('Add author')
|
||||||
|
expect(document.body.querySelector('[aria-label^="Actions for"]')).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Authors: list changes', () => {
|
||||||
|
it('deleting the last row of the last page requests the previous page', async () => {
|
||||||
|
const backend = backendFor(SCREENS[1]!, 26)
|
||||||
|
await mountDirectory('authors', '/authors?page=2', { backend })
|
||||||
|
expect(rows()).toHaveLength(1)
|
||||||
|
|
||||||
|
const trigger = document.body.querySelector<HTMLElement>('[aria-label^="Actions for"]')!
|
||||||
|
trigger.click()
|
||||||
|
await flushPromises()
|
||||||
|
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||||
|
.find((item) => textOf(item) === 'Delete…')!
|
||||||
|
.click()
|
||||||
|
await flushPromises()
|
||||||
|
const confirm = Array.from(document.body.querySelectorAll<HTMLElement>('button')).find(
|
||||||
|
(item) => textOf(item) === 'Delete author',
|
||||||
|
)!
|
||||||
|
confirm.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(listCalls(backend, SCREENS[1]!).at(-1)!.raw).toBe('/v1/author/?page=1&size=25')
|
||||||
|
expect(router.currentRoute.value.fullPath).toBe('/authors')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a 26th row turns the count line into the pager', async () => {
|
||||||
|
const backend = backendFor(SCREENS[1]!, 25)
|
||||||
|
await mountDirectory('authors', '/authors', { backend })
|
||||||
|
expect(countLine()).toBe('25 authors')
|
||||||
|
|
||||||
|
await click('Add author')
|
||||||
|
const form = dialog()!
|
||||||
|
await typeInto(field('e.g. Camille Roche'), 'Zoe Lambert')
|
||||||
|
await click('Add author', form)
|
||||||
|
|
||||||
|
expect(summary()).toBe('Showing 1–25 of 26')
|
||||||
|
expect(countLine()).toBeUndefined()
|
||||||
|
expect(pageNav()).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -184,17 +184,19 @@ describe('Sites form: add succeeds', () => {
|
|||||||
|
|
||||||
expect(button('Saving…', box())).toBeTruthy()
|
expect(button('Saving…', box())).toBeTruthy()
|
||||||
expect(field('e.g. Journal').disabled).toBe(true)
|
expect(field('e.g. Journal').disabled).toBe(true)
|
||||||
hold.resolve({
|
const created = {
|
||||||
id: 5,
|
id: 5,
|
||||||
name: 'Notes',
|
name: 'Notes',
|
||||||
key: 'notes',
|
key: 'notes',
|
||||||
base_url: 'https://notes.example',
|
base_url: 'https://notes.example',
|
||||||
default_social_image_id: null,
|
default_social_image_id: null,
|
||||||
default_robots: 'noindex',
|
default_robots: 'noindex' as const,
|
||||||
locale: null,
|
locale: null,
|
||||||
is_default: false,
|
is_default: false,
|
||||||
article_count: 0,
|
article_count: 0,
|
||||||
})
|
}
|
||||||
|
backend.sites.push(created)
|
||||||
|
hold.resolve(created)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const posts = callsTo(backend, 'POST', '/v1/site/')
|
const posts = callsTo(backend, 'POST', '/v1/site/')
|
||||||
|
|||||||
@@ -21,6 +21,20 @@ export interface Call {
|
|||||||
body: Record<string, unknown> | undefined
|
body: Record<string, unknown> | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PagedReply {
|
||||||
|
body: unknown
|
||||||
|
total: number
|
||||||
|
|
||||||
|
constructor(body: unknown, total: number) {
|
||||||
|
this.body = body
|
||||||
|
this.total = total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paged(body: unknown, total: number) {
|
||||||
|
return new PagedReply(body, total)
|
||||||
|
}
|
||||||
|
|
||||||
export type Responder = (call: Call) => unknown | Promise<unknown>
|
export type Responder = (call: Call) => unknown | Promise<unknown>
|
||||||
|
|
||||||
export interface Backend {
|
export interface Backend {
|
||||||
@@ -177,12 +191,25 @@ function pageOf(backend: Backend, query: URLSearchParams) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sitePageOf(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.sites.filter((item) =>
|
||||||
|
`${item.name} ${item.key} ${item.base_url}`.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
return new PagedReply(
|
||||||
|
structuredClone(matching.slice((page - 1) * size, page * size)),
|
||||||
|
matching.length,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function defaultRoute(backend: Backend, call: Call): unknown {
|
export function defaultRoute(backend: Backend, call: Call): unknown {
|
||||||
const { method, path, query, body } = call
|
const { method, path, query, body } = call
|
||||||
const siteMatch = /^\/v1\/site\/(\d+)(\/default)?$/.exec(path)
|
const siteMatch = /^\/v1\/site\/(\d+)(\/default)?$/.exec(path)
|
||||||
const authorMatch = /^\/v1\/author\/(\d+)$/.exec(path)
|
const authorMatch = /^\/v1\/author\/(\d+)$/.exec(path)
|
||||||
if (method === 'GET' && path === '/v1/site/') {
|
if (method === 'GET' && path === '/v1/site/') {
|
||||||
return structuredClone(backend.sites)
|
return query.has('page') ? sitePageOf(backend, query) : structuredClone(backend.sites)
|
||||||
}
|
}
|
||||||
if (method === 'POST' && path === '/v1/site/') {
|
if (method === 'POST' && path === '/v1/site/') {
|
||||||
if (backend.sites.some((site) => site.key === body?.key)) {
|
if (backend.sites.some((site) => site.key === body?.key)) {
|
||||||
@@ -349,9 +376,20 @@ export async function mountDirectory(
|
|||||||
backend.responders[`${call.method} ${pathname}`]
|
backend.responders[`${call.method} ${pathname}`]
|
||||||
return responder ? responder(call) : defaultRoute(backend, call)
|
return responder ? responder(call) : defaultRoute(backend, call)
|
||||||
})
|
})
|
||||||
|
const requestPaged = vi.fn(async (path: string, init?: RequestInit) => {
|
||||||
|
const result = await request(path, init)
|
||||||
|
if (result instanceof PagedReply) {
|
||||||
|
return { body: result.body, total: result.total }
|
||||||
|
}
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
return { body: result, total: result.length }
|
||||||
|
}
|
||||||
|
return { body: result, total: (result as { total: number }).total }
|
||||||
|
})
|
||||||
vi.mocked(useApi).mockReturnValue({
|
vi.mocked(useApi).mockReturnValue({
|
||||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
||||||
request,
|
request,
|
||||||
|
requestPaged,
|
||||||
} as unknown as ReturnType<typeof useApi>)
|
} as unknown as ReturnType<typeof useApi>)
|
||||||
await router.push(url)
|
await router.push(url)
|
||||||
const component = view === 'sites' ? SitesView : AuthorsView
|
const component = view === 'sites' ? SitesView : AuthorsView
|
||||||
|
|||||||
+26
-1
@@ -31,8 +31,27 @@ const VISUAL_REFERENCE =
|
|||||||
|
|
||||||
const officeBoard = 'RenewCMS Screens Back-office.dc.html'
|
const officeBoard = 'RenewCMS Screens Back-office.dc.html'
|
||||||
|
|
||||||
|
const PAGINATION_STATES = [
|
||||||
|
'First page',
|
||||||
|
'Middle page',
|
||||||
|
'Last page',
|
||||||
|
'Single page',
|
||||||
|
'Page size menu',
|
||||||
|
'Loading a page',
|
||||||
|
'Error loading a page',
|
||||||
|
'Empty after search',
|
||||||
|
'Search resets to page 1',
|
||||||
|
'Long labels on mobile',
|
||||||
|
]
|
||||||
|
|
||||||
const extraSources: Record<string, Source[]> = {
|
const extraSources: Record<string, Source[]> = {
|
||||||
sites: [
|
sites: [
|
||||||
|
{
|
||||||
|
board: officeBoard,
|
||||||
|
group: 'Sites: pagination',
|
||||||
|
states: PAGINATION_STATES,
|
||||||
|
alias: {},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
board: officeBoard,
|
board: officeBoard,
|
||||||
group: 'Sites: form',
|
group: 'Sites: form',
|
||||||
@@ -67,6 +86,12 @@ const extraSources: Record<string, Source[]> = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
authors: [
|
authors: [
|
||||||
|
{
|
||||||
|
board: officeBoard,
|
||||||
|
group: 'Authors: pagination',
|
||||||
|
states: PAGINATION_STATES,
|
||||||
|
alias: {},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
board: officeBoard,
|
board: officeBoard,
|
||||||
group: 'Authors: form',
|
group: 'Authors: form',
|
||||||
@@ -842,7 +867,7 @@ function fulfilment(mock: ApiMock, url: URL, call: number) {
|
|||||||
const text = typeof body === 'string'
|
const text = typeof body === 'string'
|
||||||
return {
|
return {
|
||||||
status: mock.status,
|
status: mock.status,
|
||||||
headers: mock.headers,
|
headers: { ...mock.headers, ...mock.headersFor?.(url, call) },
|
||||||
contentType: text ? 'text/plain; charset=utf-8' : 'application/json',
|
contentType: text ? 'text/plain; charset=utf-8' : 'application/json',
|
||||||
body: body === undefined ? '' : text ? (body as string) : JSON.stringify(body),
|
body: body === undefined ? '' : text ? (body as string) : JSON.stringify(body),
|
||||||
}
|
}
|
||||||
|
|||||||
+470
-68
@@ -6,6 +6,7 @@ export interface ApiMock {
|
|||||||
body?: unknown
|
body?: unknown
|
||||||
bodyFor?: (url: URL, call: number) => unknown
|
bodyFor?: (url: URL, call: number) => unknown
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
|
headersFor?: (url: URL, call: number) => Record<string, string>
|
||||||
hang?: boolean
|
hang?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +374,9 @@ function editorApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiM
|
|||||||
'GET /v1/media/': { status: 200, body: emptyLibrary },
|
'GET /v1/media/': { status: 200, body: emptyLibrary },
|
||||||
'GET /v1/site/': boardSiteList(),
|
'GET /v1/site/': boardSiteList(),
|
||||||
'GET /v1/author/': boardAuthorList(),
|
'GET /v1/author/': boardAuthorList(),
|
||||||
|
'GET /v1/media/12': { status: 404 },
|
||||||
|
'GET /v1/media/13': { status: 404 },
|
||||||
|
'GET /v1/media/14': { status: 404 },
|
||||||
'POST /v1/article/12/seo/check': { status: 200, body: seoReady },
|
'POST /v1/article/12/seo/check': { status: 200, body: seoReady },
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
@@ -4793,7 +4797,9 @@ function sitesApi(
|
|||||||
): Record<string, ApiMock> {
|
): Record<string, ApiMock> {
|
||||||
return {
|
return {
|
||||||
'GET /v1/auth/me': directorySession(role),
|
'GET /v1/auth/me': directorySession(role),
|
||||||
'GET /v1/site/': { status: 200, body: directorySites },
|
'GET /v1/site/': siteListMock(directorySites),
|
||||||
|
'GET /v1/media/11': { status: 200, body: { ...mediaItem(mediaSamples[0]!), id: 11 } },
|
||||||
|
'GET /v1/media/12': { status: 200, body: { ...mediaItem(mediaSamples[1]!), id: 12 } },
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4857,15 +4863,403 @@ async function submitDirectoryPanel(page: Page, label: string) {
|
|||||||
await page.mouse.move(0, 0)
|
await page.mouse.move(0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TOTAL_COUNT_HEADER = 'X-Total-Count'
|
||||||
|
|
||||||
|
function pageParams(url: URL) {
|
||||||
|
return {
|
||||||
|
q: (url.searchParams.get('q') ?? '').toLowerCase(),
|
||||||
|
size: Number(url.searchParams.get('size') ?? 25),
|
||||||
|
page: Number(url.searchParams.get('page') ?? 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteListMock(sites: unknown[]): ApiMock {
|
||||||
|
const matching = (url: URL) => {
|
||||||
|
const { q } = pageParams(url)
|
||||||
|
return (sites as { name: string; key: string; base_url: string }[]).filter((site) =>
|
||||||
|
`${site.name} ${site.key} ${site.base_url}`.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
bodyFor: (url) => {
|
||||||
|
if (!url.searchParams.has('page')) {
|
||||||
|
return sites
|
||||||
|
}
|
||||||
|
const { size, page } = pageParams(url)
|
||||||
|
return matching(url).slice((page - 1) * size, page * size)
|
||||||
|
},
|
||||||
|
headersFor: (url) => ({ [TOTAL_COUNT_HEADER]: String(matching(url).length) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PagerGroup {
|
||||||
|
page: number
|
||||||
|
per: number
|
||||||
|
total: number
|
||||||
|
q: string
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PagerTotals {
|
||||||
|
total: number
|
||||||
|
searchTotal: number
|
||||||
|
emptyTerm: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagerSiteNames = [
|
||||||
|
'Atelier',
|
||||||
|
'Birchwood',
|
||||||
|
'Cedar Row',
|
||||||
|
'Dovetail',
|
||||||
|
'Eastgate',
|
||||||
|
'Fieldnotes',
|
||||||
|
'Granary',
|
||||||
|
'Harbour',
|
||||||
|
'Ironwork',
|
||||||
|
'Joinery Guide',
|
||||||
|
'Kiln',
|
||||||
|
'Larch',
|
||||||
|
'Millstone',
|
||||||
|
'Nettle',
|
||||||
|
'Orchard',
|
||||||
|
'Planing',
|
||||||
|
'Quarry',
|
||||||
|
'Ridgeway',
|
||||||
|
'Slate',
|
||||||
|
'Tannery',
|
||||||
|
'Underhill',
|
||||||
|
'Veneer',
|
||||||
|
'Workshop',
|
||||||
|
'Yardline',
|
||||||
|
'Zinc Works',
|
||||||
|
]
|
||||||
|
const pagerSearchSiteNames = [
|
||||||
|
'Journal',
|
||||||
|
'Projects',
|
||||||
|
'Archive',
|
||||||
|
'Press',
|
||||||
|
'Careers',
|
||||||
|
'Docs',
|
||||||
|
'Events',
|
||||||
|
'Shop',
|
||||||
|
]
|
||||||
|
const pagerFirstNames = [
|
||||||
|
'Anaïs',
|
||||||
|
'Bruno',
|
||||||
|
'Clara',
|
||||||
|
'Diego',
|
||||||
|
'Elise',
|
||||||
|
'Félix',
|
||||||
|
'Greta',
|
||||||
|
'Hugo',
|
||||||
|
'Iris',
|
||||||
|
'Julien',
|
||||||
|
'Karim',
|
||||||
|
'Lucie',
|
||||||
|
'Mathieu',
|
||||||
|
'Nora',
|
||||||
|
'Oscar',
|
||||||
|
'Pilar',
|
||||||
|
'Quentin',
|
||||||
|
'Rosa',
|
||||||
|
'Samir',
|
||||||
|
'Tess',
|
||||||
|
]
|
||||||
|
const pagerSearchFirstNames = [
|
||||||
|
'Marc',
|
||||||
|
'Marie',
|
||||||
|
'Marta',
|
||||||
|
'Mario',
|
||||||
|
'Marina',
|
||||||
|
'Marius',
|
||||||
|
'Marlène',
|
||||||
|
'Marcel',
|
||||||
|
]
|
||||||
|
const pagerLastNames = [
|
||||||
|
'Marchand',
|
||||||
|
'Nunes',
|
||||||
|
'Oliveira',
|
||||||
|
'Perrin',
|
||||||
|
'Quintela',
|
||||||
|
'Rocha',
|
||||||
|
'Santos',
|
||||||
|
'Teixeira',
|
||||||
|
'Vidal',
|
||||||
|
'Weber',
|
||||||
|
]
|
||||||
|
const PAGER_VISIBLE_ROWS = 12
|
||||||
|
const PAGER_ID_OFFSET = 1000
|
||||||
|
|
||||||
|
function pagerSlug(text: string) {
|
||||||
|
return text
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagerRows(group: PagerGroup) {
|
||||||
|
const start = (group.page - 1) * group.per
|
||||||
|
const count = Math.max(0, Math.min(group.limit, group.per, group.total - start))
|
||||||
|
return { start, count }
|
||||||
|
}
|
||||||
|
|
||||||
|
function numbered(name: string, round: number) {
|
||||||
|
return round > 0 ? `${name} ${round + 1}` : name
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagerSites(group: PagerGroup) {
|
||||||
|
const { start, count } = pagerRows(group)
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
const position = start + index
|
||||||
|
let name: string
|
||||||
|
if (group.q) {
|
||||||
|
const names = pagerSearchSiteNames
|
||||||
|
name = numbered(
|
||||||
|
`Renew ${names[position % names.length]}`,
|
||||||
|
Math.floor(position / names.length),
|
||||||
|
)
|
||||||
|
} else if (position < directorySites.length) {
|
||||||
|
return directorySites[position]!
|
||||||
|
} else {
|
||||||
|
const offset = position - directorySites.length
|
||||||
|
name = numbered(
|
||||||
|
pagerSiteNames[offset % pagerSiteNames.length]!,
|
||||||
|
Math.floor(offset / pagerSiteNames.length),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const key = pagerSlug(name)
|
||||||
|
return {
|
||||||
|
...directorySites[1]!,
|
||||||
|
id: PAGER_ID_OFFSET + position,
|
||||||
|
name,
|
||||||
|
site_name: name,
|
||||||
|
key,
|
||||||
|
base_url: `https://${group.q ? key.replace(/^renew-/, '') : key}.renew.example`,
|
||||||
|
default_robots: 'index',
|
||||||
|
is_default: false,
|
||||||
|
article_count: (position * 37) % 90,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagerAuthors(group: PagerGroup): DirectoryAuthor[] {
|
||||||
|
const { start, count } = pagerRows(group)
|
||||||
|
const known = directoryAuthors()
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
const position = start + index
|
||||||
|
if (!group.q && position < known.length) {
|
||||||
|
return known[position]!
|
||||||
|
}
|
||||||
|
const firstNames = group.q ? pagerSearchFirstNames : pagerFirstNames
|
||||||
|
const name = `${firstNames[position % firstNames.length]} ${pagerLastNames[(position * 3 + 1) % pagerLastNames.length]}`
|
||||||
|
const slug = pagerSlug(name)
|
||||||
|
const linked = position % 3 === 0
|
||||||
|
return {
|
||||||
|
id: PAGER_ID_OFFSET + position,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
bio: '',
|
||||||
|
avatar_id: position % 2 === 0 ? 12 : null,
|
||||||
|
website: null,
|
||||||
|
social_links: [],
|
||||||
|
user_id: linked ? 3 : null,
|
||||||
|
is_linked: linked,
|
||||||
|
article_count: (position * 5) % 12,
|
||||||
|
...(linked ? { user_email: `${slug.split('-')[0]}@renew.example` } : {}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function pagedDirectoryMock(
|
||||||
|
kind: 'sites' | 'authors',
|
||||||
|
totals: PagerTotals,
|
||||||
|
limit = PAGER_VISIBLE_ROWS,
|
||||||
|
): ApiMock {
|
||||||
|
const totalFor = (q: string) =>
|
||||||
|
q === '' ? totals.total : q === totals.emptyTerm ? 0 : totals.searchTotal
|
||||||
|
const rowsFor = kind === 'sites' ? pagerSites : pagerAuthors
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
bodyFor: (url) => {
|
||||||
|
const { q, size, page } = pageParams(url)
|
||||||
|
const total = totalFor(q)
|
||||||
|
const items = total === 0 ? [] : rowsFor({ page, per: size, total, q, limit })
|
||||||
|
return kind === 'sites' ? items : { items, total, page, size }
|
||||||
|
},
|
||||||
|
headersFor: (url) => ({ [TOTAL_COUNT_HEADER]: String(totalFor(pageParams(url).q)) }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function holdListPage(path: string, nextPage: number, answer?: number) {
|
||||||
|
return async (page: Page) => {
|
||||||
|
await page.route(
|
||||||
|
(url) => url.pathname === path && url.searchParams.get('page') === String(nextPage),
|
||||||
|
(route) => (answer ? route.fulfill({ status: answer }) : undefined),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectoryPagerSetup {
|
||||||
|
route: string
|
||||||
|
listPath: string
|
||||||
|
kind: 'sites' | 'authors'
|
||||||
|
totals: PagerTotals
|
||||||
|
api: (list: ApiMock) => Record<string, ApiMock>
|
||||||
|
middle: number
|
||||||
|
searchPlaceholder: string
|
||||||
|
searchTerm: string
|
||||||
|
singleTotal: number
|
||||||
|
bigTotal: number
|
||||||
|
bigPage: number
|
||||||
|
emptyTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorFooterReason =
|
||||||
|
'The board pins the footer in a clipped list; the app keeps normal flow (board fix pending)'
|
||||||
|
|
||||||
|
const errorFooterExceptions: Record<DirectoryPagerSetup['kind'], DiffException> = {
|
||||||
|
sites: {
|
||||||
|
maxPercent: { desktop: 10, mobile: 13 },
|
||||||
|
reason: errorFooterReason,
|
||||||
|
approvedBy: 'fsylvain',
|
||||||
|
},
|
||||||
|
authors: {
|
||||||
|
maxPercent: { desktop: 10, mobile: 11 },
|
||||||
|
reason: errorFooterReason,
|
||||||
|
approvedBy: 'fsylvain',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const boardLacksSearchReason = 'The board does not draw the new search field (board fix pending)'
|
||||||
|
|
||||||
|
interface FittedRows {
|
||||||
|
desktop: number
|
||||||
|
mobile: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const FITTED_ROWS = {
|
||||||
|
menu: { desktop: PAGER_VISIBLE_ROWS, mobile: 5 },
|
||||||
|
error: { desktop: 9, mobile: 3 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitRows(setup: DirectoryPagerSetup, limits: FittedRows, state: VisualState): VisualState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
steps: async (page) => {
|
||||||
|
const limited = pagedDirectoryMock(
|
||||||
|
setup.kind,
|
||||||
|
setup.totals,
|
||||||
|
isPhone(page) ? limits.mobile : limits.desktop,
|
||||||
|
)
|
||||||
|
await page.route(
|
||||||
|
(url) => url.pathname === setup.listPath,
|
||||||
|
(route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers: limited.headersFor?.(url, 1),
|
||||||
|
body: JSON.stringify(limited.bodyFor?.(url, 1)),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await page.reload()
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="directory-row"], [data-testid="directory-card"]')
|
||||||
|
.first()
|
||||||
|
.waitFor()
|
||||||
|
await state.steps?.(page)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function directoryPagerStates(setup: DirectoryPagerSetup): Record<string, VisualState> {
|
||||||
|
const withTotals = (overrides: Partial<PagerTotals> = {}) =>
|
||||||
|
setup.api(pagedDirectoryMock(setup.kind, { ...setup.totals, ...overrides }))
|
||||||
|
const lastPage = Math.ceil(setup.totals.total / 25)
|
||||||
|
const nextPage = setup.middle + 1
|
||||||
|
return {
|
||||||
|
'First page': { path: setup.route, api: withTotals() },
|
||||||
|
'Middle page': { path: `${setup.route}?page=${setup.middle}`, api: withTotals() },
|
||||||
|
'Last page': { path: `${setup.route}?page=${lastPage}`, api: withTotals() },
|
||||||
|
'Single page': { path: setup.route, api: withTotals({ total: setup.singleTotal }) },
|
||||||
|
'Page size menu': fitRows(setup, FITTED_ROWS.menu, {
|
||||||
|
path: `${setup.route}?page=${setup.middle}`,
|
||||||
|
api: withTotals(),
|
||||||
|
steps: async (page) => {
|
||||||
|
await page.locator('[aria-label="Rows per page"]').click()
|
||||||
|
await page.getByRole('listbox').waitFor()
|
||||||
|
await scrollEverythingToTop(page)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'Loading a page': {
|
||||||
|
path: `${setup.route}?page=${setup.middle}`,
|
||||||
|
api: withTotals(),
|
||||||
|
steps: async (page) => {
|
||||||
|
await holdListPage(setup.listPath, nextPage)(page)
|
||||||
|
await pressNextPage(page)
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
await page.getByText(`Loading page ${nextPage}…`).waitFor()
|
||||||
|
await page.locator('[data-testid="directory-skeleton-row"]').first().waitFor()
|
||||||
|
await scrollEverythingToTop(page)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Error loading a page': fitRows(setup, FITTED_ROWS.error, {
|
||||||
|
exception: errorFooterExceptions[setup.kind],
|
||||||
|
path: `${setup.route}?page=${setup.middle}`,
|
||||||
|
api: withTotals(),
|
||||||
|
steps: async (page) => {
|
||||||
|
await holdListPage(setup.listPath, nextPage, 500)(page)
|
||||||
|
await pressNextPage(page)
|
||||||
|
await page.mouse.move(0, 0)
|
||||||
|
await page
|
||||||
|
.getByText(`Couldn't load page ${nextPage}. Showing page ${setup.middle}.`)
|
||||||
|
.waitFor()
|
||||||
|
await scrollEverythingToTop(page)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'Empty after search': {
|
||||||
|
path: setup.route,
|
||||||
|
api: withTotals(),
|
||||||
|
steps: async (page) => {
|
||||||
|
await page.getByPlaceholder(setup.searchPlaceholder).fill(setup.totals.emptyTerm)
|
||||||
|
await page.getByText(setup.emptyTitle).waitFor()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'Search resets to page 1': {
|
||||||
|
path: `${setup.route}?q=${setup.searchTerm}`,
|
||||||
|
api: withTotals(),
|
||||||
|
},
|
||||||
|
'Long labels on mobile': {
|
||||||
|
path: `${setup.route}?page=${setup.bigPage}`,
|
||||||
|
api: withTotals({ total: setup.bigTotal }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sitesStates: Record<string, VisualState> = {
|
const sitesStates: Record<string, VisualState> = {
|
||||||
|
...directoryPagerStates({
|
||||||
|
route: '/settings/sites',
|
||||||
|
listPath: '/v1/site/',
|
||||||
|
kind: 'sites',
|
||||||
|
totals: { total: 61, searchTotal: 27, emptyTerm: 'blog' },
|
||||||
|
api: (list) => sitesApi({ 'GET /v1/site/': list }),
|
||||||
|
middle: 2,
|
||||||
|
searchPlaceholder: 'Search by name or key',
|
||||||
|
searchTerm: 'renew',
|
||||||
|
singleTotal: 4,
|
||||||
|
bigTotal: 312,
|
||||||
|
bigPage: 6,
|
||||||
|
emptyTitle: 'No sites match "blog"',
|
||||||
|
}),
|
||||||
Default: { path: '/settings/sites', api: sitesApi() },
|
Default: { path: '/settings/sites', api: sitesApi() },
|
||||||
'Only the default site': {
|
'Only the default site': {
|
||||||
path: '/settings/sites',
|
path: '/settings/sites',
|
||||||
api: sitesApi({
|
api: sitesApi({
|
||||||
'GET /v1/site/': {
|
'GET /v1/site/': siteListMock([{ ...directorySites[0], article_count: 312 }]),
|
||||||
status: 200,
|
|
||||||
body: [{ ...directorySites[0], article_count: 312 }],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
Loading: {
|
Loading: {
|
||||||
@@ -4874,6 +5268,11 @@ const sitesStates: Record<string, VisualState> = {
|
|||||||
},
|
},
|
||||||
Error: { path: '/settings/sites', api: sitesApi({ 'GET /v1/site/': { status: 500 } }) },
|
Error: { path: '/settings/sites', api: sitesApi({ 'GET /v1/site/': { status: 500 } }) },
|
||||||
'Row menu': {
|
'Row menu': {
|
||||||
|
exception: {
|
||||||
|
maxPercent: { desktop: 17, mobile: 10 },
|
||||||
|
reason: boardLacksSearchReason,
|
||||||
|
approvedBy: 'fsylvain',
|
||||||
|
},
|
||||||
path: '/settings/sites',
|
path: '/settings/sites',
|
||||||
api: sitesApi(),
|
api: sitesApi(),
|
||||||
steps: async (page) => {
|
steps: async (page) => {
|
||||||
@@ -4882,6 +5281,11 @@ const sitesStates: Record<string, VisualState> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
'Default site: delete unavailable': {
|
'Default site: delete unavailable': {
|
||||||
|
exception: {
|
||||||
|
maxPercent: { desktop: 11, mobile: 10 },
|
||||||
|
reason: boardLacksSearchReason,
|
||||||
|
approvedBy: 'fsylvain',
|
||||||
|
},
|
||||||
path: '/settings/sites',
|
path: '/settings/sites',
|
||||||
api: sitesApi(),
|
api: sitesApi(),
|
||||||
steps: async (page) => {
|
steps: async (page) => {
|
||||||
@@ -4893,6 +5297,11 @@ const sitesStates: Record<string, VisualState> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
'Set as default: confirm': {
|
'Set as default: confirm': {
|
||||||
|
exception: {
|
||||||
|
maxPercent: { desktop: 10, mobile: 11 },
|
||||||
|
reason: boardLacksSearchReason,
|
||||||
|
approvedBy: 'fsylvain',
|
||||||
|
},
|
||||||
path: '/settings/sites',
|
path: '/settings/sites',
|
||||||
api: sitesApi(),
|
api: sitesApi(),
|
||||||
steps: async (page) => {
|
steps: async (page) => {
|
||||||
@@ -4981,12 +5390,11 @@ const sitesStates: Record<string, VisualState> = {
|
|||||||
'Edit: SEO defaults': {
|
'Edit: SEO defaults': {
|
||||||
path: '/settings/sites',
|
path: '/settings/sites',
|
||||||
api: sitesApi({
|
api: sitesApi({
|
||||||
'GET /v1/site/': {
|
'GET /v1/site/': siteListMock(
|
||||||
status: 200,
|
directorySites.map((site) =>
|
||||||
body: directorySites.map((site) =>
|
|
||||||
site.id === 1 ? { ...site, default_description: siteSeoDescription } : site,
|
site.id === 1 ? { ...site, default_description: siteSeoDescription } : site,
|
||||||
),
|
),
|
||||||
},
|
),
|
||||||
}),
|
}),
|
||||||
steps: async (page) => {
|
steps: async (page) => {
|
||||||
await openSiteEdit(page, 'Renew')
|
await openSiteEdit(page, 'Renew')
|
||||||
@@ -5009,12 +5417,11 @@ const sitesStates: Record<string, VisualState> = {
|
|||||||
'Template: custom': {
|
'Template: custom': {
|
||||||
path: '/settings/sites',
|
path: '/settings/sites',
|
||||||
api: sitesApi({
|
api: sitesApi({
|
||||||
'GET /v1/site/': {
|
'GET /v1/site/': siteListMock(
|
||||||
status: 200,
|
directorySites.map((site) =>
|
||||||
body: directorySites.map((site) =>
|
|
||||||
site.id === 2 ? { ...site, title_template: '%title% – %site%' } : site,
|
site.id === 2 ? { ...site, title_template: '%title% – %site%' } : site,
|
||||||
),
|
),
|
||||||
},
|
),
|
||||||
}),
|
}),
|
||||||
steps: async (page) => {
|
steps: async (page) => {
|
||||||
await openSiteEdit(page, 'Journal')
|
await openSiteEdit(page, 'Journal')
|
||||||
@@ -5036,12 +5443,11 @@ const sitesStates: Record<string, VisualState> = {
|
|||||||
'Save failed': {
|
'Save failed': {
|
||||||
path: '/settings/sites',
|
path: '/settings/sites',
|
||||||
api: sitesApi({
|
api: sitesApi({
|
||||||
'GET /v1/site/': {
|
'GET /v1/site/': siteListMock(
|
||||||
status: 200,
|
directorySites.map((site) =>
|
||||||
body: directorySites.map((site) =>
|
|
||||||
site.id === 1 ? { ...site, site_name: 'Ren' } : site,
|
site.id === 1 ? { ...site, site_name: 'Ren' } : site,
|
||||||
),
|
),
|
||||||
},
|
),
|
||||||
'PATCH /v1/site/1': { status: 500 },
|
'PATCH /v1/site/1': { status: 500 },
|
||||||
}),
|
}),
|
||||||
steps: async (page) => {
|
steps: async (page) => {
|
||||||
@@ -5200,6 +5606,13 @@ function authorListMock(authors: DirectoryAuthor[]): ApiMock {
|
|||||||
size,
|
size,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
headersFor: (url) => {
|
||||||
|
const q = (url.searchParams.get('q') ?? '').toLowerCase()
|
||||||
|
const total = authors.filter((author) =>
|
||||||
|
`${author.name} ${author.slug}`.toLowerCase().includes(q),
|
||||||
|
).length
|
||||||
|
return { [TOTAL_COUNT_HEADER]: String(total) }
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5255,6 +5668,9 @@ function authorsApi(
|
|||||||
'GET /v1/auth/me': directorySession(role),
|
'GET /v1/auth/me': directorySession(role),
|
||||||
'GET /v1/author/': authorListMock(options.authors ?? directoryAuthors({}, role !== 'user')),
|
'GET /v1/author/': authorListMock(options.authors ?? directoryAuthors({}, role !== 'user')),
|
||||||
'GET /v1/user/': { status: 200, body: directoryUsers },
|
'GET /v1/user/': { status: 200, body: directoryUsers },
|
||||||
|
'GET /v1/media/12': { status: 404 },
|
||||||
|
'GET /v1/media/13': { status: 404 },
|
||||||
|
'GET /v1/media/14': { status: 404 },
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5299,6 +5715,20 @@ function avatarPickerList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authorsStates: Record<string, VisualState> = {
|
const authorsStates: Record<string, VisualState> = {
|
||||||
|
...directoryPagerStates({
|
||||||
|
route: '/authors',
|
||||||
|
listPath: '/v1/author/',
|
||||||
|
kind: 'authors',
|
||||||
|
totals: { total: 128, searchTotal: 31, emptyTerm: 'zoe' },
|
||||||
|
api: (list) => authorsApi({ 'GET /v1/author/': list }),
|
||||||
|
middle: 3,
|
||||||
|
searchPlaceholder: 'Search by name or slug',
|
||||||
|
searchTerm: 'mar',
|
||||||
|
singleTotal: 6,
|
||||||
|
bigTotal: 1284,
|
||||||
|
bigPage: 12,
|
||||||
|
emptyTitle: 'No authors match "zoe"',
|
||||||
|
}),
|
||||||
Default: { path: '/authors', api: authorsApi() },
|
Default: { path: '/authors', api: authorsApi() },
|
||||||
Loading: {
|
Loading: {
|
||||||
path: '/authors',
|
path: '/authors',
|
||||||
@@ -5813,20 +6243,6 @@ async function seoSaveFromPanel(page: Page) {
|
|||||||
.click()
|
.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
const boardOmitsMobileDialogReason =
|
|
||||||
"The board's mobile frame omits the Create author dialog, so only the Details sheet is drawn"
|
|
||||||
|
|
||||||
function withMobileException<T extends VisualState>(state: T, mobile: number, reason: string): T {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
exception: {
|
|
||||||
maxPercent: { desktop: 10, mobile },
|
|
||||||
reason,
|
|
||||||
approvedBy: 'fsylvain',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const editorSeoStates: Record<string, VisualState> = {
|
const editorSeoStates: Record<string, VisualState> = {
|
||||||
'Collapsed: ready': seoSidebarState({}, false),
|
'Collapsed: ready': seoSidebarState({}, false),
|
||||||
'Collapsed: suggestions': seoSidebarState(
|
'Collapsed: suggestions': seoSidebarState(
|
||||||
@@ -6384,7 +6800,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 60, mobile: 55 }),
|
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 60, mobile: 55 }),
|
||||||
exception: {
|
exception: {
|
||||||
maxPercent: { desktop: 16, mobile: 22 },
|
maxPercent: { desktop: 16, mobile: 22 },
|
||||||
reason: 'The 403 note under the Errors table has no layout in the design (design gap) and pushes the following rows down; the 409 row is new and the board already differed at HEAD',
|
reason: 'The board lacks the 409 row, the "Message" heading and the Sites and Authors contents entries',
|
||||||
approvedBy: 'fsylvain',
|
approvedBy: 'fsylvain',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -6867,45 +7283,31 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
{ authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'] },
|
{ authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'] },
|
||||||
(page) => liftAuthor(page, 'Camille Roche'),
|
(page) => liftAuthor(page, 'Camille Roche'),
|
||||||
),
|
),
|
||||||
'Create author: dialog': withMobileException(
|
'Create author: dialog': metaState({ withAuthor: true }, (page) =>
|
||||||
metaState({ withAuthor: true }, (page) => openCreateAuthorDialog(page, 'Zoe Lambert')),
|
openCreateAuthorDialog(page, 'Zoe Lambert'),
|
||||||
55,
|
|
||||||
boardOmitsMobileDialogReason,
|
|
||||||
),
|
),
|
||||||
'Create author: for me': withMobileException(
|
'Create author: for me': metaState({ authors: [] }, async (page) => {
|
||||||
metaState({}, async (page) => {
|
await openCreateAuthorDialog(page, 'Marta Alves')
|
||||||
await openCreateAuthorDialog(page, 'Marta Alves')
|
await page.getByRole('checkbox', { name: /This is me/ }).click()
|
||||||
await page.getByRole('checkbox', { name: /This is me/ }).click()
|
await page.mouse.move(0, 0)
|
||||||
|
}),
|
||||||
|
'Create author: slug taken': metaState(
|
||||||
|
{ withAuthor: true, overrides: { 'POST /v1/author/': newAuthorSlugTaken } },
|
||||||
|
async (page) => {
|
||||||
|
await openCreateAuthorDialog(page, 'Zoe Lambert')
|
||||||
|
await page.getByRole('button', { name: 'Create author', exact: true }).click()
|
||||||
|
await page.getByText('is already used by another author').waitFor()
|
||||||
await page.mouse.move(0, 0)
|
await page.mouse.move(0, 0)
|
||||||
}),
|
},
|
||||||
50,
|
|
||||||
boardOmitsMobileDialogReason,
|
|
||||||
),
|
),
|
||||||
'Create author: slug taken': withMobileException(
|
'Create author: failed': metaState(
|
||||||
metaState(
|
{ withAuthor: true, overrides: { 'POST /v1/author/': { status: 500 } } },
|
||||||
{ withAuthor: true, overrides: { 'POST /v1/author/': newAuthorSlugTaken } },
|
async (page) => {
|
||||||
async (page) => {
|
await openCreateAuthorDialog(page, 'Zoe Lambert')
|
||||||
await openCreateAuthorDialog(page, 'Zoe Lambert')
|
await page.getByRole('button', { name: 'Create author', exact: true }).click()
|
||||||
await page.getByRole('button', { name: 'Create author', exact: true }).click()
|
await page.getByText("Couldn't create the author").waitFor()
|
||||||
await page.getByText('is already used by another author').waitFor()
|
await page.mouse.move(0, 0)
|
||||||
await page.mouse.move(0, 0)
|
},
|
||||||
},
|
|
||||||
),
|
|
||||||
40,
|
|
||||||
boardOmitsMobileDialogReason,
|
|
||||||
),
|
|
||||||
'Create author: failed': withMobileException(
|
|
||||||
metaState(
|
|
||||||
{ withAuthor: true, overrides: { 'POST /v1/author/': { status: 500 } } },
|
|
||||||
async (page) => {
|
|
||||||
await openCreateAuthorDialog(page, 'Zoe Lambert')
|
|
||||||
await page.getByRole('button', { name: 'Create author', exact: true }).click()
|
|
||||||
await page.getByText("Couldn't create the author").waitFor()
|
|
||||||
await page.mouse.move(0, 0)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
52.5,
|
|
||||||
boardOmitsMobileDialogReason,
|
|
||||||
),
|
),
|
||||||
Loading: {
|
Loading: {
|
||||||
path: '/article/12/edit',
|
path: '/article/12/edit',
|
||||||
|
|||||||
Reference in new issue
Block a user