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
+2833 -896

No files matched your search

@@ -0,0 +1,307 @@
Feature: Sites and Authors list pagination
As a RenewCMS owner (Sites) or any signed-in role (Authors)
I want the Sites and Authors lists split into pages with a page-size menu, server-side search and shareable URLs
So that I can browse a long directory quickly and come back to the same page
Background:
Given /settings/sites (SitesView, owner only) and /authors (AuthorsView, every role) are the verified routes of features/sites_authors_screens.feature, per docs/design/routes.md, docs/design/screens/sites.md group "Sites: pagination" and docs/design/screens/authors.md group "Authors: pagination"
And the Paginator and the page-size select are the Articles and Media ones (AppPaginator, docs/design/components.md "Paginator"); nothing new is drawn except the single-page variant below
And the screens request GET /v1/site/?q=Q&page=P&size=S and GET /v1/author/?q=Q&page=P&size=S through useApi only, with relative URLs, always sending page and size, and q only when it is not empty
And the total is read from the X-Total-Count response header, and the body stays the array of sites (Sites) or the envelope {items,total,page,size} (Authors)
And a request that fails or carries no X-Total-Count is treated as a failed load
And page, size and q live in the URL as ?page=&size=&q=; sizes are 5, 25, 50, 100 and the default is 25; the defaults (page 1, size 25, no q) are omitted from the URL
And an invalid page (not a positive integer) or an invalid size (not 5, 25, 50 or 100) is replaced silently by the default with a history replace and no notice, and the request carries the default
And a change to search or to size resets page to 1 and drops it from the URL; search keeps its existing debounce
And the URL changes when a response succeeds, not when the request is sent, and Browser Back returns to the previous page of results
And a page past the last page (a 200 with items [] and total above 0) is replaced in the URL (no history entry) by ?page=<last>, <last> is requested, and the info notice "That page doesn't exist. Showing the last page." is shown
And the summary reads "Showing {from}–{to} of {total}" with locale-formatted numbers (thousands separators above 999), computed from total (the filtered count)
And the footer sits below the list: summary on the left, the page-size select next to it, the page controls right-aligned; below md the summary and the select share one row (wrapping onto two if needed), then Previous, "Page X of Y" and Next on one row with 44 px targets and a label that never wraps
And the page controls are First, Previous, page numbers with ellipses marking skipped ranges once there are more than 7 pages, Next and Last; the current page uses --rc-sand fill, --rc-on-sand text, a --ui-primary border and aria-current="page"; disabled controls are at 40 % opacity with aria-disabled
And on a single page (total <= size, total > 0) the controls and the page-size select are not rendered and the footer holds only the count line "{N} sites" or "{N} authors" in --ui-text-muted ("1 site", "1 author"); this replaces the AuthorsView rule that showed the pager only above 25 items
And the count line or the footer appears whenever the list has loaded with total above 0, including the existing "Default" frames
And the same footer is shown to owner, admin and read-only roles on Authors; Sites stays owner only (403 page for the others)
And SitesView gains a search field with the placeholder "Search by name or key", matching name, key and base URL on the server; Authors keeps "Search by name or slug"
And the places that need every site (deep link ?edit=<key>, duplicate-key check, ReassignDialog destinations, the form's default-site text) read the unpaged GET /v1/site/ and never the current page
And keyboard and aria are those of Articles: Tab reaches the page-size select then the controls, Enter or Space activates, ArrowDown opens the page-size list, Enter selects, Esc closes and returns focus; every control shows a 2 px --ui-primary focus outline with a 2 px offset
And every new visible string goes through t(...) with the English text as the key and its French entry in web/src/i18n/messages.ts; the placeholders are written {0}, as in the other messages; the new strings are "Search by name or key" (French "Rechercher par nom ou clé"), "{0} sites" and "1 site" ("{0} sites", "1 site"), "{0} authors" and "1 author" ("{0} auteurs", "1 auteur"), and 'No sites match "{0}"' (French 'Aucun site ne correspond à « {0} »'); the other strings reuse the Articles and Media entries
And the sites fixture is 61 sites (3 pages at size 25) and the authors fixture is 128 authors (6 pages at size 25), with generated rows, unless stated
Rule: Page states, one set per screen
Scenario Outline: 1 First page
Given <screen> resolves GET <path>?page=1&size=25 with 25 rows and X-Total-Count <total>
When the user navigates to <route>
Then the summary reads "<summary>", the page-size select reads "25 per page" and the controls read "Page 1 of <pages>"
And First and Previous are disabled with aria-disabled, page 1 is current, Next and Last are enabled
And below md Previous is disabled
And the URL has no page and no size
Examples:
| screen | path | route | total | pages | summary |
| Sites | /v1/site/ | /settings/sites | 61 | 3 | Showing 1–25 of 61 |
| Authors | /v1/author/ | /authors | 128 | 6 | Showing 1–25 of 128 |
Scenario Outline: 2 Middle page
Given <screen> resolves GET <path>?page=<page>&size=25 with 25 rows and X-Total-Count <total>
When the user navigates to <route>?page=<page>
Then the summary reads "<summary>" and the label reads "Page <page> of <pages>"
And page <page> is current with --rc-sand, --rc-on-sand, a --ui-primary border and aria-current="page"
And every control is enabled
Examples:
| screen | path | route | page | total | pages | summary |
| Sites | /v1/site/ | /settings/sites | 2 | 61 | 3 | Showing 26–50 of 61 |
| Authors | /v1/author/ | /authors | 3 | 128 | 6 | Showing 51–75 of 128 |
Scenario Outline: 3 Last page
Given <screen> resolves GET <path>?page=<pages>&size=25 with <rows> rows and X-Total-Count <total>
When the user navigates to <route>?page=<pages>
Then the summary reads "<summary>" and the label reads "Page <pages> of <pages>"
And Next and Last are disabled with aria-disabled and Previous and First are enabled
Examples:
| screen | path | route | pages | rows | total | summary |
| Sites | /v1/site/ | /settings/sites | 3 | 11 | 61 | Showing 51–61 of 61 |
| Authors | /v1/author/ | /authors | 6 | 3 | 128 | Showing 126–128 of 128 |
Scenario Outline: 4 Single page
Given <screen> resolves GET <path>?page=1&size=25 with <total> rows and X-Total-Count <total>
When the user navigates to <route>
Then the footer shows only "<line>"
And the page controls and the page-size select are not rendered
And no "Showing" summary is rendered
Examples:
| screen | path | route | total | line |
| Sites | /v1/site/ | /settings/sites | 4 | 4 sites |
| Authors | /v1/author/ | /authors | 6 | 6 authors |
Scenario Outline: 4b Singular count line
Given <screen> resolves its list with 1 row and X-Total-Count 1
When the user navigates to <route>
Then the footer shows only "<line>"
Examples:
| screen | route | line |
| Sites | /settings/sites | 1 site |
| Authors | /authors | 1 author |
Scenario Outline: 4c Five per page with a few rows still pages
Given <screen> resolves GET <path>?size=5&page=1 with 5 rows and X-Total-Count <total>
When the user navigates to <route>?size=5
Then the summary "Showing 1–5 of <total>", the page-size select "5 per page" and the controls read "Page 1 of 2"
Examples:
| screen | path | route | total |
| Sites | /v1/site/ | /settings/sites | 6 |
| Authors | /v1/author/ | /authors | 6 |
Scenario Outline: 5 Page size menu
Given the user is on <route>?page=<page>
When the user opens the page-size select (it opens upward) and chooses "<label>"
Then <path>?page=1&size=<size> is requested and, on success, the URL is <url>
And the list shows page 1
Examples:
| route | path | page | label | size | url |
| /settings/sites | /v1/site/ | 2 | 5 per page | 5 | /settings/sites?size=5 |
| /settings/sites | /v1/site/ | 2 | 25 per page | 25 | /settings/sites |
| /settings/sites | /v1/site/ | 2 | 50 per page | 50 | /settings/sites?size=50 |
| /settings/sites | /v1/site/ | 2 | 100 per page | 100 | /settings/sites?size=100 |
| /authors | /v1/author/ | 3 | 5 per page | 5 | /authors?size=5 |
| /authors | /v1/author/ | 3 | 25 per page | 25 | /authors |
| /authors | /v1/author/ | 3 | 50 per page | 50 | /authors?size=50 |
| /authors | /v1/author/ | 3 | 100 per page | 100 | /authors?size=100 |
Scenario Outline: 6 Loading a page
Given <route>?page=<page> has loaded and the request for page <next> has not resolved
When the user activates Next (or Previous, a page number, or changes the size)
Then the rows are replaced by skeleton rows (8 on desktop, 6 below md) and the list is aria-busy="true"
And the pager stays, at 40 % opacity, every control and the page-size select are disabled and not focusable
And the summary reads "Loading page <next>…" and the page number shown is still <page>
And the URL is unchanged until the request resolves
When the request resolves 200
Then the rows, the summary and the URL of page <next> appear
Examples:
| route | page | next |
| /settings/sites | 2 | 3 |
| /authors | 3 | 4 |
Scenario Outline: 7 Error loading a page keeps the previous rows
Given <route>?page=<page> is shown
When the user activates Next and the request for page <next> <failure>
Then the rows of page <page> stay, the pager keeps "Page <page>" and is enabled again, and the URL stays <route>?page=<page>
And an inline error above the pager reads "Couldn't load page <next>. Showing page <page>.", "Check your connection and try again." and a "Retry" action
When the user activates "Retry"
Then the request for page <next> is sent again and, on 200, page <next> replaces the rows, the URL becomes ?page=<next> and the error goes
Examples:
| route | page | next | failure |
| /settings/sites | 2 | 3 | resolves with a 500 response |
| /settings/sites | 2 | 3 | never reaches the server |
| /authors | 3 | 4 | resolves with a 500 response |
| /authors | 3 | 4 | never reaches the server |
Scenario Outline: 8 Empty after search
Given <path>?q=<q>&page=1&size=25 resolves 200 with [] and X-Total-Count 0
When the user searches "<q>"
Then the existing empty state shows "<title>", "Check the spelling, or search for a shorter part of the name." and "Clear search"
And the pager, the summary and the count line are not rendered
When the user activates "Clear search"
Then the URL is <route>, page 1 is requested and the list returns
Examples:
| route | path | q | title |
| /settings/sites | /v1/site/ | blog | No sites match "blog" |
| /authors | /v1/author/ | zoe | No authors match "zoe" |
Scenario Outline: 9 Search resets to page 1
Given the user is on <route>?page=<page>
When the user types "<q>" in the search field and the debounce elapses
Then <path>?q=<q>&page=1&size=25 is requested
And on success the URL is <route>?q=<q> (page omitted) and the summary reads "<summary>"
And a size change resets the page the same way
Examples:
| route | path | page | q | summary |
| /settings/sites | /v1/site/ | 4 | renew | Showing 1–25 of 27 |
| /authors | /v1/author/ | 5 | mar | Showing 1–25 of 31 |
Scenario Outline: 10 Long labels on mobile
Given the viewport is 390 px wide and <route>?page=<page> resolves with X-Total-Count <total>
Then the summary "<summary>" and the page-size select share one row and wrap onto two if needed
And Previous, the label "<label>" and Next stay on one row, the two buttons flex and the label never wraps
And totals above 999 use thousands separators in both labels
Examples:
| route | page | total | summary | label |
| /settings/sites | 6 | 312 | Showing 126–150 of 312 | Page 6 of 13 |
| /authors | 12 | 1284 | Showing 276–300 of 1,284 | Page 12 of 52 |
Rule: URL rules
Scenario Outline: Invalid values fall back silently
When the user navigates to <route>?size=30&page=abc
Then <path>?page=1&size=25 is requested and the URL is replaced by <route>
And no notice is shown
Examples:
| route | path |
| /settings/sites | /v1/site/ |
| /authors | /v1/author/ |
Scenario Outline: Page 0 and negative pages fall back
When the user navigates to <route>?page=<page>
Then page 1 is requested and the URL is replaced by <route>
Examples:
| route | page |
| /settings/sites | 0 |
| /settings/sites | -2 |
| /authors | 0 |
| /authors | -2 |
Scenario Outline: A page out of range goes to the last page with a notice
Given <path>?page=99&size=25 resolves 200 with [] and X-Total-Count <total>
When the user navigates to <route>?page=99
Then the URL is replaced (no history entry) by <route>?page=<last> and page <last> is requested
And the info notice reads "That page doesn't exist. Showing the last page."
And the last-page state is shown
Examples:
| route | path | total | last |
| /settings/sites | /v1/site/ | 61 | 3 |
| /authors | /v1/author/ | 128 | 6 |
Scenario Outline: A page out of range with a total of 0 shows the empty state
Given <path>?page=5&size=25 resolves 200 with [] and X-Total-Count 0
When the user navigates to <route>?page=5
Then the empty state shows, there is no redirect, no notice, and the URL is <route>
Examples:
| route | path |
| /settings/sites | /v1/site/ |
| /authors | /v1/author/ |
Scenario Outline: Combined q and page in the URL
When the user navigates to <route>?q=<q>&page=2
Then <path>?q=<q>&page=2&size=25 is requested and the search field holds "<q>"
Examples:
| route | path | q |
| /settings/sites | /v1/site/ | renew |
| /authors | /v1/author/ | mar |
Scenario Outline: Browser Back returns to the previous page of results
Given the user went from <route> to <route>?page=2 with Next
When the user uses Browser Back
Then the URL is <route> and page 1 is requested
Examples:
| route |
| /settings/sites |
| /authors |
Rule: Sites search
Scenario: The Sites search field
Given the owner is on /settings/sites
Then a search field with the placeholder "Search by name or key" is shown above the list
When the owner types "renew" and the debounce elapses
Then GET /v1/site/?q=renew&page=1&size=25 is requested and the list shows the matches
Scenario: A Sites search that matches the default site only shows what the API returns
Given GET /v1/site/?q=journal&page=1&size=25 resolves 200 with only "Journal" and X-Total-Count 1
When the owner searches "journal"
Then only "Journal" is listed and the footer shows "1 site"
Rule: Existing behaviour that must not regress
Scenario Outline: The unpaged site list still feeds the dependent features
Given the paged list shows page 2 of <total> sites
When the owner opens ?edit=<key>, blurs a duplicate key in the add form, or opens the delete dialog
Then GET /v1/site/ is requested without page and size and the result is used, not the paged rows
Examples:
| total | key |
| 61 | journal |
Scenario Outline: Read-only roles see the same Authors pager
Given the session role is "<role>"
When the user navigates to /authors?page=2
Then the same footer, summary and controls are shown, with no Add author button and no row menu
Examples:
| role |
| member |
Scenario Outline: Owner and admin see the same Authors pager with the management actions
Given the session role is "<role>"
When the user navigates to /authors?page=2
Then the same footer, summary and controls are shown, with the Add author button and the row menu
Examples:
| role |
| owner |
| admin |
Scenario: Deleting the last row of the last page
Given the user is on the last page of /authors with a single row and deletes it (204)
Then the previous page is requested and the URL is replaced by it
Scenario: Adding or deleting refreshes the total
Given a list of 25 rows (total 25, count line only)
When a row is added and the list reloads with total 26
Then the footer shows the pager and the summary "Showing 1–25 of 26"
Rule: Design conformance of the visual gate
Scenario: The Create author mobile states pass at the default budget
Given the editor states "Create author: dialog", "Create author: for me", "Create author: slug taken" and "Create author: failed" at 390x844
Then each capture is within 10 % of the board frame, with no per-state exception in web/visual/states.ts
Scenario: Integration "6. Errors" passes at the default budget
Given the board frame of Integration "6. Errors" is drawn with the 403 note
Then every capture of "6. Errors" is within 10 %, desktop and mobile, light and dark, with no maxPercent exception