From 73f4b539fecabb54b8932f55ae3e09d1a6b2232b Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Sun, 11 Oct 2026 19:14:37 +0200 Subject: [PATCH] feat: articles pagination --- api/handlers/article/articleHandler.go | 83 ++- docs/api/articles.md | 31 + features/articles_pagination_api.feature | 191 +++++ features/articles_pagination_api.qa.md | 44 ++ features/articles_pagination_screen.feature | 187 +++++ features/articles_pagination_screen.qa.md | 42 ++ features/public_article_listing.feature | 6 +- features/use_case_baseline.feature | 8 +- go.mod | 2 +- .../article/articleRepositoryInterface.go | 46 ++ .../repositories/articleRepository.go | 130 +++- .../infrastructure/useCases/ListArticles.go | 56 +- test/api_keys_http_test.go | 10 +- test/article_body_format_test.go | 4 +- test/article_publishing_http_test.go | 4 +- test/article_test.go | 84 +++ test/articles_list_http_test.go | 2 +- test/articles_pagination_api_http_test.go | 613 +++++++++++++++ test/public_article_listing_test.go | 8 +- test/use_case_baseline_test.go | 17 +- test/verified_account_required_http_test.go | 6 +- web/src/components/AppPaginator.vue | 200 +++++ web/src/components/ArticleCardList.vue | 18 +- web/src/components/ArticleTable.vue | 129 +++- .../composables/__tests__/useArticles.spec.ts | 43 +- web/src/composables/useArticles.ts | 43 +- web/src/data/__tests__/apiCatalogue.spec.ts | 32 +- web/src/data/apiCatalogue.ts | 44 +- web/src/i18n/messages.ts | 18 + web/src/i18n/spa.spec.ts | 26 +- .../lib/__tests__/articleListQuery.spec.ts | 102 +++ web/src/lib/__tests__/pagination.spec.ts | 42 ++ web/src/lib/articleListQuery.ts | 91 +++ web/src/lib/pagination.ts | 30 + web/src/views/ArticleView.vue | 377 +++++++--- web/src/views/HomeView.vue | 36 +- web/src/views/__tests__/ArticleView.spec.ts | 51 +- .../__tests__/ArticlesPagination.spec.ts | 703 ++++++++++++++++++ .../views/__tests__/HomeActivityCard.spec.ts | 33 +- .../views/__tests__/HomeUpdateBanner.spec.ts | 5 +- .../views/__tests__/HomeUpdateReady.spec.ts | 5 +- web/src/views/__tests__/HomeView.spec.ts | 59 +- web/src/views/__tests__/articleEnvelope.ts | 70 ++ web/visual/check.ts | 20 + web/visual/states.ts | 94 ++- 45 files changed, 3528 insertions(+), 317 deletions(-) create mode 100644 features/articles_pagination_api.feature create mode 100644 features/articles_pagination_api.qa.md create mode 100644 features/articles_pagination_screen.feature create mode 100644 features/articles_pagination_screen.qa.md create mode 100644 test/articles_pagination_api_http_test.go create mode 100644 web/src/components/AppPaginator.vue create mode 100644 web/src/lib/__tests__/articleListQuery.spec.ts create mode 100644 web/src/lib/__tests__/pagination.spec.ts create mode 100644 web/src/lib/articleListQuery.ts create mode 100644 web/src/lib/pagination.ts create mode 100644 web/src/views/__tests__/ArticlesPagination.spec.ts create mode 100644 web/src/views/__tests__/articleEnvelope.ts diff --git a/api/handlers/article/articleHandler.go b/api/handlers/article/articleHandler.go index 3b240e6..7380828 100644 --- a/api/handlers/article/articleHandler.go +++ b/api/handlers/article/articleHandler.go @@ -11,7 +11,9 @@ import ( "fmt" "log" "net/http" + "net/url" "strconv" + "strings" "time" "github.com/go-chi/chi/v5" @@ -268,26 +270,89 @@ func (h *Handler) callerIsVerified(r *http.Request) bool { return currentUser.IsVerified } +const ( + defaultListPage = 1 + defaultListSize = 25 +) + +type listQuery struct { + Page int `validate:"gte=1"` + Size int `validate:"oneof=5 25 50 100"` + Q string `validate:"max=200"` + Status string `validate:"omitempty,oneof=online draft"` + Sort string `validate:"oneof=title:asc title:desc status:asc status:desc created_at:asc created_at:desc updated_at:asc updated_at:desc"` +} + +const defaultListSort = "updated_at:desc" + +func parseSort(value string) domain.Sort { + field, direction, _ := strings.Cut(value, ":") + return domain.Sort{Field: domain.SortField(field), Direction: domain.SortDirection(direction)} +} + +type listResponse struct { + Items []articleResponse `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"size"` + Counts domain.Counts `json:"counts"` +} + +func intQueryParam(values url.Values, name string, fallback int) (int, error) { + if !values.Has(name) { + return fallback, nil + } + return strconv.Atoi(values.Get(name)) +} + +func readListQuery(values url.Values) (listQuery, error) { + page, err := intQueryParam(values, "page", defaultListPage) + if err != nil { + return listQuery{}, err + } + size, err := intQueryParam(values, "size", defaultListSize) + if err != nil { + return listQuery{}, err + } + status := values.Get("status") + if values.Has("status") && status == "" { + return listQuery{}, errors.New("empty status") + } + sortValue := defaultListSort + if values.Has("sort") { + sortValue = values.Get("sort") + } + return listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue}, nil +} + func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) { format, ok := parseBodyFormat(w, r) if !ok { return } - var articles []domain.Article - var err error - if h.callerIsVerified(r) { - articles, err = h.ListUseCase.ListAllArticles(format) - } else { - articles, err = h.ListUseCase.ListOnlineArticles(format) + query, err := readListQuery(r.URL.Query()) + if err != nil || validator.Validate.Struct(query) != nil { + http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest) + return } + + page, err := h.ListUseCase.ListArticlesPage(useCases.ListArticlesCommand{ + Format: format, + Verified: h.callerIsVerified(r), + Page: query.Page, + Size: query.Size, + Q: query.Q, + Status: query.Status, + Sort: parseSort(query.Sort), + }) if err != nil { http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError) return } - responses := make([]articleResponse, 0, len(articles)) - for _, article := range articles { + responses := make([]articleResponse, 0, len(page.Items)) + for _, article := range page.Items { response, err := toArticleResponse(article, format) if err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) @@ -296,7 +361,7 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) { responses = append(responses, response) } - articlesJson, err := json.Marshal(responses) + articlesJson, err := json.Marshal(listResponse{Items: responses, Total: page.Total, Page: page.Page, Size: page.Size, Counts: page.Counts}) if err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return diff --git a/docs/api/articles.md b/docs/api/articles.md index e80c2d3..9259511 100644 --- a/docs/api/articles.md +++ b/docs/api/articles.md @@ -13,6 +13,37 @@ Every route that returns an article accepts the optional `format` query paramete Visibility is unchanged: anonymous and unverified callers only see online articles, and an offline article answers `404 Article not found`. +## Pagination, search and counts + +`GET /v1/article/` always answers one page inside an envelope, with or without query parameters: + +```json +{ + "items": [], + "total": 30, + "page": 1, + "size": 25, + "counts": { "all": 30, "online": 30, "draft": 0 } +} +``` + +| Parameter | Values | Default | +| --- | --- | --- | +| `page` | integer >= 1 | `1` | +| `size` | `5`, `25`, `50` or `100` | `25` | +| `q` | up to 200 characters, matched anywhere in the title, ignoring case and accents | none | +| `status` | `online` or `draft` | none | +| `sort` | `title`, `status`, `created_at` or `updated_at`, then `:asc` or `:desc` (lowercase, for example `title:asc`) | `updated_at:desc` | + +- `items` is `[]` when empty, never `null`. Each item is the article object below. +- Items follow `sort` (default `updated_at:desc`) across the whole list before paging. `title` ignores case, `status` orders on online state (`status:asc` puts drafts first), and every ordering ends with `id` descending so pages never overlap. +- `total` is the number of visible articles matching `q` and `status`, before paging. `page` and `size` echo the values applied. +- `counts` ignores `q` and `status`, so tab counts do not move while searching. For anonymous and unverified callers `all` equals `online` and `draft` is `0`, and `status=draft` answers `items: []` and `total: 0`. +- A page beyond the last answers `200` with `items: []` and the real `total`. +- An invalid `page`, `size`, `status`, `sort` or an over-long `q` answers `400` with the plain-text message `The request cannot be processed due to a mismatch in the format of the body.` An invalid `format` is reported first. + +Breaking change: this route used to answer a bare JSON array. Clients must read the `items` property. + ## The `format` parameter | Value | `body` | `body_format` | diff --git a/features/articles_pagination_api.feature b/features/articles_pagination_api.feature new file mode 100644 index 0000000..004a963 --- /dev/null +++ b/features/articles_pagination_api.feature @@ -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/? + Then the body has page , size , total 60 and 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= + Then the item ids are + + 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= + Then total is , 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/? + 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" diff --git a/features/articles_pagination_api.qa.md b/features/articles_pagination_api.qa.md new file mode 100644 index 0000000..7967d68 --- /dev/null +++ b/features/articles_pagination_api.qa.md @@ -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. diff --git a/features/articles_pagination_screen.feature b/features/articles_pagination_screen.feature new file mode 100644 index 0000000..9926363 --- /dev/null +++ b/features/articles_pagination_screen.feature @@ -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=:, 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 + 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 "