feat: pagination in the SPA for the Sites and Authors lists

This commit is contained in:
Florian Sylvain committed 2026-10-19 04:00:47 +02:00
1 parent 500ad59458
commit 340a0ad461
34 files changed
+2807 -870

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.
+30 -48
View File
@@ -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">
+34
View File
@@ -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>
+33
View File
@@ -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>
+49
View File
@@ -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 }>()
+49 -1
View File
@@ -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 }])
})
})
+29 -3
View File
@@ -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 }
} }
+62 -189
View File
@@ -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 &&
response.items.length === 0 &&
response.total > 0 &&
query.page > lastPage
) {
await load({ ...query, page: lastPage }, 'replace', {
kind: 'range',
})
return
}
const effective = {
...query,
page: response.total === 0 ? 1 : query.page,
}
items.value = response.items items.value = response.items
total.value = response.total
counts.value = response.counts counts.value = response.counts
shown.value = effective
desired.value = effective
hasLoaded.value = true
loadFailed.value = false loadFailed.value = false
notice.value = successNotice },
await syncUrl(effective, mode) } satisfies PagedOutcome
} 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,
} }
} }
+2 -2
View File
@@ -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 = []
} }
+9 -8
View File
@@ -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 }
}
+153
View File
@@ -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
}
+247
View File
@@ -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 }
}
+10 -4
View File
@@ -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 }
} }
+3
View File
@@ -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',
+6
View File
@@ -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',
+18 -29
View File
@@ -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])
)
}
+13 -18
View File
@@ -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
+9
View File
@@ -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('&')}`
}
+24
View File
@@ -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])
)
}
+60 -212
View File
@@ -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
}
result.value = {
...result.value,
items: result.value.items.map((item) =>
item.id === author.id item.id === author.id
? { ? {
...author, ...author,
user_email: author.user_email ?? item.user_email, user_email: author.user_email ?? item.user_email,
} }
: item, : 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>
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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()
})
})
+5 -3
View File
@@ -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/')
+39 -1
View File
@@ -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
View File
@@ -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),
} }
+453 -51
View File
@@ -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,22 +7283,15 @@ 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) await page.mouse.move(0, 0)
}), }),
50, 'Create author: slug taken': metaState(
boardOmitsMobileDialogReason,
),
'Create author: slug taken': withMobileException(
metaState(
{ withAuthor: true, overrides: { 'POST /v1/author/': newAuthorSlugTaken } }, { withAuthor: true, overrides: { 'POST /v1/author/': newAuthorSlugTaken } },
async (page) => { async (page) => {
await openCreateAuthorDialog(page, 'Zoe Lambert') await openCreateAuthorDialog(page, 'Zoe Lambert')
@@ -6891,11 +7300,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await page.mouse.move(0, 0) await page.mouse.move(0, 0)
}, },
), ),
40, 'Create author: failed': metaState(
boardOmitsMobileDialogReason,
),
'Create author: failed': withMobileException(
metaState(
{ withAuthor: true, overrides: { 'POST /v1/author/': { status: 500 } } }, { withAuthor: true, overrides: { 'POST /v1/author/': { status: 500 } } },
async (page) => { async (page) => {
await openCreateAuthorDialog(page, 'Zoe Lambert') await openCreateAuthorDialog(page, 'Zoe Lambert')
@@ -6904,9 +7309,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await page.mouse.move(0, 0) await page.mouse.move(0, 0)
}, },
), ),
52.5,
boardOmitsMobileDialogReason,
),
Loading: { Loading: {
path: '/article/12/edit', path: '/article/12/edit',
now: editorNow, now: editorNow,