feat: articles pagination
This commit is contained in:
1 parent
a2d224240c
commit
73f4b539fe
45 files changed
+3528
-317
No files matched your search
@@ -0,0 +1,191 @@
|
||||
Feature: Paginated article listing
|
||||
|
||||
As an API consumer or the RenewCMS back-office
|
||||
I want GET /v1/article/ to always answer one page of articles with the total and the tab counts
|
||||
So that large sites list articles quickly and the SPA can search, filter and page on the server
|
||||
|
||||
Background:
|
||||
Given GET /v1/article/ is mounted under jwtauth.Verifier only (features/public_article_listing.feature)
|
||||
And a caller is "verified" when the jwt cookie carries a user_id claim whose user has is_verified true, and "public" otherwise (missing, malformed, expired or unverified)
|
||||
And a public caller only ever sees articles with is_online true; a verified caller sees every article
|
||||
And BREAKING CHANGE: the body is no longer a bare JSON array; every successful request, with or without query parameters, answers {"items": [...], "total": N, "page": P, "size": S, "counts": {"all": A, "online": O, "draft": D}} in snake_case
|
||||
And each item is the existing article object (id, title, body, body_format, is_online, created_at, updated_at) and items is [] rather than null
|
||||
And paging follows the Activity convention: page is an integer >= 1 (default 1), size is one of 5, 25, 50, 100 (default 25)
|
||||
And q (optional, max 200 characters; case-insensitive, accent-insensitive, matches anywhere in the title) and status (optional, online or draft) filter the items
|
||||
And total is the number of visible articles matching q and status, before paging; page and size echo the values applied
|
||||
And counts ignores q and status, so the tab counts do not move while the user searches; it respects visibility: for a public caller all equals online and draft is 0
|
||||
And status=draft requested by a public caller matches nothing: items [], total 0
|
||||
And sort (optional) is one of title:asc, title:desc, status:asc, status:desc, created_at:asc, created_at:desc, updated_at:asc, updated_at:desc; the default is updated_at:desc; status orders by is_online (asc puts drafts first); every ordering ends with id descending as the tie-break, so pages are stable and never overlap
|
||||
And sort is mapped to a column and direction through a fixed whitelist in the repository, so request text never reaches the SQL; the same ordering applies on the search path (q) before slicing
|
||||
And a page beyond the last answers 200 with items [] and the real total
|
||||
And an invalid page, size, status, sort or an over-long q answers 400 with the plain-text body "The request cannot be processed due to a mismatch in the format of the body." (apperr.BodyErrorMessage), like GET /v1/activity, validated with validate tags through validator.Validate.Struct
|
||||
And format is validated first: an invalid format still answers 400 "The format must be one of: markdown, html, json."
|
||||
And ListArticlesUseCase reaches the data only through article.Repository; the handler never touches a repository; the repository query uses portable SQL (COUNT, LIMIT/OFFSET, ORDER BY, LIKE)
|
||||
And article.Filters keeps Page 0 and Size 0 meaning "unpaged, every article" for internal callers (purge plan, media listing), which are unchanged
|
||||
|
||||
Scenario: A bare request answers the envelope with the defaults
|
||||
Given the repository holds 30 online articles
|
||||
When a public client sends GET /v1/article/
|
||||
Then the status is 200
|
||||
And the body is an object with page 1, size 25, total 30, 25 items and counts {"all": 30, "online": 30, "draft": 0}
|
||||
|
||||
Scenario Outline: Defaults when only one parameter is given
|
||||
Given the repository holds 60 online articles
|
||||
When a public client sends GET /v1/article/?<query>
|
||||
Then the body has page <page>, size <size>, total 60 and <count> items
|
||||
|
||||
Examples:
|
||||
| query | page | size | count |
|
||||
| page=2 | 2 | 25 | 25 |
|
||||
| page=3 | 3 | 25 | 10 |
|
||||
| size=5 | 1 | 5 | 5 |
|
||||
| size=50 | 1 | 50 | 50 |
|
||||
|
||||
Scenario: Items are ordered by last update, newest first, then id descending
|
||||
Given online articles id 1 updated 2026-09-01, id 2 updated 2026-09-03, id 3 updated 2026-09-03
|
||||
When a public client sends GET /v1/article/?size=5
|
||||
Then the item ids are 3, 2, 1
|
||||
|
||||
Scenario Outline: Sort orders the whole list, not just the page
|
||||
Given a verified session and articles
|
||||
| id | title | created_at | updated_at | is_online |
|
||||
| 1 | Banana | 2026-01-01 | 2026-09-03 | true |
|
||||
| 2 | apple | 2026-01-03 | 2026-09-01 | false |
|
||||
| 3 | Cherry | 2026-01-02 | 2026-09-02 | true |
|
||||
When the client sends GET /v1/article/?sort=<sort>
|
||||
Then the item ids are <ids>
|
||||
|
||||
Examples:
|
||||
| sort | ids |
|
||||
| updated_at:desc | 1,3,2 |
|
||||
| updated_at:asc | 2,3,1 |
|
||||
| created_at:desc | 2,3,1 |
|
||||
| created_at:asc | 1,3,2 |
|
||||
| title:asc | 2,1,3 |
|
||||
| title:desc | 3,1,2 |
|
||||
| status:asc | 2,3,1 |
|
||||
| status:desc | 3,1,2 |
|
||||
|
||||
Scenario: No sort parameter keeps the default order
|
||||
Given the same articles
|
||||
When the client sends GET /v1/article/
|
||||
Then the item ids are 1,3,2
|
||||
|
||||
Scenario: Equal sort keys fall back to id descending
|
||||
Given three articles with the same title "Same" and ids 1, 2, 3
|
||||
When the client sends GET /v1/article/?sort=title:asc
|
||||
Then the item ids are 3,2,1
|
||||
|
||||
Scenario: Sort is applied before paging
|
||||
Given 12 online articles whose titles sort alphabetically as ids 1..12
|
||||
When a client requests ?sort=title:desc&size=5 for pages 1, 2 and 3
|
||||
Then the pages hold ids 12..8, 7..3 and 2..1 with no repeat
|
||||
|
||||
Scenario: Sort combines with search and status
|
||||
Given a verified session and draft articles "Tile A", "Tile C", "Tile B" and an online one "Tile D"
|
||||
When the client sends GET /v1/article/?q=tile&status=draft&sort=title:asc
|
||||
Then the item titles are "Tile A", "Tile B", "Tile C", total is 3 and counts is unchanged by q, status and sort
|
||||
|
||||
Scenario: Pages partition the list with no overlap
|
||||
Given 12 online articles
|
||||
When a client requests ?page=1&size=5, ?page=2&size=5 and ?page=3&size=5
|
||||
Then the three pages hold 5, 5 and 2 items, total is 12 on each, and no id repeats
|
||||
|
||||
Scenario: A public caller's total and counts cover online articles only
|
||||
Given 5 online and 3 offline articles
|
||||
When a public client sends GET /v1/article/?size=5
|
||||
Then total is 5, counts is {"all": 5, "online": 5, "draft": 0} and no item has is_online false
|
||||
|
||||
Scenario: A verified caller sees offline articles in the page, the total and the counts
|
||||
Given 5 online and 3 offline articles and a valid jwt cookie of a verified user
|
||||
When the client sends GET /v1/article/?size=5
|
||||
Then total is 8, counts is {"all": 8, "online": 5, "draft": 3} and 3 items have is_online false
|
||||
|
||||
Scenario: An unverified or invalid session is treated as public
|
||||
Given 5 online and 3 offline articles
|
||||
And the jwt cookie is malformed, or belongs to an unverified user
|
||||
When the client sends GET /v1/article/?size=5
|
||||
Then total is 5 and no offline article is returned
|
||||
|
||||
Scenario: A page beyond the last is empty with the real total
|
||||
Given 12 online articles
|
||||
When a client sends GET /v1/article/?page=99&size=5
|
||||
Then the status is 200, items is [], total is 12, page is 99, size is 5 and counts.all is 12
|
||||
|
||||
Scenario: No articles at all
|
||||
Given the repository is empty
|
||||
When a client sends GET /v1/article/
|
||||
Then the status is 200, items is [], total is 0 and counts is {"all": 0, "online": 0, "draft": 0}
|
||||
|
||||
Scenario Outline: Status filter
|
||||
Given 4 online and 2 offline articles and a valid jwt cookie of a verified user
|
||||
When the client sends GET /v1/article/?status=<status>
|
||||
Then total is <total>, counts is {"all": 6, "online": 4, "draft": 2} and every item matches the status
|
||||
|
||||
Examples:
|
||||
| status | total |
|
||||
| online | 4 |
|
||||
| draft | 2 |
|
||||
|
||||
Scenario: A public caller asking for drafts gets nothing
|
||||
Given 4 online and 2 offline articles
|
||||
When a public client sends GET /v1/article/?status=draft
|
||||
Then items is [], total is 0 and counts is {"all": 4, "online": 4, "draft": 0}
|
||||
|
||||
Scenario: Search narrows the page and the total but not the counts
|
||||
Given online articles "Choosing lime plaster for old stone walls" and "How we price a bathroom renovation"
|
||||
When a client sends GET /v1/article/?q=PLÂSTER
|
||||
Then total is 1, the only item is the lime plaster article and counts.all is 2
|
||||
|
||||
Scenario: Search and status combine with paging
|
||||
Given a verified session and 7 draft articles whose title contains "tile" and 3 online ones
|
||||
When the client sends GET /v1/article/?q=tile&status=draft&size=5&page=2
|
||||
Then total is 7, the page holds 2 items and counts.draft is 7
|
||||
|
||||
Scenario: Search that matches nothing
|
||||
When a client sends GET /v1/article/?q=zzzz
|
||||
Then items is [], total is 0 and counts is unchanged
|
||||
|
||||
Scenario Outline: Invalid parameters answer 400 like Activity
|
||||
When a client sends GET /v1/article/?<query>
|
||||
Then the status is 400
|
||||
And the body is "The request cannot be processed due to a mismatch in the format of the body."
|
||||
And the repository was not called
|
||||
|
||||
Examples:
|
||||
| query |
|
||||
| page=0 |
|
||||
| page=-1 |
|
||||
| page=abc |
|
||||
| page= |
|
||||
| page=1.5 |
|
||||
| size=0 |
|
||||
| size=10 |
|
||||
| size=200 |
|
||||
| size=abc |
|
||||
| status=archived |
|
||||
| status= |
|
||||
| q=<201 characters>|
|
||||
| page=0&size=5 |
|
||||
| page=1&size=1000 |
|
||||
| sort=title |
|
||||
| sort=title:up |
|
||||
| sort=body:asc |
|
||||
| sort= |
|
||||
| sort=updated_at |
|
||||
| sort=title:asc;DROP TABLE |
|
||||
| sort=TITLE:ASC |
|
||||
|
||||
Scenario: Invalid format is reported first
|
||||
When a client sends GET /v1/article/?page=0&format=pdf
|
||||
Then the status is 400 and the body is "The format must be one of: markdown, html, json."
|
||||
|
||||
Scenario: format and paging combine
|
||||
Given 3 online articles
|
||||
When a client sends GET /v1/article/?size=5&format=html
|
||||
Then every item carries body_format "html" and total is 3
|
||||
|
||||
Scenario: Repository failure answers 500
|
||||
Given the repository fails on its next call
|
||||
When a client sends GET /v1/article/
|
||||
Then the status is 500 and the body is "Failed to retrieve articles"
|
||||
@@ -0,0 +1,44 @@
|
||||
# QA: Paginated article listing (API)
|
||||
|
||||
## HTTP-level (Go, `test/`, `task test`)
|
||||
|
||||
Build `article.NewHandler(...)` over the real `ListArticlesUseCase` and a fake `article.Repository` (extend `mockArticleRepo` in `test/article_test.go`, keeping type names unique across files). The fake implements the interface contract: paging (Page/Size 0 = unpaged), q/status filtering, ordering updated_at DESC then id DESC, total, and the visibility counts. Mount on a fresh `chi.NewRouter()` mirroring `NewArticleRouter` in `api/router.go` (GET / under `jwtauth.Verifier` only, test `jwtauth.New("HS256", ...)`). Call `validator.InitValidator()`. Use a user fake for the verified, unverified and missing cases, as in `test/public_article_listing_test.go`. No live server, no SQLite file.
|
||||
|
||||
Helper: decode the body into {items []map, total, page, size int, counts {all, online, draft}} and also assert the raw JSON contains `"items":[` (never null).
|
||||
|
||||
1. Bare request: 30 online articles, GET /v1/article/ -> object (not array), page 1, size 25, total 30, 25 items, counts 30/30/0.
|
||||
2. Defaults table (page=2, page=3, size=5, size=50) on 60 articles.
|
||||
3. Order: ids 3,2,1 for the equal-timestamp case with no sort parameter (default updated_at:desc). Sort table: the 8 whitelisted values on the mixed-case fixture (apple/Banana/Cherry), asserting the item ids. The fake repo implements the same contract: key, direction, then id desc.
|
||||
3a. Equal titles with sort=title:asc -> ids 3,2,1.
|
||||
3b. Sort before paging: 12 articles, ?sort=title:desc&size=5, three pages, no overlap.
|
||||
3c. Sort + q + status combination: ordered titles, total 3, counts unchanged.
|
||||
3d. Use-case test: ListArticlesUseCase passes the typed sort through to Filters and defaults to updated_at desc when absent. Only whitelisted values reaching Order in the repository is a code-review item; the handler test covers the 400 side.
|
||||
4. Partition: 12 articles, pages 1-3 of size 5 -> 5,5,2 and 12 distinct ids.
|
||||
5. Public: 5 online + 3 offline -> total 5, counts 5/5/0, no offline item.
|
||||
6. Verified: same data -> total 8, counts 8/5/3, three offline items.
|
||||
7. Malformed cookie and unverified user -> same as public.
|
||||
8. Out of range: ?page=99&size=5 -> 200, raw `"items":[]`, total 12, counts.all 12.
|
||||
9. Empty repository -> items [], total 0, counts 0/0/0.
|
||||
10. Status table (verified): online -> total 4, draft -> total 2, counts always 6/4/2.
|
||||
11. Public status=draft -> items [], total 0, counts 4/4/0.
|
||||
12. q=PLÂSTER (accent and case insensitive) -> total 1, counts.all 2. q=zzzz -> empty, counts unchanged.
|
||||
13. q+status+page combination: total 7, 2 items on page 2, counts.draft 7.
|
||||
14. Invalid table (all rows incl. `page=`, `page=1.5`, `status=archived`, `status=`, 201-char q, and the 7 sort rows: `sort=title`, `sort=title:up`, `sort=body:asc`, `sort=`, `sort=updated_at`, `sort=title:asc;DROP TABLE`, `sort=TITLE:ASC`): each -> 400 with the exact BodyErrorMessage text, and the fake's FindByFilters/count counters stay 0.
|
||||
15. ?page=0&format=pdf -> 400 with the format message.
|
||||
16. ?size=5&format=html -> every item body_format "html".
|
||||
17. Fake repo failure (existing `failNext("FindByFilters")`) -> 500 "Failed to retrieve articles".
|
||||
18. Use-case tests: the paged list method returns items, total and counts, propagates a repo error (errors.Is on a sentinel), and the unpaged `Filters{}` call used by `purgePlan.go` and `ListMedia.go` still returns every article (existing tests keep passing).
|
||||
19. Existing tests that decode `GET /v1/article/` as an array (`test/public_article_listing_test.go`, `test/article_test.go`, `test/use_case_baseline_test.go`, features/articles_list.qa.md item 1) are amended to the envelope. List them in the diff.
|
||||
|
||||
## Docs (verified by reading the diff)
|
||||
|
||||
- `docs/api/articles.md`: new "Pagination, search, sort and counts" section (parameters including `sort` values, default and id tie-break, envelope, ordering, errors) and a "Breaking change" note, like the existing format note: GET /v1/article/ no longer returns a bare array; read `items`.
|
||||
- `web/src/data/apiCatalogue.ts` `art-list`: add `page`, `size`, `q`, `status`, `sort` query entries, the 400 error, and a paged JSON example; the Integration specs that assert this catalogue are amended.
|
||||
- `docs/design/screens/integration.md` is read-only for agents: flag that its text "GET /v1/article/" example needs the user's update.
|
||||
- A release note under `docs/releases/` is the maintainer's call: flag it.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- Real SQLite file via `task dev`: seed 60 articles, check ?page=2&size=25, ordering, q accent-insensitivity, total and counts. MySQL and PostgreSQL parity of the COUNT / LIMIT-OFFSET / LIKE queries is manual (no live DB in tests).
|
||||
- Sort by `title` on a live SQLite file, and on MySQL/PostgreSQL if available: title collation differs (binary, case-insensitive, locale), so mixed-case and accented ordering may differ between databases. Accepted; note any difference.
|
||||
- An anonymous curl against a running server returns the envelope.
|
||||
@@ -0,0 +1,187 @@
|
||||
Feature: Articles list pagination
|
||||
|
||||
As a verified RenewCMS user with many articles
|
||||
I want the Articles list split into pages with a page-size menu, server-side search and tabs, and shareable URLs
|
||||
So that I can browse a large list quickly and come back to the same page
|
||||
|
||||
Background:
|
||||
Given /article is the verified Articles route (features/articles_list.feature), per docs/design/routes.md, docs/design/screens/articles.md group "Articles: pagination" and the Paginator in docs/design/components.md
|
||||
And the route stays /article (the design's /articles is not adopted)
|
||||
And the page requests GET /v1/article/?format=html&page=P&size=S&q=Q&status=T and reads {items, total, page, size, counts} (features/articles_pagination_api.feature); the tab counts "All", "Online", "Drafts" come from counts and do not change while searching
|
||||
And page, size, q, status (tab) and sort live in the URL: ?page=&size=&q=&status=&sort=; the defaults (page 1, size 25, no q, tab All, sort updated_at:desc) are omitted from the URL and from the request only where the API default applies
|
||||
And the sortable columns are Title (title), Status (status), Created (created_at) and Updated (updated_at); a header activation sends sort=<field>:<direction>, the active column shows an arrow icon for its direction, and sorting is done by the server over the whole list, never inside the loaded page
|
||||
And an invalid sort in the URL falls back to updated_at:desc and is replaced in the URL
|
||||
And the table caption reads "Articles, sorted by last updated, newest first" for the default sort and "Articles, sorted by {column}, {ascending|descending}" otherwise, with a French entry in messages.ts
|
||||
And the mobile card list has no sort control; the sort parameter is still honoured from the URL there
|
||||
And an invalid or missing size falls back to 25 and an invalid page (not an integer >= 1) falls back to 1, both replaced in the URL
|
||||
And a change to search, tab, sort or size resets page to 1 (and drops it from the URL); search keeps its 120 ms debounce
|
||||
And the URL changes when a response succeeds, not when the request is sent
|
||||
And the summary reads "Showing {from}–{to} of {total}" with locale-formatted numbers, from total (the filtered count)
|
||||
And the Paginator has First, Previous, page numbers with ellipses marking skipped ranges, Next, Last, and the page-size select "5 per page", "25 per page", "50 per page", "100 per page"
|
||||
And the current page uses --rc-sand fill, --rc-on-sand text and a --ui-primary border and has aria-current="page"; disabled controls are at 40 % opacity; buttons are 32 px (44 px on mobile)
|
||||
And on mobile the paginator is Previous, "Page N of M" and Next
|
||||
And the paginator is hidden when total fits on one page; the summary and the page-size select stay
|
||||
And keyboard: Tab reaches the page-size select, first, previous, each page, next, last; Enter or Space activates; ArrowDown opens the page-size list, Enter selects, Esc closes; every control shows a 2 px --ui-primary focus outline with a 2 px offset
|
||||
And this slice amends features/articles_list.feature: search, tabs and sort are no longer computed client-side over the full list; the list, the counts and the empty states come from the server response
|
||||
And HomeView, ArticleView and useArticles.listArticles migrate to the envelope: they read items (and counts or total where they count) and send explicit paging; no caller reads a bare array any more
|
||||
And the fixture below is total 312, size 25 (13 pages) unless stated; the frames draw at most 10 rows
|
||||
|
||||
Scenario: 1 First page
|
||||
Given GET /v1/article/?format=html&page=1&size=25 resolves 200 with 25 items and total 312
|
||||
When the user navigates to /article
|
||||
Then the summary reads "Showing 1–25 of 312"
|
||||
And First and Previous are disabled, page 1 is current, Next and Last are enabled
|
||||
And the URL has no page or size
|
||||
|
||||
Scenario: 2 Middle page
|
||||
Given GET ...&page=7&size=25 resolves 200 with 25 items and total 312
|
||||
When the user navigates to /article?page=7
|
||||
Then the summary reads "Showing 151–175 of 312"
|
||||
And page 7 is current with --rc-sand / --rc-on-sand and aria-current="page"
|
||||
And ellipses mark the skipped ranges and every control is enabled
|
||||
|
||||
Scenario: 3 Last page
|
||||
Given GET ...&page=13&size=25 resolves 200 with 12 items and total 312
|
||||
When the user navigates to /article?page=13
|
||||
Then the summary reads "Showing 301–312 of 312"
|
||||
And Next and Last are disabled
|
||||
|
||||
Scenario: 4 Single page
|
||||
Given GET /v1/article/ resolves 200 with 6 items, total 6 and counts {all 6, online 4, draft 2}
|
||||
When the user navigates to /article
|
||||
Then the summary reads "Showing 1–6 of 6" and the page-size select stays
|
||||
And the paginator (First ... Last) is not rendered
|
||||
And the tabs read "All 6" "Online 4" "Drafts 2"
|
||||
|
||||
Scenario: 5 Loading next page
|
||||
Given the first page has loaded and GET ...&page=2 has not resolved
|
||||
When the user activates Next
|
||||
Then skeleton rows keep the table height and the list is aria-busy="true"
|
||||
And every paginator control is disabled and the summary reads "Loading page 2…"
|
||||
When the request resolves 200
|
||||
Then the rows and summary of page 2 appear and the URL becomes /article?page=2
|
||||
|
||||
Scenario Outline: 6 Error loading a page keeps the previous rows
|
||||
Given page 6 is shown
|
||||
When the user activates Next and GET ...&page=7 <failure>
|
||||
Then the page-6 rows stay, the URL stays /article?page=6 and the controls are re-enabled
|
||||
And a notice reads "Couldn't load page 7. Showing page 6." with a "Retry" action
|
||||
When the user activates "Retry"
|
||||
Then GET ...&page=7 is sent again and, on 200, page 7 replaces the rows, the URL becomes ?page=7 and the notice goes
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: 7 Page out of range redirects to the last page with a notice
|
||||
Given GET ...&page=99 resolves 200 with items [] and total 312
|
||||
When the user navigates to /article?page=99
|
||||
Then the URL is replaced (no history entry) by /article?page=13 and GET ...&page=13 is sent
|
||||
And the notice reads "That page doesn't exist. Showing the last page."
|
||||
And the last-page state (3) is shown
|
||||
|
||||
Scenario: 7b Out of range with total 0 shows the empty state, no redirect
|
||||
Given GET ...&page=5 resolves 200 with items [] and total 0
|
||||
When the user navigates to /article?page=5
|
||||
Then the "No articles yet" empty state shows and the URL is /article
|
||||
|
||||
Scenario: 8 Search resets to page 1
|
||||
Given the user is on /article?page=7
|
||||
When the user types "plaster" in the search field and the debounce elapses
|
||||
Then GET ...&q=plaster&page=1 is sent
|
||||
And on success the URL is /article?q=plaster (page omitted)
|
||||
And the tab counts are unchanged
|
||||
And the same reset happens for a tab change, a sort change and a page-size change
|
||||
|
||||
Scenario: 9 Empty after search
|
||||
Given GET ...&q=tiles resolves 200 with items [], total 0 and counts {all 6, online 4, draft 2}
|
||||
When the user searches "tiles"
|
||||
Then the paginator and the summary are hidden
|
||||
And the no-results state shows "No articles match "tiles"", "Check the spelling, or search for a shorter part of the title." and "Clear search"
|
||||
And the tabs still read "All 6" "Online 4" "Drafts 2"
|
||||
|
||||
Scenario Outline: 10 Page size menu
|
||||
Given the user is on /article?page=3
|
||||
When the user opens the page-size select (it opens upward) and chooses "<label>"
|
||||
Then GET ...&page=1&size=<size> is sent and, on success, the URL is <url>
|
||||
|
||||
Examples:
|
||||
| label | size | url |
|
||||
| 5 per page | 5 | /article?size=5 |
|
||||
| 25 per page | 25 | /article |
|
||||
| 50 per page | 50 | /article?size=50 |
|
||||
| 100 per page | 100 | /article?size=100 |
|
||||
|
||||
Scenario: 10b Invalid URL values fall back
|
||||
When the user navigates to /article?size=10&page=abc
|
||||
Then GET ... is sent with page=1 and size=25 and the URL is replaced by /article
|
||||
|
||||
Scenario: 11 Focus-visible
|
||||
When the user tabs to Next
|
||||
Then Next shows a 2 px --ui-primary outline with a 2 px offset
|
||||
And the same ring shows on every paginator control and on the page-size select
|
||||
|
||||
Scenario: Keyboard, the page-size list
|
||||
When the user focuses the page-size select and presses ArrowDown
|
||||
Then the list opens; Enter selects the highlighted size; Esc closes it and returns focus to the select
|
||||
|
||||
Scenario: Tabs filter on the server
|
||||
Given the Default list has loaded and the user is on /article?page=3
|
||||
When the user selects the "Drafts" tab
|
||||
Then GET ...&status=draft&page=1 is sent and, on success, the URL is /article?status=draft
|
||||
And the tab counts are unchanged
|
||||
|
||||
Scenario Outline: Sort header, server-side, resets to page 1
|
||||
Given the user is on /article?page=7
|
||||
When the user activates the "<column>" header (first click)
|
||||
Then GET ...&sort=<sort>&page=1 is sent
|
||||
And on success the URL is /article?sort=<sort> (page omitted)
|
||||
And the header shows the arrow icon for its direction and the tab counts are unchanged
|
||||
|
||||
Examples:
|
||||
| column | sort |
|
||||
| Title | title:asc |
|
||||
| Status | status:asc |
|
||||
| Created | created_at:asc |
|
||||
| Updated | updated_at:asc |
|
||||
|
||||
Scenario: Activating the sorted header again toggles the direction
|
||||
Given the list is sorted by Title ascending
|
||||
When the user activates the "Title" header
|
||||
Then GET ...&sort=title:desc&page=1 is sent and, on success, the URL is /article?sort=title:desc
|
||||
|
||||
Scenario: Returning to the default sort drops sort from the URL
|
||||
Given the URL is /article?sort=title:asc
|
||||
When the user activates the "Updated" header until it is updated_at:desc
|
||||
Then the request sends sort=updated_at:desc and, on success, the URL has no sort
|
||||
|
||||
Scenario: Sort is kept across paging, page size, search and tabs
|
||||
Given the URL is /article?sort=title:asc
|
||||
When the user activates Next, or changes the size, or searches "plaster"
|
||||
Then every request carries sort=title:asc and the URL keeps sort=title:asc (search, tab and size also reset the page)
|
||||
|
||||
Scenario: A sorted URL loads sorted
|
||||
When the user navigates to /article?sort=created_at:desc&page=2
|
||||
Then GET ... carries sort=created_at:desc&page=2 and the Created header shows the arrow-down icon
|
||||
|
||||
Scenario: Invalid sort in the URL falls back
|
||||
When the user navigates to /article?sort=body:asc
|
||||
Then GET ... carries no sort (the default applies), the Updated header shows the arrow-down icon and the URL is replaced by /article
|
||||
|
||||
Scenario: The caption follows the sort
|
||||
Given the list is sorted by Title ascending
|
||||
Then the table's visually hidden caption reads "Articles, sorted by title, ascending"
|
||||
|
||||
Scenario: Mobile, Previous, "Page N of M", Next
|
||||
Given the viewport is below md and page 2 of 13 is shown
|
||||
Then the paginator reads Previous, "Page 2 of 13", Next with 44 px controls and no page numbers
|
||||
|
||||
Scenario: Deleting the last row of the last page
|
||||
Given the user is on the last page with a single row and deletes it (204)
|
||||
Then the list reloads the previous page and the URL is replaced by it
|
||||
And the tab counts are recomputed from the new response
|
||||
|
||||
Scenario: Home and the article view read the envelope
|
||||
When HomeView or ArticleView load their articles
|
||||
Then they call GET /v1/article/ with explicit paging and read items, never a bare array
|
||||
@@ -0,0 +1,42 @@
|
||||
# QA: Articles list pagination (SPA)
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi` for `GET /v1/article/?format=html&...` (the route key includes the query) per case. Mount ArticlesPage inside DashboardShell with a verified session and a test router at /article, as in features/articles_list.qa.md. The fixture is 312 articles at 25 per page, built by a helper, with counts {all, online, draft}. No system date freezing.
|
||||
|
||||
1. First page: no page/size in the URL, "Showing 1–25 of 312", First/Previous disabled, page 1 aria-current.
|
||||
2. Middle page: ?page=7 -> "Showing 151–175 of 312", aria-current on 7, ellipsis present.
|
||||
3. Last page: ?page=13 -> "Showing 301–312 of 312", Next/Last disabled.
|
||||
4. Single page: total 6 -> no paginator nav, summary "Showing 1–6 of 6", select present, tabs "All 6" "Online 4" "Drafts 2".
|
||||
5. Loading next page: unresolved page-2 promise -> skeleton rows, aria-busy, controls disabled, "Loading page 2…"; resolve -> rows, URL ?page=2 (URL unchanged before the response).
|
||||
6. Error (500 and network rejection): rows and URL (?page=6) kept, notice "Couldn't load page 7. Showing page 6.", Retry resends page 7, success clears the notice and sets ?page=7.
|
||||
7. Out of range: ?page=99 returns {items:[], total:312} -> router.replace to ?page=13 (assert no extra history entry), second request page=13, notice text. 7b: total 0 -> empty state, no redirect.
|
||||
8. Search resets: from ?page=7 type "plaster" (fake timers past 120 ms) -> request q=plaster&page=1, URL /article?q=plaster after the response, tab counts unchanged. Same assertion for tab, sort (request sort=title:asc&page=1) and size.
|
||||
8a. Sort headers: for Title, Status, Created, Updated, a first click sends sort=<field>:asc&page=1, the URL becomes /article?sort=<field>:asc only after the response, the arrow icon shows and counts are unchanged.
|
||||
8b. A second click toggles to desc; returning to updated_at:desc drops sort from the URL.
|
||||
8c. Sort kept across Next, size change and search.
|
||||
8d. A sorted URL on load (?sort=created_at:desc&page=2) sends it and shows the Created arrow-down; an invalid ?sort=body:asc sends no sort and the URL is replaced by /article.
|
||||
8e. No client-side re-sorting: with the mock returning rows in a fixed order, the table shows exactly that order whatever the sorting state.
|
||||
8f. Caption: default "Articles, sorted by last updated, newest first"; sorted by Title ascending "Articles, sorted by title, ascending"; French strings present.
|
||||
8g. Mobile card list renders no sort control.
|
||||
9. Empty after search: paginator and summary absent, copy and "Clear search", tab counts unchanged.
|
||||
10. Page-size menu: 5/25/50/100 -> request and URL; ArrowDown opens, Enter selects, Esc closes and restores focus. 10b: ?size=10&page=abc -> request page=1&size=25, URL replaced.
|
||||
11. Focus-visible: the focus-ring tokens on every control (the pixel outline is a visual check).
|
||||
12. Tabs on the server: Drafts -> status=draft&page=1, counts unchanged.
|
||||
13. Mobile: below md, Previous / "Page 2 of 13" / Next, no numbers.
|
||||
14. Delete last row of last page: reloads the previous page, counts refreshed.
|
||||
15. HomeView, ArticleView and useArticles: with the envelope mocked, they render from `items`; no code path reads a bare array (a mocked bare array makes the test fail).
|
||||
16. Existing specs amended: features/articles_list.qa.md items 1-18 (search and tab counts server-driven, `GET /v1/article/` mocked as the envelope), `web/src/i18n/spa.spec.ts` (mock around line 417), the ArticleTable specs that rely on client-side TanStack sorting, and the Home, ArticleView and Integration specs. List them in the diff.
|
||||
|
||||
## Visual (`task visual:check -- articles`)
|
||||
|
||||
Add a setup per state in `web/visual/states.ts`, reusing the board's own sample data from group "Articles: pagination" in `docs/design/source/RenewCMS Screens Back-office.dc.html` (total 312, size 25, titles from the frame script), at 1440x900 and 390x844, light and dark; every capture within 10 %. States: First page, Middle page, Last page, Single page, Loading next page, Error loading a page, Page out of range, Search resets to page 1, Empty after search, Page size menu, Focus-visible. A state whose frame shows relative dates ("2 hours ago") is `skip: 'clock-sensitive'`; confirm per frame when coding. Take layout (summary vs paginator vs select, select opening upward) from the frame markup, not from components.md prose. The Articles group's existing states are re-run, since the list now comes from the envelope.
|
||||
|
||||
## Manual
|
||||
|
||||
- Keyboard order and focus return (Tab path, Enter/Space, ArrowDown/Enter/Esc), hover and motion (the skeleton pulse stops under reduced motion).
|
||||
- Keyboard activation (Enter/Space) of the sort headers and the screen-reader announcement of the sort direction (aria-sort).
|
||||
- The sorted-header arrow is part of the existing Articles board states; if a frame lacks it, raise it with the user rather than inventing a state.
|
||||
- Real round trip with `task dev`: page, size, q, status, total and counts from a live SQLite file; a reload keeps the URL state; browser Back after a size change.
|
||||
- Out-of-range redirect against a real shrinking list (delete rows in another tab).
|
||||
- Home and Article views against the real API after the envelope change.
|
||||
@@ -8,8 +8,8 @@ Feature: Public and authenticated article listing
|
||||
|
||||
Background:
|
||||
Given internal/domain/article.Article has a json "is_online" field
|
||||
And ListArticlesUseCase.ListOnlineArticles filters article.Filters{IsOnline: true} and
|
||||
ListArticlesUseCase.ListAllArticles applies no filter, both already implemented
|
||||
And ListArticlesUseCase.ListArticlesPage filters article.Filters{IsOnline: true} for an unverified
|
||||
caller and applies no visibility filter for a verified one
|
||||
And before this slice, GET /v1/article/ sits inside the jwtauth Verifier + Authenticator group
|
||||
in api/router.go along with GET /v1/article/{id}, POST /v1/article/ and DELETE /v1/article/{id},
|
||||
so an anonymous request to any of them answers 401, not an online-only list
|
||||
@@ -19,7 +19,7 @@ Feature: Public and authenticated article listing
|
||||
And a later slice, features/public_article_read.feature, moves GET /v1/article/{id} into the
|
||||
same Verifier-only group as GET /v1/article/, so the 401 Examples table below is updated
|
||||
accordingly
|
||||
And Handler.ListArticles decides between ListAllArticles and ListOnlineArticles by checking the
|
||||
And Handler.ListArticles decides between a verified and an unverified ListArticlesPage call by checking the
|
||||
request for a valid jwt, read with jwtauth.FromContext from the token the router-level
|
||||
jwtauth.Verifier (header and cookie sources) placed in the request context, rather than
|
||||
requiring the router-level Authenticator middleware; article.NewHandler takes no token
|
||||
|
||||
@@ -65,13 +65,13 @@ Feature: Use case baseline characterization
|
||||
Then the result is the articles with ids <ids>, and no error
|
||||
|
||||
Examples:
|
||||
| method | ids |
|
||||
| ListOnlineArticles | 1,3 |
|
||||
| ListAllArticles | 1,2,3 |
|
||||
| method | ids |
|
||||
| ListArticlesPage unverified | 1,3 |
|
||||
| ListArticlesPage verified | 1,2,3 |
|
||||
|
||||
Scenario: Listing articles on an empty repository returns an empty slice, not an error
|
||||
Given an empty article repository fake
|
||||
When ListOnlineArticles is called
|
||||
When ListArticlesPage is called
|
||||
Then the result is an empty slice and no error
|
||||
|
||||
Scenario Outline: UpdateArticle delegates each operation to the repository and surfaces not-found for an unknown id
|
||||
|
||||
Reference in new issue
Block a user