feat: articles pagination

This commit is contained in:
Florian Sylvain committed 2026-10-11 19:14:37 +02:00
1 parent a2d224240c
commit 73f4b539fe
45 files changed
+3528 -317

No files matched your search

+74 -9
View File
@@ -11,7 +11,9 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -268,26 +270,89 @@ func (h *Handler) callerIsVerified(r *http.Request) bool {
return currentUser.IsVerified 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) { func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
format, ok := parseBodyFormat(w, r) format, ok := parseBodyFormat(w, r)
if !ok { if !ok {
return return
} }
var articles []domain.Article query, err := readListQuery(r.URL.Query())
var err error if err != nil || validator.Validate.Struct(query) != nil {
if h.callerIsVerified(r) { http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
articles, err = h.ListUseCase.ListAllArticles(format) return
} else {
articles, err = h.ListUseCase.ListOnlineArticles(format)
} }
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 { if err != nil {
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError) http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
return return
} }
responses := make([]articleResponse, 0, len(articles)) responses := make([]articleResponse, 0, len(page.Items))
for _, article := range articles { for _, article := range page.Items {
response, err := toArticleResponse(article, format) response, err := toArticleResponse(article, format)
if err != nil { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) 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) 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 { if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError) http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return return
+31
View File
@@ -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`. 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 ## The `format` parameter
| Value | `body` | `body_format` | | Value | `body` | `body_format` |
+191
View File
@@ -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"
+44
View File
@@ -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.
+187
View File
@@ -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
+42
View File
@@ -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.
+3 -3
View File
@@ -8,8 +8,8 @@ Feature: Public and authenticated article listing
Background: Background:
Given internal/domain/article.Article has a json "is_online" field Given internal/domain/article.Article has a json "is_online" field
And ListArticlesUseCase.ListOnlineArticles filters article.Filters{IsOnline: true} and And ListArticlesUseCase.ListArticlesPage filters article.Filters{IsOnline: true} for an unverified
ListArticlesUseCase.ListAllArticles applies no filter, both already implemented caller and applies no visibility filter for a verified one
And before this slice, GET /v1/article/ sits inside the jwtauth Verifier + Authenticator group 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}, 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 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 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 same Verifier-only group as GET /v1/article/, so the 401 Examples table below is updated
accordingly 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 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 jwtauth.Verifier (header and cookie sources) placed in the request context, rather than
requiring the router-level Authenticator middleware; article.NewHandler takes no token requiring the router-level Authenticator middleware; article.NewHandler takes no token
+4 -4
View File
@@ -65,13 +65,13 @@ Feature: Use case baseline characterization
Then the result is the articles with ids <ids>, and no error Then the result is the articles with ids <ids>, and no error
Examples: Examples:
| method | ids | | method | ids |
| ListOnlineArticles | 1,3 | | ListArticlesPage unverified | 1,3 |
| ListAllArticles | 1,2,3 | | ListArticlesPage verified | 1,2,3 |
Scenario: Listing articles on an empty repository returns an empty slice, not an error Scenario: Listing articles on an empty repository returns an empty slice, not an error
Given an empty article repository fake Given an empty article repository fake
When ListOnlineArticles is called When ListArticlesPage is called
Then the result is an empty slice and no error 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 Scenario Outline: UpdateArticle delegates each operation to the repository and surfaces not-found for an unknown id
+1 -1
View File
@@ -23,6 +23,7 @@ require (
golang.org/x/image v0.46.0 golang.org/x/image v0.46.0
golang.org/x/net v0.59.0 golang.org/x/net v0.59.0
golang.org/x/sync v0.23.0 golang.org/x/sync v0.23.0
golang.org/x/text v0.42.0
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.3 gorm.io/driver/postgres v1.6.3
@@ -73,7 +74,6 @@ require (
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/sys v0.48.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
modernc.org/libc v1.67.0 // indirect modernc.org/libc v1.67.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
@@ -1,11 +1,57 @@
package article package article
const (
StatusOnline = "online"
StatusDraft = "draft"
)
type SortField string
type SortDirection string
const (
SortByTitle SortField = "title"
SortByStatus SortField = "status"
SortByCreatedAt SortField = "created_at"
SortByUpdatedAt SortField = "updated_at"
SortAsc SortDirection = "asc"
SortDesc SortDirection = "desc"
)
type Sort struct {
Field SortField
Direction SortDirection
}
func DefaultSort() Sort {
return Sort{Field: SortByUpdatedAt, Direction: SortDesc}
}
type Filters struct { type Filters struct {
IsOnline *bool IsOnline *bool
Page int
Size int
Q string
Status string
Sort Sort
}
type Counts struct {
All int `json:"all"`
Online int `json:"online"`
Draft int `json:"draft"`
}
type PageResult struct {
Items []Article
Total int
} }
type Repository interface { type Repository interface {
FindByFilters(filters Filters) ([]Article, error) FindByFilters(filters Filters) ([]Article, error)
FindPage(filters Filters) (PageResult, error)
CountByVisibility(onlineOnly bool) (Counts, error)
Get(id uint32) (Article, error) Get(id uint32) (Article, error)
GetByName(name string) (Article, error) GetByName(name string) (Article, error)
GetAll() []Article GetAll() []Article
@@ -5,6 +5,12 @@ import (
"RenewCMS/internal/infrastructure/persistence/mappers" "RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models" entity "RenewCMS/internal/infrastructure/persistence/models"
"strings"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -26,12 +32,128 @@ func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Arti
var entityArticles []entity.Article var entityArticles []entity.Article
err := query.Find(&entityArticles).Error err := query.Find(&entityArticles).Error
return toDomainArticles(entityArticles), err
}
func (a *ArticleRepository) scoped(filters domain.Filters) *gorm.DB {
query := a.db.Model(&entity.Article{})
if filters.IsOnline != nil {
query = query.Where("is_online = ?", *filters.IsOnline)
}
switch filters.Status {
case domain.StatusOnline:
query = query.Where("is_online = ?", true)
case domain.StatusDraft:
query = query.Where("is_online = ?", false)
}
return query
}
var sortColumns = map[domain.SortField]string{
domain.SortByTitle: "LOWER(title)",
domain.SortByStatus: "is_online",
domain.SortByCreatedAt: "created_at",
domain.SortByUpdatedAt: "updated_at",
}
var sortDirections = map[domain.SortDirection]string{
domain.SortAsc: "ASC",
domain.SortDesc: "DESC",
}
func orderClause(sort domain.Sort) string {
column, ok := sortColumns[sort.Field]
direction, directionOk := sortDirections[sort.Direction]
if !ok || !directionOk {
column, direction = sortColumns[domain.SortByUpdatedAt], sortDirections[domain.SortDesc]
}
return column + " " + direction + ", id DESC"
}
func (a *ArticleRepository) FindPage(filters domain.Filters) (domain.PageResult, error) {
filters.Page = max(filters.Page, 1)
filters.Size = max(filters.Size, 0)
filters.Q = strings.TrimSpace(filters.Q)
if filters.Q != "" {
return a.findMatchingTitle(filters)
}
var total int64
if err := a.scoped(filters).Count(&total).Error; err != nil {
return domain.PageResult{}, err
}
var entityArticles []entity.Article
err := a.scoped(filters).Order(orderClause(filters.Sort)).Limit(filters.Size).Offset((filters.Page - 1) * filters.Size).Find(&entityArticles).Error
if err != nil {
return domain.PageResult{}, err
}
return domain.PageResult{Items: toDomainArticles(entityArticles), Total: int(total)}, nil
}
func (a *ArticleRepository) findMatchingTitle(filters domain.Filters) (domain.PageResult, error) {
var candidates []entity.Article
if err := a.scoped(filters).Select("id", "title").Order(orderClause(filters.Sort)).Find(&candidates).Error; err != nil {
return domain.PageResult{}, err
}
needle := foldForSearch(filters.Q)
matchingIds := make([]uint32, 0)
for _, candidate := range candidates {
if strings.Contains(foldForSearch(candidate.Title), needle) {
matchingIds = append(matchingIds, candidate.ID)
}
}
start := min((filters.Page-1)*filters.Size, len(matchingIds))
end := min(start+filters.Size, len(matchingIds))
pageIds := matchingIds[start:end]
page := domain.PageResult{Items: []domain.Article{}, Total: len(matchingIds)}
if len(pageIds) == 0 {
return page, nil
}
var entityArticles []entity.Article
if err := a.db.Model(&entity.Article{}).Where("id IN ?", pageIds).Find(&entityArticles).Error; err != nil {
return domain.PageResult{}, err
}
byId := make(map[uint32]entity.Article, len(entityArticles))
for _, entityArticle := range entityArticles {
byId[entityArticle.ID] = entityArticle
}
ordered := make([]entity.Article, 0, len(pageIds))
for _, id := range pageIds {
if entityArticle, found := byId[id]; found {
ordered = append(ordered, entityArticle)
}
}
page.Items = toDomainArticles(ordered)
return page, nil
}
func (a *ArticleRepository) CountByVisibility(onlineOnly bool) (domain.Counts, error) {
var online, draft int64
if err := a.db.Model(&entity.Article{}).Where("is_online = ?", true).Count(&online).Error; err != nil {
return domain.Counts{}, err
}
if !onlineOnly {
if err := a.db.Model(&entity.Article{}).Where("is_online = ?", false).Count(&draft).Error; err != nil {
return domain.Counts{}, err
}
}
return domain.Counts{All: int(online + draft), Online: int(online), Draft: int(draft)}, nil
}
func toDomainArticles(entityArticles []entity.Article) []domain.Article {
domainArticles := make([]domain.Article, 0, len(entityArticles)) domainArticles := make([]domain.Article, 0, len(entityArticles))
for _, entityArticle := range entityArticles { for _, entityArticle := range entityArticles {
domainArticles = append(domainArticles, mappers.ArticleToDomain(entityArticle)) domainArticles = append(domainArticles, mappers.ArticleToDomain(entityArticle))
} }
return domainArticles
}
return domainArticles, err func foldForSearch(value string) string {
folder := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
folded, _, err := transform.String(folder, value)
if err != nil {
return strings.ToLower(value)
}
return strings.ToLower(folded)
} }
func (a *ArticleRepository) Get(id uint32) (domain.Article, error) { func (a *ArticleRepository) Get(id uint32) (domain.Article, error) {
@@ -131,11 +253,7 @@ func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Arti
return nil, err return nil, err
} }
domainArticles := make([]domain.Article, 0, len(entityArticles)) return toDomainArticles(entityArticles), nil
for _, entityArticle := range entityArticles {
domainArticles = append(domainArticles, mappers.ArticleToDomain(entityArticle))
}
return domainArticles, nil
} }
var _ domain.Repository = &ArticleRepository{} var _ domain.Repository = &ArticleRepository{}
@@ -11,21 +11,51 @@ func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer a
return &ListArticlesUseCase{articleRepository, bodyRenderer} return &ListArticlesUseCase{articleRepository, bodyRenderer}
} }
func (u *ListArticlesUseCase) ListOnlineArticles(format article.BodyFormat) ([]article.Article, error) { type ListArticlesCommand struct {
online := true Format article.BodyFormat
articles, err := u.articleRepository.FindByFilters(article.Filters{ Verified bool
IsOnline: &online, Page int
}) Size int
if err != nil { Q string
return nil, err Status string
} Sort article.Sort
return renderArticles(u.bodyRenderer, articles, format)
} }
func (u *ListArticlesUseCase) ListAllArticles(format article.BodyFormat) ([]article.Article, error) { type ArticlePage struct {
articles, err := u.articleRepository.FindByFilters(article.Filters{}) Items []article.Article
Total int
Page int
Size int
Counts article.Counts
}
func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (ArticlePage, error) {
counts, err := u.articleRepository.CountByVisibility(!command.Verified)
if err != nil { if err != nil {
return nil, err return ArticlePage{}, err
} }
return renderArticles(u.bodyRenderer, articles, format) page := ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts}
if !command.Verified && command.Status == article.StatusDraft {
return page, nil
}
found, err := u.articleRepository.FindPage(pageFilters(command))
if err != nil {
return ArticlePage{}, err
}
rendered, err := renderArticles(u.bodyRenderer, found.Items, command.Format)
if err != nil {
return ArticlePage{}, err
}
page.Items = rendered
page.Total = found.Total
return page, nil
}
func pageFilters(command ListArticlesCommand) article.Filters {
filters := article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, Sort: command.Sort}
if !command.Verified {
online := true
filters.IsOnline = &online
}
return filters
} }
+8 -2
View File
@@ -514,7 +514,13 @@ func TestAPIKeysHttp(t *testing.T) {
raw, _ := env.issueKey(apiKeyDevId, "ci") raw, _ := env.issueKey(apiKeyDevId, "ci")
ids := func(rec *httptest.ResponseRecorder) []float64 { ids := func(rec *httptest.ResponseRecorder) []float64 {
result := []float64{} result := []float64{}
for _, entry := range apiKeyDecodeList(t, rec) { var envelope struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
t.Fatalf("expected an article envelope, got %q: %v", rec.Body.String(), err)
}
for _, entry := range envelope.Items {
result = append(result, entry["id"].(float64)) result = append(result, entry["id"].(float64))
} }
return result return result
@@ -522,7 +528,7 @@ func TestAPIKeysHttp(t *testing.T) {
withKey := env.withKey(t, http.MethodGet, "/v1/article", "", raw) withKey := env.withKey(t, http.MethodGet, "/v1/article", "", raw)
assertAPIKeyStatus(t, withKey, http.StatusOK) assertAPIKeyStatus(t, withKey, http.StatusOK)
if got := ids(withKey); len(got) != 2 || got[0] != 5 || got[1] != 6 { if got := ids(withKey); len(got) != 2 || got[0] != 6 || got[1] != 5 {
t.Errorf("expected articles 5 and 6, got %v", got) t.Errorf("expected articles 5 and 6, got %v", got)
} }
anonymous := env.anonymous(t, http.MethodGet, "/v1/article", "") anonymous := env.anonymous(t, http.MethodGet, "/v1/article", "")
+2 -2
View File
@@ -188,7 +188,7 @@ func TestArticleBodyFormatHttp(t *testing.T) {
for _, expectation := range expectations { for _, expectation := range expectations {
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/"+expectation.query, "", nil) rec := serveBodyFormat(router, http.MethodGet, "/v1/article/"+expectation.query, "", nil)
var items []bodyFormatResponse var items []bodyFormatResponse
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { if err := json.Unmarshal(articleItemsJSON(rec.Body.Bytes()), &items); err != nil {
t.Fatalf("failed to decode list: %v", err) t.Fatalf("failed to decode list: %v", err)
} }
if len(items) != 2 { if len(items) != 2 {
@@ -318,7 +318,7 @@ func TestArticleBodyFormatHttp(t *testing.T) {
router, _ := realRouter() router, _ := realRouter()
rec := serveBodyFormat(router, http.MethodGet, "/v1/article/?format=json", "", verifiedCookie) rec := serveBodyFormat(router, http.MethodGet, "/v1/article/?format=json", "", verifiedCookie)
var items []bodyFormatResponse var items []bodyFormatResponse
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { if err := json.Unmarshal(articleItemsJSON(rec.Body.Bytes()), &items); err != nil {
t.Fatalf("failed to decode list: %v", err) t.Fatalf("failed to decode list: %v", err)
} }
seen := map[uint32]bool{} seen := map[uint32]bool{}
+2 -2
View File
@@ -234,7 +234,7 @@ func TestArticlePublishingHttp(t *testing.T) {
t.Fatalf("expected status 200, got %d", listRec.Code) t.Fatalf("expected status 200, got %d", listRec.Code)
} }
var articles []domainArticle.Article var articles []domainArticle.Article
if err := json.Unmarshal(listRec.Body.Bytes(), &articles); err != nil { if err := json.Unmarshal(articleItemsJSON(listRec.Body.Bytes()), &articles); err != nil {
t.Fatalf("failed to decode response: %v", err) t.Fatalf("failed to decode response: %v", err)
} }
found := false found := false
@@ -304,7 +304,7 @@ func TestArticlePublishingHttp(t *testing.T) {
t.Fatalf("expected status 200, got %d", listRec.Code) t.Fatalf("expected status 200, got %d", listRec.Code)
} }
var articles []domainArticle.Article var articles []domainArticle.Article
if err := json.Unmarshal(listRec.Body.Bytes(), &articles); err != nil { if err := json.Unmarshal(articleItemsJSON(listRec.Body.Bytes()), &articles); err != nil {
t.Fatalf("failed to decode response: %v", err) t.Fatalf("failed to decode response: %v", err)
} }
foundStillOnline := false foundStillOnline := false
+84
View File
@@ -7,6 +7,11 @@ import (
"sort" "sort"
"strings" "strings"
"testing" "testing"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
) )
type mockArticleRepo struct { type mockArticleRepo struct {
@@ -47,6 +52,85 @@ func (m *mockArticleRepo) FindByFilters(filters article.Filters) ([]article.Arti
return result, nil return result, nil
} }
func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult, error) {
if err := m.fail.trigger("FindPage"); err != nil {
return article.PageResult{}, err
}
matching := make([]article.Article, 0)
for _, a := range m.articles {
if filters.IsOnline != nil && a.IsOnline != *filters.IsOnline {
continue
}
if filters.Status == article.StatusOnline && !a.IsOnline {
continue
}
if filters.Status == article.StatusDraft && a.IsOnline {
continue
}
if filters.Q != "" && !strings.Contains(foldArticleSearch(a.Title), foldArticleSearch(filters.Q)) {
continue
}
matching = append(matching, a)
}
sort.Slice(matching, func(i, j int) bool { return articleSortsBefore(matching[i], matching[j], filters.Sort) })
start := min((filters.Page-1)*filters.Size, len(matching))
end := min(start+filters.Size, len(matching))
return article.PageResult{Items: matching[start:end], Total: len(matching)}, nil
}
func (m *mockArticleRepo) CountByVisibility(onlineOnly bool) (article.Counts, error) {
if err := m.fail.trigger("CountByVisibility"); err != nil {
return article.Counts{}, err
}
counts := article.Counts{}
for _, a := range m.articles {
if a.IsOnline {
counts.Online++
} else if !onlineOnly {
counts.Draft++
}
}
counts.All = counts.Online + counts.Draft
return counts, nil
}
func articleSortsBefore(left, right article.Article, order article.Sort) bool {
if order.Field == "" {
order = article.DefaultSort()
}
var comparison int
switch order.Field {
case article.SortByTitle:
comparison = strings.Compare(strings.ToLower(left.Title), strings.ToLower(right.Title))
case article.SortByStatus:
if left.IsOnline != right.IsOnline {
comparison = 1
if !left.IsOnline {
comparison = -1
}
}
case article.SortByCreatedAt:
comparison = left.CreatedAt.Compare(right.CreatedAt)
default:
comparison = left.UpdatedAt.Compare(right.UpdatedAt)
}
if order.Direction == article.SortDesc {
comparison = -comparison
}
if comparison != 0 {
return comparison < 0
}
return left.ID > right.ID
}
func foldArticleSearch(value string) string {
folded, _, err := transform.String(transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC), value)
if err != nil {
return strings.ToLower(value)
}
return strings.ToLower(folded)
}
func (m *mockArticleRepo) Get(id uint32) (article.Article, error) { func (m *mockArticleRepo) Get(id uint32) (article.Article, error) {
if err := m.fail.trigger("Get"); err != nil { if err := m.fail.trigger("Get"); err != nil {
return article.Article{}, err return article.Article{}, err
+1 -1
View File
@@ -57,7 +57,7 @@ func TestArticlesListHttp(t *testing.T) {
} }
var got []map[string]any var got []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { if err := json.Unmarshal(articleItemsJSON(rec.Body.Bytes()), &got); err != nil {
t.Fatalf("failed to decode response: %v", err) t.Fatalf("failed to decode response: %v", err)
} }
if len(got) != 1 { if len(got) != 1 {
+613
View File
@@ -0,0 +1,613 @@
package test
import (
domainArticle "RenewCMS/internal/domain/article"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"github.com/go-chi/jwtauth/v5"
validator "RenewCMS/api/pkg/validator"
)
const paginationBodyMismatch = "The request cannot be processed due to a mismatch in the format of the body."
type paginationEnvelope struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
Counts map[string]int `json:"counts"`
}
func articleItemsJSON(body []byte) []byte {
var envelope struct {
Items json.RawMessage `json:"items"`
}
if err := json.Unmarshal(body, &envelope); err != nil || envelope.Items == nil {
return body
}
return envelope.Items
}
func seedPaginationArticles(repo *mockArticleRepo, online int, draft int) {
base := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
add := func(isOnline bool, title string) {
id := uint32(len(repo.articles) + 1)
repo.articles[id] = domainArticle.Article{ID: id, Title: title, IsOnline: isOnline, UpdatedAt: base.Add(time.Duration(id) * time.Hour)}
}
for i := 0; i < online; i++ {
add(true, fmt.Sprintf("Online article %d", i))
}
for i := 0; i < draft; i++ {
add(false, fmt.Sprintf("Draft article %d", i))
}
}
func seedSortArticles(repo *mockArticleRepo) {
day := func(month, d int) time.Time { return time.Date(2026, time.Month(month), d, 0, 0, 0, 0, time.UTC) }
repo.articles[1] = domainArticle.Article{ID: 1, Title: "Banana", IsOnline: true, CreatedAt: day(1, 1), UpdatedAt: day(9, 3)}
repo.articles[2] = domainArticle.Article{ID: 2, Title: "apple", IsOnline: false, CreatedAt: day(1, 3), UpdatedAt: day(9, 1)}
repo.articles[3] = domainArticle.Article{ID: 3, Title: "Cherry", IsOnline: true, CreatedAt: day(1, 2), UpdatedAt: day(9, 2)}
}
func itemIds(envelope paginationEnvelope) []int {
ids := []int{}
for _, item := range envelope.Items {
ids = append(ids, int(item["id"].(float64)))
}
return ids
}
func TestArticlesPaginationApiHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
get := func(t *testing.T, repo *mockArticleRepo, query string, verified bool) (*httptest.ResponseRecorder, paginationEnvelope) {
t.Helper()
router := newArticlesListRouter(repo, token)
req := httptest.NewRequest(http.MethodGet, "/v1/article/"+query, nil)
if verified {
setJwtCookie(t, req, token, 1)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var envelope paginationEnvelope
if rec.Code == http.StatusOK {
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
t.Fatalf("failed to decode envelope: %v", err)
}
if envelope.Items == nil {
t.Fatalf("items must be [] rather than null: %s", rec.Body.String())
}
}
return rec, envelope
}
assertCounts := func(t *testing.T, envelope paginationEnvelope, all, online, draft int) {
t.Helper()
if envelope.Counts["all"] != all || envelope.Counts["online"] != online || envelope.Counts["draft"] != draft {
t.Fatalf("unexpected counts %v", envelope.Counts)
}
}
t.Run("A bare request answers the envelope with the defaults", func(t *testing.T) {
repo := newMockArticleRepo()
seedPaginationArticles(repo, 30, 0)
rec, envelope := get(t, repo, "", false)
if rec.Code != http.StatusOK || envelope.Page != 1 || envelope.Size != 25 || envelope.Total != 30 || len(envelope.Items) != 25 {
t.Fatalf("unexpected response %d %+v", rec.Code, envelope)
}
assertCounts(t, envelope, 30, 30, 0)
})
t.Run("Defaults when only one parameter is given", func(t *testing.T) {
cases := []struct {
query string
page, size, itemCount int
}{
{"?page=2", 2, 25, 25},
{"?page=3", 3, 25, 10},
{"?size=5", 1, 5, 5},
{"?size=50", 1, 50, 50},
}
for _, c := range cases {
repo := newMockArticleRepo()
seedPaginationArticles(repo, 60, 0)
_, envelope := get(t, repo, c.query, false)
if envelope.Page != c.page || envelope.Size != c.size || envelope.Total != 60 || len(envelope.Items) != c.itemCount {
t.Fatalf("%s: unexpected %+v", c.query, envelope)
}
}
})
t.Run("Items are ordered by last update, newest first, then id descending", func(t *testing.T) {
repo := newMockArticleRepo()
sept1 := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
sept3 := time.Date(2026, 9, 3, 0, 0, 0, 0, time.UTC)
repo.articles[1] = domainArticle.Article{ID: 1, Title: "First article", IsOnline: true, UpdatedAt: sept1}
repo.articles[2] = domainArticle.Article{ID: 2, Title: "Second article", IsOnline: true, UpdatedAt: sept3}
repo.articles[3] = domainArticle.Article{ID: 3, Title: "Third article", IsOnline: true, UpdatedAt: sept3}
_, envelope := get(t, repo, "?size=5", false)
ids := itemIds(envelope)
if len(ids) != 3 || ids[0] != 3 || ids[1] != 2 || ids[2] != 1 {
t.Fatalf("unexpected order %v", ids)
}
})
t.Run("Pages partition the list with no overlap", func(t *testing.T) {
repo := newMockArticleRepo()
seedPaginationArticles(repo, 12, 0)
seen := map[int]bool{}
for page, want := range map[int]int{1: 5, 2: 5, 3: 2} {
_, envelope := get(t, repo, fmt.Sprintf("?page=%d&size=5", page), false)
if len(envelope.Items) != want || envelope.Total != 12 {
t.Fatalf("page %d unexpected %+v", page, envelope)
}
for _, id := range itemIds(envelope) {
if seen[id] {
t.Fatalf("id %d repeated", id)
}
seen[id] = true
}
}
})
t.Run("A public caller's total and counts cover online articles only", func(t *testing.T) {
repo := newMockArticleRepo()
seedPaginationArticles(repo, 5, 3)
_, envelope := get(t, repo, "?size=5", false)
if envelope.Total != 5 {
t.Fatalf("unexpected total %d", envelope.Total)
}
assertCounts(t, envelope, 5, 5, 0)
for _, item := range envelope.Items {
if item["is_online"] == false {
t.Fatalf("offline item leaked")
}
}
})
t.Run("A verified caller sees offline articles in the page, the total and the counts", func(t *testing.T) {
repo := newMockArticleRepo()
seedPaginationArticles(repo, 5, 3)
_, envelope := get(t, repo, "?size=5", true)
if envelope.Total != 8 {
t.Fatalf("unexpected total %d", envelope.Total)
}
assertCounts(t, envelope, 8, 5, 3)
})
t.Run("An unverified or invalid session is treated as public", func(t *testing.T) {
repo := newMockArticleRepo()
seedPaginationArticles(repo, 5, 3)
for _, cookie := range []string{"not-a-jwt", ""} {
router := newArticlesListRouter(repo, token)
req := httptest.NewRequest(http.MethodGet, "/v1/article/?size=5", nil)
if cookie != "" {
req.AddCookie(&http.Cookie{Name: "jwt", Value: cookie})
} else {
setJwtCookie(t, req, token, 99)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var envelope paginationEnvelope
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil || envelope.Total != 5 {
t.Fatalf("expected public total 5, got %s", rec.Body.String())
}
}
})
t.Run("A page beyond the last is empty with the real total", func(t *testing.T) {
repo := newMockArticleRepo()
seedPaginationArticles(repo, 12, 0)
rec, envelope := get(t, repo, "?page=99&size=5", false)
if rec.Code != http.StatusOK || len(envelope.Items) != 0 || envelope.Total != 12 || envelope.Page != 99 || envelope.Size != 5 || envelope.Counts["all"] != 12 {
t.Fatalf("unexpected %d %+v", rec.Code, envelope)
}
})
t.Run("No articles at all", func(t *testing.T) {
rec, envelope := get(t, newMockArticleRepo(), "", false)
if rec.Code != http.StatusOK || len(envelope.Items) != 0 || envelope.Total != 0 {
t.Fatalf("unexpected %d %+v", rec.Code, envelope)
}
assertCounts(t, envelope, 0, 0, 0)
})
t.Run("Status filter", func(t *testing.T) {
for status, total := range map[string]int{"online": 4, "draft": 2} {
repo := newMockArticleRepo()
seedPaginationArticles(repo, 4, 2)
_, envelope := get(t, repo, "?status="+status, true)
if envelope.Total != total || len(envelope.Items) != total {
t.Fatalf("%s: unexpected %+v", status, envelope)
}
assertCounts(t, envelope, 6, 4, 2)
for _, item := range envelope.Items {
if item["is_online"] != (status == "online") {
t.Fatalf("%s: item does not match", status)
}
}
}
})
t.Run("A public caller asking for drafts gets nothing", func(t *testing.T) {
repo := newMockArticleRepo()
seedPaginationArticles(repo, 4, 2)
_, envelope := get(t, repo, "?status=draft", false)
if len(envelope.Items) != 0 || envelope.Total != 0 {
t.Fatalf("unexpected %+v", envelope)
}
assertCounts(t, envelope, 4, 4, 0)
})
t.Run("Search narrows the page and the total but not the counts", func(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = domainArticle.Article{ID: 1, Title: "Choosing lime plaster for old stone walls", IsOnline: true}
repo.articles[2] = domainArticle.Article{ID: 2, Title: "How we price a bathroom renovation", IsOnline: true}
_, envelope := get(t, repo, "?q="+url.QueryEscape("PLÂSTER"), false)
ids := itemIds(envelope)
if envelope.Total != 1 || len(ids) != 1 || ids[0] != 1 {
t.Fatalf("unexpected %+v", envelope)
}
assertCounts(t, envelope, 2, 2, 0)
})
t.Run("Search and status combine with paging", func(t *testing.T) {
repo := newMockArticleRepo()
for i := 0; i < 7; i++ {
id := uint32(i + 1)
repo.articles[id] = domainArticle.Article{ID: id, Title: fmt.Sprintf("Tile guide %d", i), IsOnline: false}
}
for i := 0; i < 3; i++ {
id := uint32(i + 8)
repo.articles[id] = domainArticle.Article{ID: id, Title: fmt.Sprintf("Tile story %d", i), IsOnline: true}
}
_, envelope := get(t, repo, "?q=tile&status=draft&size=5&page=2", true)
if envelope.Total != 7 || len(envelope.Items) != 2 || envelope.Counts["draft"] != 7 {
t.Fatalf("unexpected %+v", envelope)
}
})
t.Run("Search that matches nothing", func(t *testing.T) {
repo := newMockArticleRepo()
seedPaginationArticles(repo, 3, 0)
_, envelope := get(t, repo, "?q=zzzz", false)
if len(envelope.Items) != 0 || envelope.Total != 0 {
t.Fatalf("unexpected %+v", envelope)
}
assertCounts(t, envelope, 3, 3, 0)
})
t.Run("Invalid parameters answer 400 like Activity", func(t *testing.T) {
queries := []string{
"page=0", "page=-1", "page=abc", "page=", "page=1.5", "size=0", "size=10", "size=200", "size=abc",
"status=archived", "status=", "q=" + strings.Repeat("a", 201), "page=0&size=5", "page=1&size=1000",
}
for _, query := range queries {
repo := newMockArticleRepo()
seedPaginationArticles(repo, 3, 0)
repo.fail.failNext("FindPage")
rec, _ := get(t, repo, "?"+query, false)
if rec.Code != http.StatusBadRequest || strings.TrimSpace(rec.Body.String()) != paginationBodyMismatch {
t.Fatalf("%q: unexpected %d %q", query, rec.Code, rec.Body.String())
}
if err := repo.fail.trigger("FindPage"); err == nil {
t.Fatalf("%q: the repository was called", query)
}
}
})
t.Run("Invalid format is reported first", func(t *testing.T) {
rec, _ := get(t, newMockArticleRepo(), "?page=0&format=pdf", false)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "The format must be one of: markdown, html, json.") {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
})
t.Run("format and paging combine", func(t *testing.T) {
repo := newMockArticleRepo()
seedPaginationArticles(repo, 3, 0)
_, envelope := get(t, repo, "?size=5&format=html", false)
if envelope.Total != 3 || len(envelope.Items) != 3 {
t.Fatalf("unexpected %+v", envelope)
}
for _, item := range envelope.Items {
if item["body_format"] != "html" {
t.Fatalf("unexpected body_format %v", item["body_format"])
}
}
})
t.Run("Repository failure answers 500", func(t *testing.T) {
repo := newMockArticleRepo()
repo.fail.failNext("FindPage")
rec, _ := get(t, repo, "", false)
if rec.Code != http.StatusInternalServerError || strings.TrimSpace(rec.Body.String()) != "Failed to retrieve articles" {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
})
t.Run("Sort orders the whole list, not just the page", func(t *testing.T) {
cases := map[string][]int{
"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},
}
for sortValue, want := range cases {
repo := newMockArticleRepo()
seedSortArticles(repo)
_, envelope := get(t, repo, "?sort="+url.QueryEscape(sortValue), true)
if got := itemIds(envelope); fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("%s: got %v want %v", sortValue, got, want)
}
}
})
t.Run("No sort parameter keeps the default order", func(t *testing.T) {
repo := newMockArticleRepo()
seedSortArticles(repo)
_, envelope := get(t, repo, "", true)
if got := itemIds(envelope); fmt.Sprint(got) != "[1 3 2]" {
t.Fatalf("unexpected order %v", got)
}
})
t.Run("Equal sort keys fall back to id descending", func(t *testing.T) {
repo := newMockArticleRepo()
for id := uint32(1); id <= 3; id++ {
repo.articles[id] = domainArticle.Article{ID: id, Title: "Same", IsOnline: true}
}
_, envelope := get(t, repo, "?sort=title:asc", false)
if got := itemIds(envelope); fmt.Sprint(got) != "[3 2 1]" {
t.Fatalf("unexpected order %v", got)
}
})
t.Run("Sort is applied before paging", func(t *testing.T) {
repo := newMockArticleRepo()
for id := uint32(1); id <= 12; id++ {
repo.articles[id] = domainArticle.Article{ID: id, Title: fmt.Sprintf("Title %02d", id), IsOnline: true}
}
want := map[int]string{1: "[12 11 10 9 8]", 2: "[7 6 5 4 3]", 3: "[2 1]"}
for page, ids := range want {
_, envelope := get(t, repo, fmt.Sprintf("?sort=title:desc&size=5&page=%d", page), false)
if got := fmt.Sprint(itemIds(envelope)); got != ids {
t.Fatalf("page %d: got %s want %s", page, got, ids)
}
}
})
t.Run("Sort combines with search and status", func(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = domainArticle.Article{ID: 1, Title: "Tile A", IsOnline: false}
repo.articles[2] = domainArticle.Article{ID: 2, Title: "Tile C", IsOnline: false}
repo.articles[3] = domainArticle.Article{ID: 3, Title: "Tile B", IsOnline: false}
repo.articles[4] = domainArticle.Article{ID: 4, Title: "Tile D", IsOnline: true}
_, envelope := get(t, repo, "?q=tile&status=draft&sort=title:asc", true)
if got := fmt.Sprint(itemIds(envelope)); got != "[1 3 2]" || envelope.Total != 3 {
t.Fatalf("unexpected %s %+v", got, envelope)
}
assertCounts(t, envelope, 4, 1, 3)
})
t.Run("Invalid sort answers 400", func(t *testing.T) {
for _, value := range []string{"title", "title:up", "body:asc", "", "updated_at", "title:asc;DROP TABLE", "TITLE:ASC"} {
repo := newMockArticleRepo()
repo.fail.failNext("FindPage")
rec, _ := get(t, repo, "?sort="+url.QueryEscape(value), false)
if rec.Code != http.StatusBadRequest || strings.TrimSpace(rec.Body.String()) != paginationBodyMismatch {
t.Fatalf("%q: unexpected %d %q", value, rec.Code, rec.Body.String())
}
}
})
t.Run("The SQLite repository pages, counts, searches and sorts", func(t *testing.T) {
db := openActivityDatabase(t)
repo := persistence.NewArticleRepository(db)
titles := []struct {
title string
online bool
}{{"Banana", true}, {"apple", false}, {"Cherry", true}, {"Plâtre fin", true}}
for _, entry := range titles {
if _, err := repo.Create(domainArticle.Article{Title: entry.title, IsOnline: entry.online}); err != nil {
t.Fatalf("seed failed: %v", err)
}
}
if _, err := repo.UpdateIsOnline(2, false); err != nil {
t.Fatalf("seed failed: %v", err)
}
idsOf := func(page domainArticle.PageResult) string {
ids := []uint32{}
for _, item := range page.Items {
ids = append(ids, item.ID)
}
return fmt.Sprint(ids)
}
sorted, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}})
if err != nil || idsOf(sorted) != "[2 1 3 4]" || sorted.Total != 4 {
t.Fatalf("title asc: %v %v %+v", err, idsOf(sorted), sorted)
}
byStatus, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Sort: domainArticle.Sort{Field: domainArticle.SortByStatus, Direction: domainArticle.SortAsc}})
if err != nil || idsOf(byStatus) != "[2 4 3 1]" {
t.Fatalf("status asc: %v %v", err, idsOf(byStatus))
}
paged, err := repo.FindPage(domainArticle.Filters{Page: 2, Size: 2, Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortDesc}})
if err != nil || idsOf(paged) != "[1 2]" || paged.Total != 4 {
t.Fatalf("paged: %v %v %+v", err, idsOf(paged), paged)
}
searched, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Q: "PLATRE", Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}})
if err != nil || idsOf(searched) != "[4]" || searched.Total != 1 {
t.Fatalf("search: %v %v %+v", err, idsOf(searched), searched)
}
multi, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Q: "a", Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortDesc}})
if err != nil || idsOf(multi) != "[4 1 2]" {
t.Fatalf("search sort: %v %v", err, idsOf(multi))
}
online := true
publicDraft, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, IsOnline: &online, Status: domainArticle.StatusDraft})
if err != nil || publicDraft.Total != 0 {
t.Fatalf("draft: %v %+v", err, publicDraft)
}
counts, err := repo.CountByVisibility(false)
if err != nil || counts != (domainArticle.Counts{All: 4, Online: 3, Draft: 1}) {
t.Fatalf("counts: %v %+v", err, counts)
}
publicCounts, err := repo.CountByVisibility(true)
if err != nil || publicCounts != (domainArticle.Counts{All: 3, Online: 3, Draft: 0}) {
t.Fatalf("public counts: %v %+v", err, publicCounts)
}
})
t.Run("The SQLite repository pages search results and filters by visibility", func(t *testing.T) {
db := openActivityDatabase(t)
repo := persistence.NewArticleRepository(db)
for _, entry := range []struct {
title string
online bool
}{{"Alpha", true}, {"Bravo", false}, {"Charlie", true}, {"Delta", false}} {
if _, err := repo.Create(domainArticle.Article{Title: entry.title, IsOnline: entry.online}); err != nil {
t.Fatalf("seed failed: %v", err)
}
}
if _, err := repo.UpdateIsOnline(2, false); err != nil {
t.Fatalf("seed failed: %v", err)
}
titleAsc := domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}
second, err := repo.FindPage(domainArticle.Filters{Page: 2, Size: 2, Q: "a", Sort: titleAsc})
if err != nil || second.Total != 4 || len(second.Items) != 2 || second.Items[0].Title != "Charlie" || second.Items[1].Title != "Delta" {
t.Fatalf("search page 2: %v %+v", err, second)
}
beyond, err := repo.FindPage(domainArticle.Filters{Page: 9, Size: 2, Q: "a", Sort: titleAsc})
if err != nil || beyond.Total != 4 || len(beyond.Items) != 0 {
t.Fatalf("search beyond: %v %+v", err, beyond)
}
online := true
onlyOnline, err := repo.FindByFilters(domainArticle.Filters{IsOnline: &online})
if err != nil || len(onlyOnline) != 2 {
t.Fatalf("online filter: %v %d", err, len(onlyOnline))
}
everything, err := repo.FindByFilters(domainArticle.Filters{})
if err != nil || len(everything) != 4 {
t.Fatalf("no filter: %v %d", err, len(everything))
}
drafts, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Status: domainArticle.StatusDraft, Sort: titleAsc})
if err != nil || drafts.Total != 2 || len(drafts.Items) != 2 {
t.Fatalf("drafts: %v %+v", err, drafts)
}
onlineStatus, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Status: domainArticle.StatusOnline, Sort: titleAsc})
if err != nil || onlineStatus.Total != 2 {
t.Fatalf("online status: %v %+v", err, onlineStatus)
}
})
t.Run("The SQLite repository surfaces database failures", func(t *testing.T) {
db := openActivityDatabase(t)
repo := persistence.NewArticleRepository(db)
sqlDb, err := db.DB()
if err != nil {
t.Fatalf("sql handle: %v", err)
}
if err := sqlDb.Close(); err != nil {
t.Fatalf("close: %v", err)
}
sort := domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}
if _, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Sort: sort}); err == nil {
t.Fatal("plain page should fail")
}
if _, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Q: "a", Sort: sort}); err == nil {
t.Fatal("search page should fail")
}
if _, err := repo.CountByVisibility(false); err == nil {
t.Fatal("counts should fail")
}
if _, err := repo.CountByVisibility(true); err == nil {
t.Fatal("public counts should fail")
}
if _, err := repo.FindByFilters(domainArticle.Filters{}); err == nil {
t.Fatal("find by filters should fail")
}
})
t.Run("The SQLite repository clamps invalid paging, trims the search and skips vanished rows", func(t *testing.T) {
db := openActivityDatabase(t)
repo := persistence.NewArticleRepository(db)
for _, title := range []string{"Alpha", "Beta", "Gamma"} {
if _, err := repo.Create(domainArticle.Article{Title: title, IsOnline: true}); err != nil {
t.Fatalf("seed failed: %v", err)
}
}
titleAsc := domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}
for _, page := range []int{0, -3} {
searched, err := repo.FindPage(domainArticle.Filters{Page: page, Size: 2, Q: "a", Sort: titleAsc})
if err != nil || searched.Total != 3 || len(searched.Items) != 2 || searched.Items[0].Title != "Alpha" {
t.Fatalf("search page %d: %v %+v", page, err, searched)
}
plain, err := repo.FindPage(domainArticle.Filters{Page: page, Size: 2, Sort: titleAsc})
if err != nil || plain.Total != 3 || len(plain.Items) != 2 || plain.Items[0].Title != "Alpha" {
t.Fatalf("plain page %d: %v %+v", page, err, plain)
}
}
trimmed, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Q: " mm ", Sort: titleAsc})
if err != nil || trimmed.Total != 1 || trimmed.Items[0].Title != "Gamma" {
t.Fatalf("trimmed search: %v %+v", err, trimmed)
}
spaced, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Q: " a", Sort: titleAsc})
if err != nil || spaced.Total != 3 {
t.Fatalf("leading space search: %v %+v", err, spaced)
}
})
}
func TestArticlesPaginationUseCase(t *testing.T) {
t.Run("The typed sort reaches the repository and an absent sort keeps updated_at descending", func(t *testing.T) {
repo := newMockArticleRepo()
seedSortArticles(repo)
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
sorted, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Verified: true, Page: 1, Size: 25, Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}})
if err != nil || sorted.Total != 3 || sorted.Counts.All != 3 {
t.Fatalf("unexpected %v %+v", err, sorted)
}
if sorted.Items[0].ID != 2 || sorted.Items[2].ID != 3 {
t.Fatalf("title ascending expected apple first, Cherry last: %v", sorted.Items)
}
})
t.Run("A repository error is propagated from both queries", func(t *testing.T) {
sentinel := errors.New("boom")
for _, method := range []string{"FindPage", "CountByVisibility"} {
repo := newMockArticleRepo()
seedPaginationArticles(repo, 3, 0)
repo.fail.failNextWith(method, sentinel)
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
_, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Verified: true, Page: 1, Size: 25})
if !errors.Is(err, sentinel) {
t.Fatalf("%s: expected sentinel, got %v", method, err)
}
}
})
t.Run("The unpaged Filters call still returns every article", func(t *testing.T) {
repo := newMockArticleRepo()
seedPaginationArticles(repo, 30, 5)
all, err := repo.FindByFilters(domainArticle.Filters{})
if err != nil || len(all) != 35 {
t.Fatalf("unexpected %d %v", len(all), err)
}
})
}
+4 -4
View File
@@ -90,7 +90,7 @@ func TestPublicArticleListingHttp(t *testing.T) {
t.Fatalf("expected status 200, got %d", rec.Code) t.Fatalf("expected status 200, got %d", rec.Code)
} }
var got []domainArticle.Article var got []domainArticle.Article
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { if err := json.Unmarshal(articleItemsJSON(rec.Body.Bytes()), &got); err != nil {
t.Fatalf("failed to decode response: %v", err) t.Fatalf("failed to decode response: %v", err)
} }
if len(got) != 1 { if len(got) != 1 {
@@ -112,7 +112,7 @@ func TestPublicArticleListingHttp(t *testing.T) {
t.Fatalf("expected status 200, got %d", rec.Code) t.Fatalf("expected status 200, got %d", rec.Code)
} }
var got []domainArticle.Article var got []domainArticle.Article
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { if err := json.Unmarshal(articleItemsJSON(rec.Body.Bytes()), &got); err != nil {
t.Fatalf("failed to decode response: %v", err) t.Fatalf("failed to decode response: %v", err)
} }
if len(got) != 2 { if len(got) != 2 {
@@ -138,7 +138,7 @@ func TestPublicArticleListingHttp(t *testing.T) {
t.Fatalf("expected status 200, got %d", rec.Code) t.Fatalf("expected status 200, got %d", rec.Code)
} }
var got []domainArticle.Article var got []domainArticle.Article
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { if err := json.Unmarshal(articleItemsJSON(rec.Body.Bytes()), &got); err != nil {
t.Fatalf("failed to decode response: %v", err) t.Fatalf("failed to decode response: %v", err)
} }
if len(got) != 1 { if len(got) != 1 {
@@ -194,7 +194,7 @@ func TestPublicArticleListingHttp(t *testing.T) {
t.Run("A repository failure still answers 500 regardless of auth state", func(t *testing.T) { t.Run("A repository failure still answers 500 regardless of auth state", func(t *testing.T) {
repo := seedPublicArticleListingRepo() repo := seedPublicArticleListingRepo()
repo.fail.failNext("FindByFilters") repo.fail.failNext("FindPage")
router := newPublicArticleListingRouter(repo, token) router := newPublicArticleListingRouter(repo, token)
req := httptest.NewRequest(http.MethodGet, "/v1/article/", nil) req := httptest.NewRequest(http.MethodGet, "/v1/article/", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
+9 -8
View File
@@ -196,35 +196,36 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
return repo return repo
} }
t.Run("ListOnlineArticles", func(t *testing.T) { t.Run("ListArticlesPage unverified", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}) uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
result, err := uc.ListOnlineArticles(article.BodyFormatHTML) result, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Page: 1, Size: 25, Sort: article.Sort{Field: article.SortByTitle, Direction: article.SortAsc}})
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
assertArticleIds(t, result, []uint32{1, 3}) assertArticleIds(t, result.Items, []uint32{1, 3})
}) })
t.Run("ListAllArticles", func(t *testing.T) { t.Run("ListArticlesPage verified", func(t *testing.T) {
repo := newRepo() repo := newRepo()
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}) uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
result, err := uc.ListAllArticles(article.BodyFormatHTML) result, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Verified: true, Page: 1, Size: 25, Sort: article.Sort{Field: article.SortByTitle, Direction: article.SortAsc}})
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
assertArticleIds(t, result, []uint32{1, 2, 3}) assertArticleIds(t, result.Items, []uint32{1, 2, 3})
}) })
} }
func TestListOnlineArticlesEmptyRepositoryReturnsEmptySlice(t *testing.T) { func TestListArticlesPageEmptyRepositoryReturnsEmptySlice(t *testing.T) {
repo := newMockArticleRepo() repo := newMockArticleRepo()
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}) uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{})
result, err := uc.ListOnlineArticles(article.BodyFormatHTML) page, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Verified: true, Page: 1, Size: 25})
if err != nil { if err != nil {
t.Fatalf("expected no error, got %v", err) t.Fatalf("expected no error, got %v", err)
} }
result := page.Items
if result == nil { if result == nil {
t.Errorf("expected an empty slice, got nil") t.Errorf("expected an empty slice, got nil")
} }
+3 -3
View File
@@ -131,7 +131,7 @@ func TestVerifiedAccountRequiredHttp(t *testing.T) {
t.Fatalf("expected status 201, got %d, body %q", rec.Code, rec.Body.String()) t.Fatalf("expected status 201, got %d, body %q", rec.Code, rec.Body.String())
} }
var got domainArticle.Article var got domainArticle.Article
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { if err := json.Unmarshal(articleItemsJSON(rec.Body.Bytes()), &got); err != nil {
t.Fatalf("failed to decode response: %v", err) t.Fatalf("failed to decode response: %v", err)
} }
if got.Title != "Valid Title" || got.Body != "Some content" { if got.Title != "Valid Title" || got.Body != "Some content" {
@@ -248,7 +248,7 @@ func TestVerifiedAccountRequiredHttp(t *testing.T) {
t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String()) t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String())
} }
var got []domainArticle.Article var got []domainArticle.Article
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { if err := json.Unmarshal(articleItemsJSON(rec.Body.Bytes()), &got); err != nil {
t.Fatalf("failed to decode response: %v", err) t.Fatalf("failed to decode response: %v", err)
} }
if len(got) != 1 || got[0].ID != 1 { if len(got) != 1 || got[0].ID != 1 {
@@ -283,7 +283,7 @@ func TestVerifiedAccountRequiredHttp(t *testing.T) {
t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String()) t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String())
} }
var got []domainArticle.Article var got []domainArticle.Article
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { if err := json.Unmarshal(articleItemsJSON(rec.Body.Bytes()), &got); err != nil {
t.Fatalf("failed to decode response: %v", err) t.Fatalf("failed to decode response: %v", err)
} }
ids := map[uint32]bool{} ids := map[uint32]bool{}
+200
View File
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { pageItems, rangeFor } from '@/lib/pagination'
import { ARTICLE_PAGE_SIZES } from '@/lib/articleListQuery'
const DESKTOP_QUERY = '(min-width: 48rem)'
const props = defineProps<{
page: number
pageCount: number
total: number
size: number
loadingPage: number | null
}>()
const emit = defineEmits<{
'update:page': [page: number]
'update:size': [size: number]
}>()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const busy = computed(() => props.loadingPage !== null)
const atStart = computed(() => busy.value || props.page <= 1)
const atEnd = computed(() => busy.value || props.page >= props.pageCount)
const showControls = computed(() => props.pageCount > 1)
const items = computed(() => pageItems(props.page, props.pageCount))
const summary = computed(() => {
if (props.loadingPage !== null) {
return t('Loading page {0}…', [formatNumber(props.loadingPage)])
}
const { from, to } = rangeFor(props.page, props.size, props.total)
return t('Showing {0}–{1} of {2}', [
formatNumber(from),
formatNumber(to),
formatNumber(props.total),
])
})
const sizeItems = computed(() =>
ARTICLE_PAGE_SIZES.map((value) => ({ label: t('{0} per page', [value]), value })),
)
const FOCUS_RING =
'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 ICON_CONTROL = `size-11 md:size-8 ${CONTROL}`
const CURRENT_PAGE = 'border-primary bg-(--rc-sand) font-semibold text-(--rc-on-sand)'
function numberClass(number: number) {
const sizing = `h-11 min-w-11 px-1.5 md:h-8 md:min-w-8 ${CONTROL}`
return number === props.page ? `${sizing} ${CURRENT_PAGE}` : `${sizing} font-normal`
}
</script>
<template>
<div v-if="isDesktop" data-testid="paginator" class="flex items-center gap-4 pt-3">
<span aria-live="polite" data-testid="paginator-summary" class="flex-1 text-muted">{{
summary
}}</span>
<USelectMenu
: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">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevrons-left"
:disabled="atStart"
:aria-label="t('First page')"
:class="ICON_CONTROL"
@click="emit('update:page', 1)"
/>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-left"
:disabled="atStart"
:aria-label="t('Previous page')"
:class="ICON_CONTROL"
@click="emit('update:page', page - 1)"
/>
<template v-for="(item, index) in items" :key="`${item}-${index}`">
<span
v-if="item === 'gap'"
aria-hidden="true"
class="flex h-8 min-w-8 items-center justify-center text-muted"
>…</span
>
<UButton
v-else
color="neutral"
variant="outline"
:disabled="busy"
:aria-current="item === page ? 'page' : undefined"
:class="numberClass(item)"
@click="emit('update:page', item)"
>
{{ formatNumber(item) }}
</UButton>
</template>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-right"
:disabled="atEnd"
:aria-label="t('Next page')"
:class="ICON_CONTROL"
@click="emit('update:page', page + 1)"
/>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevrons-right"
:disabled="atEnd"
:aria-label="t('Last page')"
:class="ICON_CONTROL"
@click="emit('update:page', pageCount)"
/>
</nav>
</div>
<div v-else data-testid="paginator" class="flex flex-col gap-3 pt-3 pb-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<span aria-live="polite" data-testid="paginator-summary" class="text-muted">{{
summary
}}</span>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
:disabled="busy"
:aria-label="t('Rows per page')"
:content="{ side: 'top', align: 'end' }"
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>
<nav v-if="showControls" :aria-label="t('Pagination')" class="flex items-center gap-2">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-left"
:disabled="atStart"
:aria-label="t('Previous page')"
:class="`h-11 flex-1 ${CONTROL}`"
@click="emit('update:page', page - 1)"
>
{{ t('Previous') }}
</UButton>
<span
aria-current="page"
class="shrink-0 px-1 font-medium whitespace-nowrap text-highlighted"
>{{ t('Page {0} of {1}', [formatNumber(page), formatNumber(pageCount)]) }}</span
>
<UButton
color="neutral"
variant="outline"
trailing-icon="i-lucide-chevron-right"
:disabled="atEnd"
:aria-label="t('Next page')"
:class="`h-11 flex-1 ${CONTROL}`"
@click="emit('update:page', page + 1)"
>
{{ t('Next') }}
</UButton>
</nav>
</div>
</template>
+11 -7
View File
@@ -6,12 +6,16 @@ import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatRelativeDate } from '@/lib/relativeDate' import { formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles' import type { Article } from '@/composables/useArticles'
const props = defineProps<{ const props = withDefaults(
articles: Article[] defineProps<{
loading: boolean articles: Article[]
busyIds: Set<number> loading: boolean
now?: Date skeletonRows?: number
}>() busyIds: Set<number>
now?: Date
}>(),
{ skeletonRows: 5 },
)
const emit = defineEmits<{ const emit = defineEmits<{
publish: [id: number] publish: [id: number]
@@ -97,7 +101,7 @@ function onRowClick(article: Article, event: MouseEvent) {
> >
<template v-if="loading"> <template v-if="loading">
<li <li
v-for="n in 5" v-for="n in skeletonRows"
:key="n" :key="n"
class="flex min-h-17 flex-col justify-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0" class="flex min-h-17 flex-col justify-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
> >
+95 -34
View File
@@ -1,24 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n' import { t } from '@/i18n'
import { ref } from 'vue' import { computed, ref, watchPostEffect } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue' import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate' import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles' import type { Article } from '@/composables/useArticles'
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
const props = defineProps<{ const props = withDefaults(
articles: Article[] defineProps<{
loading: boolean articles: Article[]
busyIds: Set<number> loading: boolean
now?: Date skeletonRows?: number
}>() sort?: string
busyIds: Set<number>
now?: Date
}>(),
{ skeletonRows: 5, sort: DEFAULT_ARTICLE_SORT },
)
const emit = defineEmits<{ const emit = defineEmits<{
publish: [id: number] publish: [id: number]
unpublish: [id: number] unpublish: [id: number]
delete: [article: Article] delete: [article: Article]
'copy-url': [id: number] 'copy-url': [id: number]
sort: [field: ArticleSortField]
}>() }>()
const router = useRouter() const router = useRouter()
@@ -26,7 +33,42 @@ const router = useRouter()
const columnWidths = const columnWidths =
'[&:nth-child(2)]:w-27.5 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-35 [&:nth-child(5)]:w-11' '[&:nth-child(2)]:w-27.5 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-35 [&:nth-child(5)]:w-11'
const sorting = ref([{ id: 'updated_at', desc: true }]) const sorting = computed(() => {
const [id, direction] = props.sort.split(':')
return [{ id: id!, desc: direction === 'desc' }]
})
const tableRoot = ref<HTMLElement | null>(null)
const SORTABLE_COLUMNS = ['title', 'status', 'created_at', 'updated_at']
watchPostEffect(() => {
const [active, direction] = props.sort.split(':')
tableRoot.value?.querySelectorAll('thead th').forEach((header, index) => {
if (SORTABLE_COLUMNS[index] === active) {
header.setAttribute('aria-sort', direction === 'desc' ? 'descending' : 'ascending')
} else {
header.removeAttribute('aria-sort')
}
})
})
const COLUMN_NAMES = {
title: () => t('title'),
status: () => t('status'),
created_at: () => t('created'),
updated_at: () => t('updated'),
}
const caption = computed(() => {
if (props.sort === DEFAULT_ARTICLE_SORT) {
return t('Articles, sorted by last updated, newest first')
}
const [field, direction] = props.sort.split(':') as [ArticleSortField, string]
return t('Articles, sorted by {0}, {1}', [
COLUMN_NAMES[field](),
direction === 'desc' ? t('descending') : t('ascending'),
])
})
const columns = [ const columns = [
{ {
@@ -34,15 +76,16 @@ const columns = [
get header() { get header() {
return t('Title') return t('Title')
}, },
enableSorting: true, enableSorting: false,
}, },
{ {
accessorKey: 'is_online', accessorKey: 'is_online',
id: 'status',
get header() { get header() {
return t('Status') return t('Status')
}, },
size: 110, size: 110,
enableSorting: true, enableSorting: false,
}, },
{ {
accessorKey: 'created_at', accessorKey: 'created_at',
@@ -50,7 +93,7 @@ const columns = [
return t('Created') return t('Created')
}, },
size: 130, size: 130,
enableSorting: true, enableSorting: false,
}, },
{ {
accessorKey: 'updated_at', accessorKey: 'updated_at',
@@ -58,20 +101,17 @@ const columns = [
return t('Updated') return t('Updated')
}, },
size: 140, size: 140,
enableSorting: true, enableSorting: false,
}, },
{ id: 'actions', header: '', size: 44 }, { id: 'actions', header: '', size: 44 },
] ]
function sortIcon(column: { getIsSorted: () => false | 'asc' | 'desc' }) { function sortIcon(field: string) {
const sorted = column.getIsSorted() const [id, direction] = props.sort.split(':')
if (sorted === 'asc') { if (id !== field) {
return 'i-lucide-arrow-up' return undefined
} }
if (sorted === 'desc') { return direction === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
return 'i-lucide-arrow-down'
}
return undefined
} }
function editPath(article: Article) { function editPath(article: Article) {
@@ -143,7 +183,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block"> <div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
<span role="status" class="sr-only">{{ t('Loading articles') }}</span> <span role="status" class="sr-only">{{ t('Loading articles') }}</span>
<div <div
v-for="n in 5" v-for="n in skeletonRows"
:key="n" :key="n"
class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0" class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0"
> >
@@ -153,7 +193,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<USkeleton class="h-3 w-20 rounded-none bg-accented" /> <USkeleton class="h-3 w-20 rounded-none bg-accented" />
</div> </div>
</div> </div>
<div v-else class="hidden border border-default bg-default md:block"> <div v-else ref="tableRoot" class="hidden border border-default bg-default md:block">
<UTable <UTable
:data="articles" :data="articles"
:columns="columns" :columns="columns"
@@ -163,13 +203,14 @@ function onSelect(_event: Event, row: { original: Article }) {
th: `px-3 ${columnWidths}`, th: `px-3 ${columnWidths}`,
td: `h-11 px-3 py-0 ${columnWidths}`, td: `h-11 px-3 py-0 ${columnWidths}`,
}" }"
v-model:sorting="sorting" :sorting="sorting"
:sorting-options="{ manualSorting: true }"
@select="onSelect" @select="onSelect"
> >
<template #caption> <template #caption>
<caption class="sr-only"> <caption class="sr-only">
{{ {{
t('Articles, sorted by last updated, newest first') caption
}} }}
</caption> </caption>
</template> </template>
@@ -178,21 +219,31 @@ function onSelect(_event: Event, row: { original: Article }) {
<button <button
type="button" type="button"
class="inline-flex items-center gap-1 text-highlighted" class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()" @click.stop="emit('sort', column.id as ArticleSortField)"
> >
{{ t('Title') }} {{ t('Title') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" /> <UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button> </button>
</template> </template>
<template #is_online-header="{ column }"> <template #status-header="{ column }">
<button <button
type="button" type="button"
class="inline-flex items-center gap-1 text-highlighted" class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()" @click.stop="emit('sort', column.id as ArticleSortField)"
> >
{{ t('Status') }} {{ t('Status') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" /> <UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button> </button>
</template> </template>
@@ -200,10 +251,15 @@ function onSelect(_event: Event, row: { original: Article }) {
<button <button
type="button" type="button"
class="inline-flex items-center gap-1 text-highlighted" class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()" @click.stop="emit('sort', column.id as ArticleSortField)"
> >
{{ t('Created') }} {{ t('Created') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" /> <UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button> </button>
</template> </template>
@@ -211,10 +267,15 @@ function onSelect(_event: Event, row: { original: Article }) {
<button <button
type="button" type="button"
class="inline-flex items-center gap-1 text-highlighted" class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()" @click.stop="emit('sort', column.id as ArticleSortField)"
> >
{{ t('Updated') }} {{ t('Updated') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" /> <UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button> </button>
</template> </template>
@@ -229,7 +290,7 @@ function onSelect(_event: Event, row: { original: Article }) {
</RouterLink> </RouterLink>
</template> </template>
<template #is_online-cell="{ row }"> <template #status-cell="{ row }">
<ArticleStatusBadge <ArticleStatusBadge
:online="row.original.is_online" :online="row.original.is_online"
size="sm" size="sm"
@@ -8,16 +8,51 @@ vi.mock('@/composables/useApi', () => ({
import { useArticles } from '@/composables/useArticles' import { useArticles } from '@/composables/useArticles'
const envelope = {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 0, online: 0, draft: 0 },
}
describe('useArticles', () => { describe('useArticles', () => {
beforeEach(() => { beforeEach(() => {
request.mockReset() request.mockReset()
request.mockResolvedValue(envelope)
}) })
it('asks the API for html bodies when listing articles', async () => { it('asks the API for html bodies with explicit paging when listing articles', async () => {
request.mockResolvedValue([]) await useArticles().listArticles({ page: 1, size: 25 })
await useArticles().listArticles() expect(request).toHaveBeenCalledWith('/v1/article/?format=html&page=1&size=25')
})
expect(request).toHaveBeenCalledWith('/v1/article/?format=html') it('sends the search text and the status when they are set', async () => {
await useArticles().listArticles({ page: 2, size: 50, q: 'plaster', status: 'draft' })
expect(request).toHaveBeenCalledWith(
'/v1/article/?format=html&page=2&size=50&q=plaster&status=draft',
)
})
it('sends the sort when it is set', async () => {
await useArticles().listArticles({ page: 1, size: 25, sort: 'title:asc' })
expect(request).toHaveBeenCalledWith(
'/v1/article/?format=html&page=1&size=25&sort=title%3Aasc',
)
})
it('omits an empty search and an empty status', async () => {
await useArticles().listArticles({ page: 3, size: 5, q: '', status: '' })
expect(request).toHaveBeenCalledWith('/v1/article/?format=html&page=3&size=5')
})
it('returns the envelope untouched', async () => {
const result = await useArticles().listArticles({ page: 1, size: 25 })
expect(result).toEqual(envelope)
}) })
}) })
+41 -2
View File
@@ -1,4 +1,5 @@
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import type { ArticleStatus } from '@/lib/articleListQuery'
export interface Article { export interface Article {
id: number id: number
@@ -9,11 +10,49 @@ export interface Article {
updated_at: string updated_at: string
} }
export type { ArticleStatus }
export interface ArticleCounts {
all: number
online: number
draft: number
}
export interface ArticlePage {
items: Article[]
total: number
page: number
size: number
counts: ArticleCounts
}
export interface ListArticlesParams {
page: number
size: number
q?: string
status?: ArticleStatus | ''
sort?: string
}
export function useArticles() { export function useArticles() {
const { request } = useApi() const { request } = useApi()
function listArticles(): Promise<Article[]> { function listArticles(params: ListArticlesParams): Promise<ArticlePage> {
return request<Article[]>('/v1/article/?format=html') const query = new URLSearchParams({
format: 'html',
page: String(params.page),
size: String(params.size),
})
if (params.q) {
query.set('q', params.q)
}
if (params.status) {
query.set('status', params.status)
}
if (params.sort) {
query.set('sort', params.sort)
}
return request<ArticlePage>(`/v1/article/?${query.toString()}`)
} }
return { listArticles } return { listArticles }
+25 -7
View File
@@ -266,9 +266,9 @@ describe('Response samples use the real domain JSON keys', () => {
expect(single).not.toHaveProperty('images') expect(single).not.toHaveProperty('images')
expect(single).not.toHaveProperty('status') expect(single).not.toHaveProperty('status')
const list = parsed(find('GET /v1/article/')) const list = parsed(find('GET /v1/article/'))
expectOnlyKeys(list[0], ARTICLE_KEYS) expectOnlyKeys(list.items[0], ARTICLE_KEYS)
expect(list[0]).not.toHaveProperty('images') expect(list.items[0]).not.toHaveProperty('images')
expect(list[0]).not.toHaveProperty('status') expect(list.items[0]).not.toHaveProperty('status')
}) })
it('returns articles from create, update, publish and unpublish', () => { it('returns articles from create, update, publish and unpublish', () => {
@@ -393,13 +393,31 @@ describe('Request bodies and query parameters', () => {
it('lists format on every article route', () => { it('lists format on every article route', () => {
for (const endpoint of API_GROUPS[0]!.endpoints) { for (const endpoint of API_GROUPS[0]!.endpoints) {
expect( expect(endpoint.query.map((param) => param.name)).toContain('format')
endpoint.query.map((param) => param.name),
label(endpoint),
).toEqual(['format'])
} }
}) })
it('lists page, size, q, status and sort on the article list only', () => {
expect(find('GET /v1/article/').query.map((param) => param.name)).toEqual([
'format',
'page',
'size',
'q',
'status',
'sort',
])
expect(find('GET /v1/article/{id}').query.map((param) => param.name)).toEqual(['format'])
})
it('documents the article list as a page envelope with a 400 for invalid paging', () => {
const list = find('GET /v1/article/')
const body = JSON.parse(list.response)
expect(Object.keys(body)).toEqual(['items', 'total', 'page', 'size', 'counts'])
expect(Object.keys(body.counts)).toEqual(['all', 'online', 'draft'])
expect(list.errors.map((error) => error.status)).toContain(400)
expect(Array.isArray(JSON.parse(list.response))).toBe(false)
})
it('lists q, hash, cursor, filter, sort and limit on the media list only', () => { it('lists q, hash, cursor, filter, sort and limit on the media list only', () => {
expect(find('GET /v1/media/').query.map((param) => param.name)).toEqual([ expect(find('GET /v1/media/').query.map((param) => param.name)).toEqual([
'q', 'q',
+40 -4
View File
@@ -95,6 +95,25 @@ const FORMAT_QUERY: ApiField = {
rule: 'markdown, html or json. Default markdown.', rule: 'markdown, html or json. Default markdown.',
} }
const ARTICLE_LIST_QUERY: ApiField[] = [
FORMAT_QUERY,
{ name: 'page', type: 'number', required: false, rule: 'A whole number from 1. Default 1.' },
{ name: 'size', type: 'number', required: false, rule: '5, 25, 50 or 100. Default 25.' },
{
name: 'q',
type: 'string',
required: false,
rule: 'Up to 200 characters, matched anywhere in the title, ignoring case and accents.',
},
{ name: 'status', type: 'string', required: false, rule: 'online or draft. Default both.' },
{
name: 'sort',
type: 'string',
required: false,
rule: 'field:direction, with field title, status, created_at or updated_at and direction asc or desc. Default updated_at:desc.',
},
]
const MEDIA_QUERY: ApiField[] = [ const MEDIA_QUERY: ApiField[] = [
{ name: 'q', type: 'string', required: false, rule: 'Search text.' }, { name: 'q', type: 'string', required: false, rule: 'Search text.' },
{ {
@@ -188,6 +207,23 @@ const ownerOrAdminErrors = (...own: ApiError[]) => [
e(403, OWNER_OR_ADMIN_MESSAGE), e(403, OWNER_OR_ADMIN_MESSAGE),
] ]
const articlePage = () => ({
items: [
article(),
article({
id: 13,
title: 'Choosing tiles for a small bathroom',
body: 'Small rooms show every grout line.',
is_online: false,
updated_at: '2026-09-09T07:00:00Z',
}),
],
total: 30,
page: 1,
size: 25,
counts: { all: 30, online: 28, draft: 2 },
})
const articleIdErrors = [e(400, ID_FORMAT), e(404, 'Article not found')] const articleIdErrors = [e(400, ID_FORMAT), e(404, 'Article not found')]
const none: ApiField[] = [] const none: ApiField[] = []
@@ -260,11 +296,11 @@ export const API_GROUPS: ApiGroup[] = [
'art-list', 'art-list',
'GET', 'GET',
'/v1/article/', '/v1/article/',
'List articles. Anonymous callers get online articles only.', 'List one page of articles, newest update first, with the total and the tab counts. Anonymous callers get online articles only.',
'Public', 'Public',
articleErrors(), articleErrors(e(400, BODY_MISMATCH)),
json([article()]), json(articlePage()),
{ query: [FORMAT_QUERY] }, { query: ARTICLE_LIST_QUERY },
), ),
endpoint( endpoint(
'art-get', 'art-get',
+18
View File
@@ -285,6 +285,13 @@ export const messages = {
"Couldn't copy the link": 'Impossible de copier le lien', "Couldn't copy the link": 'Impossible de copier le lien',
'Articles, sorted by last updated, newest first': 'Articles, sorted by last updated, newest first':
'Articles triés par dernière modification, du plus récent au plus ancien', 'Articles triés par dernière modification, du plus récent au plus ancien',
'Articles, sorted by {0}, {1}': 'Articles triés par {0}, {1}',
title: 'titre',
status: 'statut',
created: 'date de création',
updated: 'dernière modification',
ascending: 'croissant',
descending: 'décroissant',
'Loading articles': 'Chargement des articles', 'Loading articles': 'Chargement des articles',
Loading: 'Chargement', Loading: 'Chargement',
"Couldn't load your articles": 'Impossible de charger vos articles', "Couldn't load your articles": 'Impossible de charger vos articles',
@@ -1062,6 +1069,17 @@ export const messages = {
'Rows per page': 'Lignes par page', 'Rows per page': 'Lignes par page',
'Previous page': 'Page précédente', 'Previous page': 'Page précédente',
'Next page': 'Page suivante', 'Next page': 'Page suivante',
'First page': 'Première page',
'Last page': 'Dernière page',
Pagination: 'Pagination',
Previous: 'Précédent',
Next: 'Suivant',
'{0} per page': '{0} par page',
'Loading page {0}…': 'Chargement de la page {0}…',
"Couldn't load page {0}. Showing page {1}.":
'Impossible de charger la page {0}. Affichage de la page {1}.',
"That page doesn't exist. Showing the last page.":
'Cette page n’existe pas. Affichage de la dernière page.',
'Start date': 'Date de début', 'Start date': 'Date de début',
'End date': 'Date de fin', 'End date': 'Date de fin',
When: 'Quand', When: 'Quand',
+15 -11
View File
@@ -13,6 +13,7 @@ import { initializeLocale, setLocale, t } from './index'
import { messages } from './messages' import { messages } from './messages'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate' import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { formatSize, pluralArticles } from '@/lib/mediaFormat' import { formatSize, pluralArticles } from '@/lib/mediaFormat'
import { articleEnvelope, articleListUrl } from '@/views/__tests__/articleEnvelope'
vi.mock('@/composables/useApi', async () => { vi.mock('@/composables/useApi', async () => {
const { ref } = await import('vue') const { ref } = await import('vue')
@@ -349,16 +350,19 @@ describe('Hardener localization outcomes', () => {
expect(document.activeElement).toBe(wrapper!.find('input[autocomplete="username"]').element) expect(document.activeElement).toBe(wrapper!.find('input[autocomplete="username"]').element)
}) })
it('focuses article search after clearing French no-results', async () => { it('focuses article search after clearing French no-results', async () => {
request.mockResolvedValue([ const article = {
{ id: 12,
id: 12, title: 'Data title',
title: 'Data title', body: '',
body: '', is_online: false,
is_online: false, created_at: '2025-02-14T12:00:00Z',
created_at: '2025-02-14T12:00:00Z', updated_at: '2025-02-14T12:00:00Z',
updated_at: '2025-02-14T12:00:00Z', }
}, request.mockImplementation(async (url: string) =>
]) articleEnvelope([article], {
q: new URL(url, 'http://localhost').searchParams.get('q') ?? '',
}),
)
setLocale('fr') setLocale('fr')
await mountRoute('/article?q=absent') await mountRoute('/article?q=absent')
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button')) Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
@@ -414,7 +418,7 @@ describe('Hardener localization outcomes', () => {
updated_at: '2025-02-14T12:00:00Z', updated_at: '2025-02-14T12:00:00Z',
} }
request.mockImplementation(async (url: string) => request.mockImplementation(async (url: string) =>
url === '/v1/article/?format=html' ? [article] : article, url === articleListUrl() ? articleEnvelope([article]) : article,
) )
await mountRoute(path) await mountRoute(path)
if (path === '/article') { if (path === '/article') {
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import { nextSort, parseArticleQuery, sameArticleQuery, toRouteQuery } from '@/lib/articleListQuery'
describe('parseArticleQuery', () => {
it('falls back to page 1, size 25, no search and the All tab', () => {
expect(parseArticleQuery({})).toEqual({ page: 1, size: 25, q: '', status: '', sort: '' })
})
it('reads valid values', () => {
expect(
parseArticleQuery({
page: '7',
size: '50',
q: 'plaster',
status: 'draft',
sort: 'title:asc',
}),
).toEqual({ page: 7, size: 50, q: 'plaster', status: 'draft', sort: 'title:asc' })
})
it.each(['abc', '0', '-2', '2.5', '', '1e2'])('treats page "%s" as 1', (page) => {
expect(parseArticleQuery({ page }).page).toBe(1)
})
it.each(['10', 'abc', '0', '26', ''])('treats size "%s" as 25', (size) => {
expect(parseArticleQuery({ size }).size).toBe(25)
})
it('ignores an unknown status', () => {
expect(parseArticleQuery({ status: 'archived' }).status).toBe('')
})
it('reads the first value of a repeated parameter and caps the search at 200 characters', () => {
expect(parseArticleQuery({ page: ['3', '4'] }).page).toBe(3)
expect(parseArticleQuery({ q: 'a'.repeat(250) }).q).toHaveLength(200)
})
})
describe('sort parameter', () => {
it.each(['title:asc', 'status:desc', 'created_at:asc', 'created_at:desc'])(
'keeps %s',
(sort) => {
expect(parseArticleQuery({ sort }).sort).toBe(sort)
},
)
it.each(['body:asc', 'title', 'title:up', 'TITLE:ASC', ''])('drops invalid "%s"', (sort) => {
expect(parseArticleQuery({ sort }).sort).toBe('')
})
it('treats the default as no sort', () => {
expect(parseArticleQuery({ sort: 'updated_at:desc' }).sort).toBe('')
})
it('omits the default from the route and treats it as equal to no sort', () => {
const base = { page: 1, size: 25, q: '', status: '' as const }
expect(toRouteQuery({ ...base, sort: 'updated_at:desc' })).toEqual({})
expect(toRouteQuery({ ...base, sort: 'title:asc' })).toEqual({ sort: 'title:asc' })
expect(sameArticleQuery({ ...base, sort: '' }, { ...base, sort: 'updated_at:desc' })).toBe(
true,
)
expect(sameArticleQuery({ ...base, sort: 'title:asc' }, { ...base, sort: '' })).toBe(false)
})
it('toggles: first click ascending, second descending, other column restarts', () => {
expect(nextSort('', 'title')).toBe('title:asc')
expect(nextSort('title:asc', 'title')).toBe('title:desc')
expect(nextSort('title:desc', 'title')).toBe('title:asc')
expect(nextSort('title:asc', 'status')).toBe('status:asc')
expect(nextSort('', 'updated_at')).toBe('updated_at:asc')
expect(nextSort('updated_at:asc', 'updated_at')).toBe('updated_at:desc')
})
})
describe('toRouteQuery', () => {
it('omits every default', () => {
expect(toRouteQuery({ page: 1, size: 25, q: '', status: '', sort: '' })).toEqual({})
})
it('keeps what differs from the defaults', () => {
expect(
toRouteQuery({ page: 2, size: 5, q: 'plaster', status: 'online', sort: 'title:desc' }),
).toEqual({
page: '2',
size: '5',
q: 'plaster',
status: 'online',
sort: 'title:desc',
})
})
})
describe('sameArticleQuery', () => {
it('compares every field', () => {
const base = { page: 1, size: 25, q: '', status: '' as const, sort: '' }
expect(sameArticleQuery(base, { ...base })).toBe(true)
expect(sameArticleQuery(base, { ...base, page: 2 })).toBe(false)
expect(sameArticleQuery(base, { ...base, size: 5 })).toBe(false)
expect(sameArticleQuery(base, { ...base, q: 'a' })).toBe(false)
expect(sameArticleQuery(base, { ...base, status: 'draft' })).toBe(false)
})
})
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { pageItems, pageCountFor, rangeFor } from '@/lib/pagination'
describe('pageItems', () => {
it('lists every page when there are seven or fewer', () => {
expect(pageItems(3, 7)).toEqual([1, 2, 3, 4, 5, 6, 7])
expect(pageItems(1, 1)).toEqual([1])
})
it('marks the skipped range after the first page', () => {
expect(pageItems(1, 13)).toEqual([1, 2, 'gap', 13])
})
it('marks skipped ranges on both sides of a middle page', () => {
expect(pageItems(7, 13)).toEqual([1, 'gap', 6, 7, 8, 'gap', 13])
})
it('marks the skipped range before the last page', () => {
expect(pageItems(13, 13)).toEqual([1, 'gap', 12, 13])
})
it('does not mark a gap between adjacent pages', () => {
expect(pageItems(3, 13)).toEqual([1, 2, 3, 4, 'gap', 13])
})
})
describe('pageCountFor', () => {
it('rounds up and never drops below one', () => {
expect(pageCountFor(312, 25)).toBe(13)
expect(pageCountFor(6, 25)).toBe(1)
expect(pageCountFor(0, 25)).toBe(1)
})
})
describe('rangeFor', () => {
it('computes the shown range', () => {
expect(rangeFor(1, 25, 312)).toEqual({ from: 1, to: 25 })
expect(rangeFor(7, 25, 312)).toEqual({ from: 151, to: 175 })
expect(rangeFor(13, 25, 312)).toEqual({ from: 301, to: 312 })
expect(rangeFor(1, 25, 0)).toEqual({ from: 0, to: 0 })
})
})
+91
View File
@@ -0,0 +1,91 @@
import type { LocationQuery } from 'vue-router'
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 ARTICLE_SORT_FIELDS = ['title', 'status', 'created_at', 'updated_at'] as const
export type ArticleSortField = (typeof ARTICLE_SORT_FIELDS)[number]
const POSITIVE_INTEGER = /^[1-9]\d*$/
export interface ArticleListQuery {
page: number
size: number
q: string
status: ArticleStatus | ''
sort: string
}
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)$`)
function validSort(value: string) {
return SORT_PATTERN.test(value) && value !== DEFAULT_ARTICLE_SORT ? value : ''
}
export function effectiveSort(sort: string) {
return sort || DEFAULT_ARTICLE_SORT
}
export function nextSort(current: string, field: ArticleSortField) {
const [activeField, direction] = effectiveSort(current).split(':')
return `${field}:${activeField === field && direction === 'asc' ? 'desc' : 'asc'}`
}
export function parseArticleQuery(query: LocationQuery): ArticleListQuery {
const page = first(query.page)
const size = Number(first(query.size))
const status = first(query.status)
return {
page: POSITIVE_INTEGER.test(page) ? Number(page) : 1,
size: ARTICLE_PAGE_SIZES.includes(size) ? size : DEFAULT_ARTICLE_PAGE_SIZE,
q: first(query.q).slice(0, ARTICLE_SEARCH_MAX_LENGTH),
status: status === 'online' || status === 'draft' ? status : '',
sort: validSort(first(query.sort)),
}
}
export function toRouteQuery(query: ArticleListQuery) {
const route: Record<string, string> = {}
if (query.page !== 1) {
route.page = String(query.page)
}
if (query.size !== DEFAULT_ARTICLE_PAGE_SIZE) {
route.size = String(query.size)
}
if (query.q) {
route.q = query.q
}
if (query.status) {
route.status = query.status
}
if (effectiveSort(query.sort) !== DEFAULT_ARTICLE_SORT) {
route.sort = query.sort
}
return route
}
export function sameArticleQuery(left: ArticleListQuery, right: ArticleListQuery) {
return (
left.page === right.page &&
left.size === right.size &&
left.q === right.q &&
left.status === right.status &&
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])
)
}
+30
View File
@@ -0,0 +1,30 @@
const FULL_LIST_LIMIT = 7
export type PageItem = number | 'gap'
export function pageCountFor(total: number, size: number) {
return Math.max(1, Math.ceil(total / size))
}
export function rangeFor(page: number, size: number, total: number) {
if (total === 0) {
return { from: 0, to: 0 }
}
return { from: (page - 1) * size + 1, to: Math.min(page * size, total) }
}
export function pageItems(page: number, pageCount: number): PageItem[] {
const visible =
pageCount <= FULL_LIST_LIMIT
? Array.from({ length: pageCount }, (_, index) => index + 1)
: [
...new Set(
[1, page - 1, page, page + 1, pageCount].filter(
(candidate) => candidate >= 1 && candidate <= pageCount,
),
),
].sort((left, right) => left - right)
return visible.flatMap((number, index) =>
index > 0 && number - visible[index - 1]! > 1 ? ['gap' as const, number] : [number],
)
}
+287 -90
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { t } from '@/i18n' import { t } from '@/i18n'
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation' import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import DashboardShell from '@/components/DashboardShell.vue' import DashboardShell from '@/components/DashboardShell.vue'
@@ -10,12 +10,34 @@ import ArticleCardList from '@/components/ArticleCardList.vue'
import EmptyState from '@/components/EmptyState.vue' import EmptyState from '@/components/EmptyState.vue'
import NewArticleModal from '@/components/NewArticleModal.vue' import NewArticleModal from '@/components/NewArticleModal.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue' import ConfirmDialog from '@/components/ConfirmDialog.vue'
import AppPaginator from '@/components/AppPaginator.vue'
import { ApiError, useApi } from '@/composables/useApi' import { ApiError, useApi } from '@/composables/useApi'
import { useArticles, type Article } from '@/composables/useArticles' import {
useArticles,
type Article,
type ArticleCounts,
type ArticleStatus,
} from '@/composables/useArticles'
import { bodyImageCount } from '@/lib/bodyImageIds' import { bodyImageCount } from '@/lib/bodyImageIds'
import {
effectiveSort,
nextSort,
parseArticleQuery,
type ArticleSortField,
sameArticleQuery,
sameRouteQuery,
toRouteQuery,
type ArticleListQuery,
} from '@/lib/articleListQuery'
import { pageCountFor } from '@/lib/pagination'
const SKELETON_DELAY_MS = 150 const SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120 const SEARCH_DEBOUNCE_MS = 120
const DEFAULT_SKELETON_ROWS = 5
const ARTICLES_PATH = '/article'
type UrlMode = 'push' | 'replace'
type Notice = { kind: 'range' } | { kind: 'error'; query: ArticleListQuery; mode: UrlMode }
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -24,51 +46,40 @@ const { listArticles } = useArticles()
const overlay = useLocalizedOverlay() const overlay = useLocalizedOverlay()
const toast = useLocalizedToast() const toast = useLocalizedToast()
const articles = ref<Article[]>([]) const items = ref<Article[]>([])
const total = ref(0)
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
const shown = ref<ArticleListQuery>(parseArticleQuery(route.query))
const desired = ref<ArticleListQuery>(shown.value)
const hasLoaded = ref(false)
const loading = ref(true) const loading = ref(true)
const loadFailed = ref(false) const loadFailed = ref(false)
const pendingPage = ref<number | null>(null)
const notice = ref<Notice | null>(null)
const showSkeleton = ref(false) const showSkeleton = ref(false)
const busyIds = ref<Set<number>>(new Set()) const busyIds = ref<Set<number>>(new Set())
const newArticleModalOpen = ref(false) const newArticleModalOpen = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
let requestToken = 0
const searchInput = ref(String(route.query.q ?? '')) const searchInput = ref(shown.value.q)
const debouncedSearch = ref(searchInput.value)
function normalize(value: string) { function clearSearchTimer() {
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
} }
watch(searchInput, (value) => { watch(searchInput, (value) => {
if (searchDebounceTimer !== null) { clearSearchTimer()
clearTimeout(searchDebounceTimer) searchDebounceTimer = setTimeout(() => changeSearch(value), SEARCH_DEBOUNCE_MS)
}
searchDebounceTimer = setTimeout(() => {
debouncedSearch.value = value
router.replace({
query: { ...route.query, q: value || undefined },
})
}, SEARCH_DEBOUNCE_MS)
}) })
const activeTab = computed<'' | 'online' | 'draft'>({ const activeTab = computed<'' | ArticleStatus>({
get: () => get: () => desired.value.status,
route.query.status === 'online' || route.query.status === 'draft' ? route.query.status : '', set: (value) => changeStatus(value),
set: (value) => {
router.replace({
query: { ...route.query, status: value || undefined },
})
},
})
const counts = computed(() => {
const online = articles.value.filter((article) => article.is_online).length
return {
all: articles.value.length,
online,
drafts: articles.value.length - online,
}
}) })
const tabItems = computed(() => [ const tabItems = computed(() => [
@@ -86,46 +97,44 @@ const tabItems = computed(() => [
}, },
{ {
get label() { get label() {
return t('Drafts {0}', [counts.value.drafts]) return t('Drafts {0}', [counts.value.draft])
}, },
value: 'draft', value: 'draft',
}, },
]) ])
const statusFilteredArticles = computed(() => { const settled = computed(() => hasLoaded.value && !loadFailed.value)
if (activeTab.value === 'online') { const skeletonActive = computed(() => loading.value && showSkeleton.value)
return articles.value.filter((article) => article.is_online) const skeletonRows = computed(() => items.value.length || DEFAULT_SKELETON_ROWS)
}
if (activeTab.value === 'draft') {
return articles.value.filter((article) => !article.is_online)
}
return articles.value
})
const displayedArticles = computed(() => {
const search = normalize(debouncedSearch.value.trim())
if (!search) {
return statusFilteredArticles.value
}
return statusFilteredArticles.value.filter((article) =>
normalize(article.title).includes(search),
)
})
const emptyVariant = computed(() => { const emptyVariant = computed(() => {
if (loading.value || loadFailed.value) { if (!settled.value || skeletonActive.value || total.value > 0) {
return null return null
} }
if (articles.value.length === 0) { if (shown.value.q) {
return 'no-articles' as const
}
if (debouncedSearch.value.trim() && displayedArticles.value.length === 0) {
return 'no-results' as const return 'no-results' as const
} }
if (statusFilteredArticles.value.length === 0) { if (shown.value.status === 'draft') {
return activeTab.value === 'draft' ? ('no-drafts' as const) : ('no-online' as const) return 'no-drafts' as const
} }
return null if (shown.value.status === 'online') {
return 'no-online' as const
}
return 'no-articles' as const
})
const showPaginator = computed(() => settled.value && total.value > 0)
const pageCount = computed(() => pageCountFor(total.value, shown.value.size))
const noticeText = computed(() => {
const current = notice.value
if (!current) {
return ''
}
if (current.kind === 'range') {
return t("That page doesn't exist. Showing the last page.")
}
return t("Couldn't load page {0}. Showing page {1}.", [current.query.page, shown.value.page])
}) })
function clearSkeletonTimer() { function clearSkeletonTimer() {
@@ -135,27 +144,125 @@ function clearSkeletonTimer() {
} }
} }
async function loadArticles() { async function syncUrl(query: ArticleListQuery, mode: UrlMode) {
if (route.path !== ARTICLES_PATH) {
return
}
const target = toRouteQuery(query)
if (sameRouteQuery(route.query, target)) {
return
}
await router[mode]({ query: target })
}
async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notice | null = null) {
const token = ++requestToken
desired.value = query
loading.value = true loading.value = true
showSkeleton.value = false showSkeleton.value = false
pendingPage.value = hasLoaded.value ? query.page : null
clearSkeletonTimer() clearSkeletonTimer()
skeletonTimer = setTimeout(() => { skeletonTimer = setTimeout(() => {
showSkeleton.value = true showSkeleton.value = true
}, SKELETON_DELAY_MS) }, SKELETON_DELAY_MS)
loadFailed.value = false
try { try {
articles.value = await listArticles() const response = await listArticles(query)
if (token !== requestToken) {
return
}
const lastPage = pageCountFor(response.total, query.size)
if (
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
total.value = response.total
counts.value = response.counts
shown.value = effective
desired.value = effective
hasLoaded.value = true
loadFailed.value = false
notice.value = successNotice
await syncUrl(effective, mode)
} catch { } catch {
articles.value = [] if (token !== requestToken) {
loadFailed.value = true return
}
if (hasLoaded.value) {
notice.value = { kind: 'error', query, mode }
} else {
items.value = []
loadFailed.value = true
}
} finally { } finally {
loading.value = false if (token === requestToken) {
clearSkeletonTimer() loading.value = false
showSkeleton.value = false pendingPage.value = null
clearSkeletonTimer()
showSkeleton.value = false
}
} }
} }
function changePage(page: number) {
load({ ...desired.value, page }, 'push')
}
function changeSize(size: number) {
load({ ...desired.value, size, page: 1 }, 'push')
}
function changeStatus(status: '' | ArticleStatus) {
if (status === desired.value.status) {
return
}
load({ ...desired.value, status, page: 1 }, 'push')
}
function changeSort(field: ArticleSortField) {
load({ ...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 retryNotice() {
if (notice.value?.kind === 'error') {
load(notice.value.query, notice.value.mode)
}
}
watch(
() => route.query,
() => {
if (route.path !== ARTICLES_PATH) {
return
}
const next = parseArticleQuery(route.query)
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')
},
)
function setBusy(id: number, busy: boolean) { function setBusy(id: number, busy: boolean) {
const next = new Set(busyIds.value) const next = new Set(busyIds.value)
if (busy) { if (busy) {
@@ -166,8 +273,46 @@ function setBusy(id: number, busy: boolean) {
busyIds.value = next busyIds.value = next
} }
function removeArticle(id: number) { function shiftCounts(article: Article, delta: number) {
articles.value = articles.value.filter((article) => article.id !== id) const key = article.is_online ? 'online' : 'draft'
counts.value = {
...counts.value,
all: Math.max(0, counts.value.all + delta),
[key]: Math.max(0, counts.value[key] + delta),
}
}
function detachRow(id: number) {
if (!items.value.some((article) => article.id === id)) {
return
}
items.value = items.value.filter((article) => article.id !== id)
total.value = Math.max(0, total.value - 1)
if (items.value.length === 0 && total.value > 0) {
const page = Math.min(shown.value.page, pageCountFor(total.value, shown.value.size))
load({ ...shown.value, page }, 'replace')
}
}
function removeArticle(article: Article) {
shiftCounts(article, -1)
detachRow(article.id)
}
function replaceArticle(updated: Article, previous: Article) {
items.value = items.value.map((existing) => (existing.id === updated.id ? updated : existing))
if (updated.is_online === previous.is_online) {
return
}
counts.value = {
...counts.value,
online: counts.value.online + (updated.is_online ? 1 : -1),
draft: counts.value.draft + (updated.is_online ? -1 : 1),
}
const filter = shown.value.status
if (filter && (filter === 'online') !== updated.is_online) {
detachRow(updated.id)
}
} }
function notFoundToast(article: Article) { function notFoundToast(article: Article) {
@@ -191,9 +336,7 @@ async function setOnline(article: Article, makeOnline: boolean) {
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`, `/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' }, { method: 'PATCH' },
) )
articles.value = articles.value.map((existing) => replaceArticle(updated, article)
existing.id === updated.id ? updated : existing,
)
toast.add(() => ({ toast.add(() => ({
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`, id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
color: 'success', color: 'success',
@@ -209,7 +352,7 @@ async function setOnline(article: Article, makeOnline: boolean) {
})) }))
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 404) { if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id) removeArticle(article)
toast.add(() => notFoundToast(article)) toast.add(() => notFoundToast(article))
return return
} }
@@ -238,22 +381,26 @@ async function setOnline(article: Article, makeOnline: boolean) {
} }
} }
function findItem(id: number) {
return items.value.find((existing) => existing.id === id)
}
function handlePublish(id: number) { function handlePublish(id: number) {
const article = articles.value.find((existing) => existing.id === id) const article = findItem(id)
if (article) { if (article) {
setOnline(article, true) setOnline(article, true)
} }
} }
function handleUnpublish(id: number) { function handleUnpublish(id: number) {
const article = articles.value.find((existing) => existing.id === id) const article = findItem(id)
if (article) { if (article) {
setOnline(article, false) setOnline(article, false)
} }
} }
async function handleCopyUrl(id: number) { async function handleCopyUrl(id: number) {
const article = articles.value.find((existing) => existing.id === id) const article = findItem(id)
if (!article) { if (!article) {
return return
} }
@@ -295,7 +442,7 @@ function deleteDescription(article: Article) {
async function performDelete(article: Article) { async function performDelete(article: Article) {
try { try {
await request(`/v1/article/${article.id}`, { method: 'DELETE' }) await request(`/v1/article/${article.id}`, { method: 'DELETE' })
removeArticle(article.id) removeArticle(article)
toast.add(() => ({ toast.add(() => ({
id: `article-deleted-${article.id}`, id: `article-deleted-${article.id}`,
color: 'success', color: 'success',
@@ -309,7 +456,7 @@ async function performDelete(article: Article) {
})) }))
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 404) { if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id) removeArticle(article)
toast.add(() => notFoundToast(article)) toast.add(() => notFoundToast(article))
return return
} }
@@ -331,7 +478,7 @@ async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) {
} }
async function handleDelete(article: Article) { async function handleDelete(article: Article) {
const idsAtOpen = displayedArticles.value.map((existing) => existing.id) const idsAtOpen = items.value.map((existing) => existing.id)
const confirmed = await overlay const confirmed = await overlay
.create(ConfirmDialog, { .create(ConfirmDialog, {
props: { props: {
@@ -369,9 +516,9 @@ function onArticleCreated(article: Article) {
} }
function clearSearch() { function clearSearch() {
clearSearchTimer()
searchInput.value = '' searchInput.value = ''
debouncedSearch.value = '' changeSearch('')
router.replace({ query: { ...route.query, q: undefined } })
nextTick(() => { nextTick(() => {
document.querySelector<HTMLInputElement>('input[type="search"]')?.focus() document.querySelector<HTMLInputElement>('input[type="search"]')?.focus()
}) })
@@ -389,7 +536,13 @@ function onEmptyAction() {
openNewArticleModal() openNewArticleModal()
} }
onMounted(loadArticles) onMounted(() => load(shown.value, 'replace'))
onBeforeUnmount(() => {
requestToken++
clearSearchTimer()
clearSkeletonTimer()
})
</script> </script>
<template> <template>
@@ -436,26 +589,59 @@ onMounted(loadArticles)
/> />
</div> </div>
<template v-if="!loading || showSkeleton"> <UAlert
v-if="notice"
:role="notice.kind === 'error' ? 'alert' : 'status'"
:color="notice.kind === 'error' ? 'error' : 'info'"
variant="soft"
orientation="horizontal"
:close="false"
:icon="notice.kind === 'error' ? 'i-lucide-circle-x' : 'i-lucide-info'"
:description="noticeText"
:ui="{
root: 'items-center gap-2.5 px-3.5 py-2.5',
icon: 'size-4',
description: 'text-toned',
}"
>
<template v-if="notice.kind === 'error'" #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="retryNotice"
>
{{ t('Retry') }}
</UButton>
</template>
</UAlert>
<template v-if="hasLoaded || loadFailed || showSkeleton">
<EmptyState <EmptyState
v-if="emptyVariant" v-if="emptyVariant"
:variant="emptyVariant" :variant="emptyVariant"
:query="debouncedSearch" :query="shown.q"
@action="onEmptyAction" @action="onEmptyAction"
/> />
<template v-else> <template v-else>
<ArticleTable <ArticleTable
:articles="displayedArticles" :articles="items"
:loading="loading && showSkeleton" :loading="skeletonActive"
:skeleton-rows="skeletonRows"
:sort="effectiveSort(shown.sort)"
:busy-ids="busyIds" :busy-ids="busyIds"
@sort="changeSort"
@publish="handlePublish" @publish="handlePublish"
@unpublish="handleUnpublish" @unpublish="handleUnpublish"
@delete="handleDelete" @delete="handleDelete"
@copy-url="handleCopyUrl" @copy-url="handleCopyUrl"
/> />
<ArticleCardList <ArticleCardList
:articles="displayedArticles" :articles="items"
:loading="loading && showSkeleton" :loading="skeletonActive"
:skeleton-rows="skeletonRows"
:busy-ids="busyIds" :busy-ids="busyIds"
@publish="handlePublish" @publish="handlePublish"
@unpublish="handleUnpublish" @unpublish="handleUnpublish"
@@ -464,6 +650,17 @@ onMounted(loadArticles)
/> />
</template> </template>
</template> </template>
<AppPaginator
v-if="showPaginator"
:page="shown.page"
:page-count="pageCount"
:total="total"
:size="shown.size"
:loading-page="pendingPage"
@update:page="changePage"
@update:size="changeSize"
/>
</div> </div>
<UButton <UButton
+17 -19
View File
@@ -9,7 +9,7 @@ import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import NewArticleModal from '@/components/NewArticleModal.vue' import NewArticleModal from '@/components/NewArticleModal.vue'
import RecentActivityCard from '@/components/RecentActivityCard.vue' import RecentActivityCard from '@/components/RecentActivityCard.vue'
import UpdateBanner from '@/components/UpdateBanner.vue' import UpdateBanner from '@/components/UpdateBanner.vue'
import { useArticles, type Article } from '@/composables/useArticles' import { useArticles, type Article, type ArticleCounts } from '@/composables/useArticles'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import { useUpdateStore } from '@/stores/update' import { useUpdateStore } from '@/stores/update'
import { accountName } from '@/lib/accountName' import { accountName } from '@/lib/accountName'
@@ -55,14 +55,13 @@ const subtitle = computed(() =>
: t("Here's where things stand on your site."), : t("Here's where things stand on your site."),
) )
const stats = computed(() => { const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
const online = articles.value.filter((article) => article.is_online).length
return { const stats = computed(() => ({
total: articles.value.length, total: counts.value.all,
online, online: counts.value.online,
drafts: articles.value.length - online, drafts: counts.value.draft,
} }))
})
const statTiles = computed(() => [ const statTiles = computed(() => [
{ {
@@ -85,12 +84,6 @@ const statTiles = computed(() => [
}, },
]) ])
const recentArticles = computed(() =>
[...articles.value]
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.slice(0, RECENT_LIST_SIZE),
)
function clearSkeletonTimer() { function clearSkeletonTimer() {
if (skeletonTimer !== null) { if (skeletonTimer !== null) {
clearTimeout(skeletonTimer) clearTimeout(skeletonTimer)
@@ -107,9 +100,14 @@ async function loadArticles() {
}, SKELETON_DELAY_MS) }, SKELETON_DELAY_MS)
try { try {
const result = await listArticles() const result = await listArticles({
articles.value = result page: 1,
loadState.value = result.length === 0 ? 'empty' : 'default' size: RECENT_LIST_SIZE,
sort: 'updated_at:desc',
})
articles.value = result.items
counts.value = result.counts
loadState.value = result.counts.all === 0 ? 'empty' : 'default'
} catch { } catch {
loadState.value = 'error' loadState.value = 'error'
} finally { } finally {
@@ -263,7 +261,7 @@ onMounted(loadArticles)
data-testid="recent-list" data-testid="recent-list"
class="border border-default bg-default" class="border border-default bg-default"
> >
<li v-for="article in recentArticles" :key="article.id"> <li v-for="article in articles" :key="article.id">
<RouterLink <RouterLink
:to="`/article/${article.id}/edit`" :to="`/article/${article.id}/edit`"
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11" class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
+30 -21
View File
@@ -6,6 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles' import type { Article } from '@/composables/useArticles'
import ArticleView from '../ArticleView.vue' import ArticleView from '../ArticleView.vue'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
const NOW = new Date('2026-08-26T10:00:00Z') const NOW = new Date('2026-08-26T10:00:00Z')
const HOUR_MS = 60 * 60 * 1000 const HOUR_MS = 60 * 60 * 1000
@@ -192,7 +193,7 @@ afterEach(async () => {
describe('Default — the list loads sorted by last updated, newest first', () => { describe('Default — the list loads sorted by last updated, newest first', () => {
it('shows the breadcrumb, tab counts and every row in fixture order', async () => { it('shows the breadcrumb, tab counts and every row in fixture order', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), [articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
}) })
expect(wrapper.text()).toContain('All 6') expect(wrapper.text()).toContain('All 6')
@@ -218,9 +219,10 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
it('shows skeleton rows and aria-busy, then replaces them once resolved', async () => { it('shows skeleton rows and aria-busy, then replaces them once resolved', async () => {
let resolveList: (() => void) | null = null let resolveList: (() => void) | null = null
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => [articleListRoute()]: () =>
new Promise((resolve) => { new Promise((resolve) => {
resolveList = () => resolve(mockResponse(200, sixArticleFixture())) resolveList = () =>
resolve(mockResponse(200, articleEnvelope(sixArticleFixture())))
}), }),
}) })
@@ -239,7 +241,7 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
describe('Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…', () => { describe('Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…', () => {
it('opens a menu named "Actions for Winter checklist for timber windows"', async () => { it('opens a menu named "Actions for Winter checklist for timber windows"', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), [articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
}) })
await openRowMenu(wrapper, 'Winter checklist for timber windows') await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -260,7 +262,7 @@ describe('Copy API URL succeeds — the absolute URL is copied and confirmed', (
}) })
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), [articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
}) })
await openRowMenu(wrapper, 'Winter checklist for timber windows') await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -287,7 +289,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
}) })
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), [articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
}) })
await openRowMenu(wrapper, 'Winter checklist for timber windows') await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -305,7 +307,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
describe('Empty — zero articles', () => { describe('Empty — zero articles', () => {
it('shows the no-articles empty state and opens NewArticleModal', async () => { it('shows the no-articles empty state and opens NewArticleModal', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, []), [articleListRoute()]: () => mockResponse(200, articleEnvelope([])),
}) })
expect(wrapper.text()).toContain('All 0') expect(wrapper.text()).toContain('All 0')
@@ -326,7 +328,7 @@ describe('Empty — zero articles', () => {
describe('Initial load failure — an error must not render the no-articles empty state', () => { describe('Initial load failure — an error must not render the no-articles empty state', () => {
it('does not show "No articles yet" when GET /v1/article/ fails', async () => { it('does not show "No articles yet" when GET /v1/article/ fails', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(500), [articleListRoute()]: () => mockResponse(500),
}) })
expect(wrapper.text()).not.toContain('No articles yet') expect(wrapper.text()).not.toContain('No articles yet')
@@ -335,8 +337,11 @@ describe('Initial load failure — an error must not render the no-articles empt
describe('No results — a search matches nothing, but the tab counts still show the full list', () => { describe('No results — a search matches nothing, but the tab counts still show the full list', () => {
it('shows the no-results empty state, then clears the search', async () => { it('shows the no-results empty state, then clears the search', async () => {
const all = sixArticleFixture()
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), [articleListRoute()]: () => mockResponse(200, articleEnvelope(all)),
[articleListRoute({ q: 'tiles' })]: () =>
mockResponse(200, articleEnvelope(all, { q: 'tiles' })),
}) })
const searchInput = wrapper.find('input[type="search"]') const searchInput = wrapper.find('input[type="search"]')
@@ -367,7 +372,9 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
.slice(0, 4) .slice(0, 4)
.map((a) => ({ ...a, is_online: true })) .map((a) => ({ ...a, is_online: true }))
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, onlineOnly), [articleListRoute()]: () => mockResponse(200, articleEnvelope(onlineOnly)),
[articleListRoute({ status: 'draft' })]: () =>
mockResponse(200, articleEnvelope(onlineOnly, { status: 'draft' })),
}) })
await clickTab('Drafts 0') await clickTab('Drafts 0')
@@ -384,7 +391,9 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
.slice(0, 4) .slice(0, 4)
.map((a) => ({ ...a, is_online: false })) .map((a) => ({ ...a, is_online: false }))
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, draftsOnly), [articleListRoute()]: () => mockResponse(200, articleEnvelope(draftsOnly)),
[articleListRoute({ status: 'online' })]: () =>
mockResponse(200, articleEnvelope(draftsOnly, { status: 'online' })),
}) })
await clickTab('Online 0') await clickTab('Online 0')
@@ -400,7 +409,7 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
describe('Action in progress — publishing a row disables only that row', () => { describe('Action in progress — publishing a row disables only that row', () => {
it('sends the PATCH and shows the busy label for that row alone', async () => { it('sends the PATCH and shows the busy label for that row alone', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), [articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': () => new Promise(() => {}), 'PATCH /v1/article/4/publish?format=html': () => new Promise(() => {}),
}) })
@@ -420,7 +429,7 @@ describe('Published — a 200 updates the row in place and raises a toast', () =
updated_at: iso(0), updated_at: iso(0),
} }
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), [articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': () => mockResponse(200, updated), 'PATCH /v1/article/4/publish?format=html': () => mockResponse(200, updated),
}) })
@@ -448,7 +457,7 @@ describe('Action failed, publish — a 500 or a network error leaves the row unc
])('shows Retry for $name and re-sends the same request', async ({ handler }) => { ])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler) const patchCall = vi.fn(handler)
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), [articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': patchCall, 'PATCH /v1/article/4/publish?format=html': patchCall,
}) })
@@ -487,7 +496,7 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
])('shows Retry for $name and re-sends the same request', async ({ handler }) => { ])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler) const patchCall = vi.fn(handler)
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), [articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/3/unpublish?format=html': patchCall, 'PATCH /v1/article/3/unpublish?format=html': patchCall,
}) })
@@ -517,7 +526,7 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
describe('Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast', () => { describe('Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast', () => {
it('removes the row on a 404 publish response', async () => { it('removes the row on a 404 publish response', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), [articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/2/publish?format=html': () => mockResponse(404), 'PATCH /v1/article/2/publish?format=html': () => mockResponse(404),
}) })
@@ -540,7 +549,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
it('removes the row when the delete confirmation resolves 404', async () => { it('removes the row when the delete confirmation resolves 404', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), [articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/2': () => mockResponse(404), 'DELETE /v1/article/2': () => mockResponse(404),
}) })
@@ -565,7 +574,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
describe('Row vanished, online row — a 404 on unpublish removes the row and shows an info toast', () => { describe('Row vanished, online row — a 404 on unpublish removes the row and shows an info toast', () => {
it('removes the row and updates the tab counts', async () => { it('removes the row and updates the tab counts', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), [articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/3/unpublish?format=html': () => mockResponse(404), 'PATCH /v1/article/3/unpublish?format=html': () => mockResponse(404),
}) })
@@ -590,7 +599,7 @@ describe('Row vanished, online row — a 404 on unpublish removes the row and sh
describe('Delete confirm — opens the dialog with the row title and image count', () => { describe('Delete confirm — opens the dialog with the row title and image count', () => {
it('opens ConfirmDialog with the title and 3 images', async () => { it('opens ConfirmDialog with the title and 3 images', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), [articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
}) })
await openRowMenu(wrapper, 'Winter checklist for timber windows') await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -611,7 +620,7 @@ describe('Delete confirm — opens the dialog with the row title and image count
describe('Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row', () => { describe('Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row', () => {
it('removes the row, recomputes tabs and raises the success toast', async () => { it('removes the row, recomputes tabs and raises the success toast', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), [articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/4': () => mockResponse(204), 'DELETE /v1/article/4': () => mockResponse(204),
}) })
@@ -640,7 +649,7 @@ describe('Deleted — a 204 closes the dialog, removes the row, and moves focus
describe('Delete failed — a 500 keeps the article and rejects with the inline message', () => { describe('Delete failed — a 500 keeps the article and rejects with the inline message', () => {
it('throws the inline error message and leaves the article in the list', async () => { it('throws the inline error message and leaves the article in the list', async () => {
const wrapper = await mountArticles({ const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()), [articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/4': () => mockResponse(500), 'DELETE /v1/article/4': () => mockResponse(500),
}) })
@@ -0,0 +1,703 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { setLocale } from '@/i18n'
import { useSessionStore } from '@/stores/session'
import type { Article, ArticlePage } from '@/composables/useArticles'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ArticleView from '../ArticleView.vue'
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
const TOTAL = 312
const PLASTER_EVERY = 20
function createOverlayMock() {
let resolveOpen: ((value: unknown) => void) | null = null
let props: Record<string, unknown> | null = null
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
})
return {
create,
getProps: () => props,
resolveOpen: (value: unknown) => resolveOpen?.(value),
}
}
let overlayMock: ReturnType<typeof createOverlayMock>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useOverlay: () => overlayMock,
useToast: () => ({ add: vi.fn() }),
}
})
function buildArticles(count: number): Article[] {
return Array.from({ length: count }, (_, index) => {
const id = index + 1
return {
id,
title: id % PLASTER_EVERY === 0 ? `Lime plaster notes ${id}` : `Article ${id}`,
body: '',
is_online: id % 3 !== 0,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-08-18T09:00:00Z',
}
})
}
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
type ListHandler = (params: EnvelopeParams, url: string) => Response | Promise<Response>
let articles: Article[]
let listCalls: EnvelopeParams[]
let listHandler: ListHandler
let deleteHandler: (id: number) => Response | Promise<Response>
let wrapper: Awaited<ReturnType<typeof mount>> | null = null
function respondFromFixture(params: EnvelopeParams) {
return mockResponse(200, articleEnvelope(articles, params))
}
function installFetch() {
global.fetch = vi.fn((url: string, init?: RequestInit) => {
const method = init?.method ?? 'GET'
if (method === 'GET' && url.startsWith('/v1/article/?')) {
const params = parseListUrl(url)
listCalls.push(params)
return Promise.resolve(listHandler(params, url))
}
const deleted = url.match(/^\/v1\/article\/(\d+)$/)
if (method === 'DELETE' && deleted) {
return Promise.resolve(deleteHandler(Number(deleted[1])))
}
throw new Error(`Unexpected fetch call: ${method} ${url}`)
}) as unknown as typeof fetch
}
async function mountAt(path: string) {
setActivePinia(createPinia())
overlayMock = createOverlayMock()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
await router.push(path)
wrapper = mount(ArticleView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
function summary() {
return document.body.querySelector('[data-testid="paginator-summary"]')?.textContent?.trim()
}
function control(label: string) {
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
}
function pageButtons() {
return Array.from(
document.body.querySelectorAll<HTMLButtonElement>('nav[aria-label="Pagination"] button'),
).filter((button) => /^\d+$/.test(button.textContent?.trim() ?? ''))
}
function rowIds() {
return new Set(
Array.from(document.body.querySelectorAll('[data-row-title]')).map((row) =>
row.getAttribute('data-row-title'),
),
)
}
function paginatorControls() {
return Array.from(
document.body.querySelectorAll<HTMLButtonElement>('nav[aria-label="Pagination"] button'),
)
}
async function click(button: HTMLElement | null | undefined) {
button?.click()
await flushPromises()
}
function alertText() {
return document.body.querySelector('[role="alert"], [role="status"]')?.textContent ?? ''
}
function pageSizeTrigger() {
return document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')
}
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
})
beforeEach(() => {
articles = buildArticles(TOTAL)
listCalls = []
listHandler = (params) => respondFromFixture(params)
deleteHandler = () => mockResponse(204)
setViewportWidth(DESKTOP_WIDTH)
installFetch()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
vi.restoreAllMocks()
await router.push('/login')
})
describe('French caption', () => {
it('renders the range summary in French and restores English', async () => {
setLocale('fr')
try {
await mountAt('/article')
expect(summary()).toBe('Affichage de 1 à 25 sur 312')
} finally {
setLocale('en')
}
})
})
describe('1 First page', () => {
it('shows the first 25 of 312 with First and Previous disabled and no page or size in the URL', async () => {
await mountAt('/article')
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(summary()).toBe('Showing 1–25 of 312')
expect(rowIds().size).toBe(25)
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(pageButtons()[0]?.getAttribute('aria-current')).toBe('page')
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
describe('Disabled controls', () => {
it('carry the 40 % opacity class on every paginator button', async () => {
await mountAt('/article')
const disabled = paginatorControls().filter((button) => button.disabled)
expect(disabled.length).toBeGreaterThan(1)
for (const button of disabled) {
expect(button.className).toContain('disabled:opacity-40')
}
})
it('carry it on the mobile Previous and Next buttons', async () => {
setViewportWidth(MOBILE_WIDTH)
await mountAt('/article')
expect(control('Previous page')?.disabled).toBe(true)
expect(control('Previous page')?.className).toContain('disabled:opacity-40')
expect(control('Next page')?.className).toContain('disabled:opacity-40')
})
})
describe('2 Middle page', () => {
it('shows page 7 as current with ellipses and every control enabled', async () => {
await mountAt('/article?page=7')
expect(summary()).toBe('Showing 151–175 of 312')
const current = pageButtons().filter((b) => b.getAttribute('aria-current') === 'page')
expect(current.map((b) => b.textContent?.trim())).toEqual(['7'])
expect(current[0]?.className).toContain('bg-(--rc-sand)')
expect(current[0]?.className).toContain('text-(--rc-on-sand)')
expect(current[0]?.className).toContain('border-primary')
expect(document.body.querySelector('nav[aria-label="Pagination"]')?.textContent).toContain(
'…',
)
expect(paginatorControls().every((button) => !button.disabled)).toBe(true)
})
})
describe('3 Last page', () => {
it('shows 301–312 of 312 with Next and Last disabled', async () => {
await mountAt('/article?page=13')
expect(summary()).toBe('Showing 301–312 of 312')
expect(rowIds().size).toBe(12)
expect(control('Next page')?.disabled).toBe(true)
expect(control('Last page')?.disabled).toBe(true)
expect(control('Previous page')?.disabled).toBe(false)
})
})
describe('4 Single page', () => {
it('keeps the summary and the page-size select, hides the paginator and reads the tab counts', async () => {
articles = buildArticles(6).map((article, index) => ({ ...article, is_online: index < 4 }))
const wrapper = await mountAt('/article')
expect(summary()).toBe('Showing 1–6 of 6')
expect(document.body.querySelector('nav[aria-label="Pagination"]')).toBeNull()
expect(pageSizeTrigger()).not.toBeNull()
expect(wrapper.text()).toContain('All 6')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 2')
})
})
describe('5 Loading next page', () => {
it('shows skeleton rows, disables every control, and moves the URL only once the response arrives', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountAt('/article')
let resolvePage2: (() => void) | null = null
listHandler = (params) =>
new Promise((resolve) => {
resolvePage2 = () => resolve(respondFromFixture(params))
})
await click(control('Next page'))
await vi.advanceTimersByTimeAsync(150)
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(summary()).toBe('Loading page 2…')
expect(paginatorControls().every((button) => button.disabled)).toBe(true)
expect(router.currentRoute.value.fullPath).toBe('/article')
resolvePage2?.()
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(summary()).toBe('Showing 26–50 of 312')
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(router.currentRoute.value.fullPath).toBe('/article?page=2')
})
})
describe.each([
['resolves with a 500 response', () => mockResponse(500)],
['never reaches the server', () => Promise.reject(new TypeError('Failed to fetch'))],
])('6 Error loading a page keeps the previous rows (%s)', (_failure, failure) => {
it('keeps rows and URL, shows the notice, and Retry loads the page', async () => {
await mountAt('/article?page=6')
const previousRows = rowIds()
listHandler = () => failure() as Promise<Response>
await click(control('Next page'))
expect(rowIds()).toEqual(previousRows)
expect(router.currentRoute.value.fullPath).toBe('/article?page=6')
expect(alertText()).toContain("Couldn't load page 7. Showing page 6.")
expect(control('Next page')?.disabled).toBe(false)
listHandler = (params) => respondFromFixture(params)
const retry = Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Retry',
)
await click(retry)
expect(listCalls.at(-1)).toMatchObject({ page: 7 })
expect(summary()).toBe('Showing 151–175 of 312')
expect(router.currentRoute.value.fullPath).toBe('/article?page=7')
expect(alertText()).not.toContain("Couldn't load page")
})
})
describe('7 Page out of range redirects to the last page with a notice', () => {
it('replaces the URL with the last page and tells the user', async () => {
const push = vi.spyOn(router, 'push')
const replace = vi.spyOn(router, 'replace')
await mountAt('/article?page=99')
expect(listCalls.map((call) => call.page)).toEqual([99, 13])
expect(replace).toHaveBeenCalledWith({ query: { page: '13' } })
expect(push).toHaveBeenCalledTimes(1)
expect(router.currentRoute.value.fullPath).toBe('/article?page=13')
expect(alertText()).toContain("That page doesn't exist. Showing the last page.")
expect(summary()).toBe('Showing 301–312 of 312')
})
})
describe('7a A shrinking server cannot loop the last-page redirect', () => {
it('redirects once and then settles', async () => {
let calls = 0
listHandler = (params) => {
calls += 1
return mockResponse(200, {
...articleEnvelope(articles, params),
items: [],
total: 25 * (30 - calls),
})
}
await mountAt('/article?page=99')
expect(listCalls.map((call) => call.page)).toEqual([99, 29])
})
})
describe('7b Out of range with total 0 shows the empty state, no redirect', () => {
it('shows No articles yet and an /article URL', async () => {
articles = []
const wrapper = await mountAt('/article?page=5')
expect(listCalls).toHaveLength(1)
expect(wrapper.text()).toContain('No articles yet')
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
describe('8 Search resets to page 1', () => {
it('sends q with page 1, updates the URL on success and leaves the tab counts alone', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article?page=7')
listCalls = []
await wrapper.find('input[type="search"]').setValue('plaster')
await vi.advanceTimersByTimeAsync(119)
expect(listCalls).toHaveLength(0)
await vi.advanceTimersByTimeAsync(1)
await flushPromises()
expect(listCalls).toEqual([{ page: 1, size: 25, q: 'plaster', status: '', sort: '' }])
expect(router.currentRoute.value.fullPath).toBe('/article?q=plaster')
expect(summary()).toBe('Showing 1–15 of 15')
expect(wrapper.text()).toContain('All 312')
expect(wrapper.text()).toContain('Online 208')
expect(wrapper.text()).toContain('Drafts 104')
})
})
describe('9 Empty after search', () => {
it('hides the paginator and the summary and keeps the tab counts', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article')
await wrapper.find('input[type="search"]').setValue('tiles')
await vi.advanceTimersByTimeAsync(120)
await flushPromises()
expect(wrapper.text()).toContain('No articles match "tiles"')
expect(wrapper.text()).toContain(
'Check the spelling, or search for a shorter part of the title.',
)
expect(wrapper.text()).toContain('Clear search')
expect(summary()).toBeUndefined()
expect(document.body.querySelector('nav[aria-label="Pagination"]')).toBeNull()
expect(wrapper.text()).toContain('All 312')
expect(wrapper.text()).toContain('Online 208')
expect(wrapper.text()).toContain('Drafts 104')
})
})
describe('10 Page size menu', () => {
it.each([
['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'],
])('choosing "%s" resets to page 1 and ends on %s', async (label, size, url) => {
await mountAt('/article?page=3')
listCalls = []
pageSizeTrigger()?.click()
await flushPromises()
const option = Array.from(document.body.querySelectorAll('[role="option"]')).find(
(element) => element.textContent?.trim() === label,
)
expect(option).toBeTruthy()
;(option as HTMLElement).click()
await flushPromises()
expect(listCalls.at(-1)).toMatchObject({ page: 1, size })
expect(router.currentRoute.value.fullPath).toBe(url)
})
})
describe('10b Invalid URL values fall back', () => {
it('requests page 1 and size 25 and replaces the URL', async () => {
await mountAt('/article?size=10&page=abc')
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
describe('11 Focus-visible', () => {
it('gives every paginator control and the select the 2 px primary outline with a 2 px offset', async () => {
await mountAt('/article?page=7')
const controls = [...paginatorControls(), pageSizeTrigger()]
expect(controls.length).toBeGreaterThan(5)
for (const element of controls) {
expect(element?.className).toContain('focus-visible:outline-2')
expect(element?.className).toContain('focus-visible:outline-offset-2')
expect(element?.className).toContain('focus-visible:outline-primary')
}
})
})
describe('Keyboard, the page-size list', () => {
it('opens on ArrowDown, selects on Enter and closes on Escape', async () => {
await mountAt('/article')
const trigger = pageSizeTrigger()!
trigger.focus()
trigger.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).not.toBeNull()
const focused = document.activeElement ?? document.body
focused.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
})
})
describe('Tabs filter on the server', () => {
it('sends status=draft with page 1, updates the URL on success and keeps the counts', async () => {
const wrapper = await mountAt('/article?page=3')
listCalls = []
const tab = Array.from(document.body.querySelectorAll('button')).find((button) =>
button.textContent?.trim().startsWith('Drafts'),
)
tab?.focus()
tab?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: 'draft', sort: '' }])
expect(router.currentRoute.value.fullPath).toBe('/article?status=draft')
expect(wrapper.text()).toContain('All 312')
expect(wrapper.text()).toContain('Online 208')
expect(wrapper.text()).toContain('Drafts 104')
})
})
describe('Mobile, Previous, "Page N of M", Next', () => {
it('shows three controls and no page numbers below md', async () => {
setViewportWidth(MOBILE_WIDTH)
await mountAt('/article?page=2')
const nav = document.body.querySelector('nav[aria-label="Pagination"]')!
expect(nav.textContent).toContain('Previous')
expect(nav.textContent).toContain('Page 2 of 13')
expect(nav.textContent).toContain('Next')
expect(pageButtons()).toHaveLength(0)
expect(nav.querySelectorAll('button')).toHaveLength(2)
})
})
describe('Deleting the last row of the last page', () => {
it('reloads the previous page and recomputes the counts from the new response', async () => {
articles = buildArticles(26)
await mountAt('/article?page=2')
expect(summary()).toBe('Showing 26–26 of 26')
listCalls = []
const menu = document.body.querySelector<HTMLButtonElement>(
'button[aria-label="Actions for Article 26"]',
)
menu?.click()
await flushPromises()
const remove = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(item) => item.textContent?.trim() === 'Delete…',
) as HTMLElement
remove.click()
await flushPromises()
const action = overlayMock.getProps()?.action as () => Promise<void>
articles = buildArticles(25)
await action()
overlayMock.resolveOpen(true)
await flushPromises()
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(router.currentRoute.value.fullPath).toBe('/article')
expect(summary()).toBe('Showing 1–25 of 25')
expect(document.body.textContent).toContain('All 25')
})
})
describe('The envelope is the only shape read', () => {
it('renders rows from items', async () => {
const page: ArticlePage = articleEnvelope(buildArticles(3))
listHandler = () => mockResponse(200, page)
const wrapper = await mountAt('/article')
expect(wrapper.text()).toContain('Article 1')
expect(wrapper.text()).toContain('Article 3')
})
})
function headerButton(label: string) {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('th button')).find(
(button) => button.textContent?.trim() === label,
)
}
function headerArrow(label: string) {
const direction = headerButton(label)?.querySelector('[data-sort-direction]')
const value = direction?.getAttribute('data-sort-direction')
if (value === 'asc') {
return 'up'
}
return value === 'desc' ? 'down' : 'none'
}
describe('8a Sort headers, server-side, reset to page 1', () => {
it.each([
['Title', 'title:asc'],
['Status', 'status:asc'],
['Created', 'created_at:asc'],
['Updated', 'updated_at:asc'],
])('the first click on %s sends sort=%s and page 1', async (label, sort) => {
const wrapper = await mountAt('/article?page=7')
listCalls = []
await click(headerButton(label))
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort }])
expect(router.currentRoute.value.fullPath).toBe(`/article?sort=${sort}`)
expect(headerArrow(label)).toBe('up')
expect(wrapper.text()).toContain('All 312')
})
it('keeps the URL unchanged until the response arrives', async () => {
await mountAt('/article?page=7')
listHandler = () => new Promise(() => undefined)
await click(headerButton('Title'))
expect(router.currentRoute.value.fullPath).toBe('/article?page=7')
})
})
describe('8b Toggling the sorted header', () => {
it('toggles to descending, and returning to updated_at:desc drops sort from the URL', async () => {
await mountAt('/article?sort=title:asc')
listCalls = []
await click(headerButton('Title'))
expect(listCalls.at(-1)).toMatchObject({ sort: 'title:desc', page: 1 })
expect(router.currentRoute.value.fullPath).toBe('/article?sort=title:desc')
expect(headerArrow('Title')).toBe('down')
await click(headerButton('Updated'))
await click(headerButton('Updated'))
expect(listCalls.at(-1)).toMatchObject({ sort: 'updated_at:desc' })
expect(router.currentRoute.value.fullPath).toBe('/article')
expect(headerArrow('Updated')).toBe('down')
})
})
describe('8c Sort is kept across paging, page size and search', () => {
it('carries sort on Next and on a search, and the URL keeps it', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article?sort=title:asc')
listCalls = []
await click(control('Next page'))
expect(listCalls.at(-1)).toMatchObject({ page: 2, sort: 'title:asc' })
expect(router.currentRoute.value.query).toMatchObject({ page: '2', sort: 'title:asc' })
await wrapper.find('input[type="search"]').setValue('plaster')
await vi.advanceTimersByTimeAsync(120)
await flushPromises()
expect(listCalls.at(-1)).toMatchObject({ page: 1, q: 'plaster', sort: 'title:asc' })
expect(router.currentRoute.value.query).toMatchObject({ q: 'plaster', sort: 'title:asc' })
expect(router.currentRoute.value.query.page).toBeUndefined()
})
})
describe('8d A sorted URL on load', () => {
it('sends the sort and shows the Created arrow-down', async () => {
await mountAt('/article?sort=created_at:desc&page=2')
expect(listCalls).toEqual([
{ page: 2, size: 25, q: '', status: '', sort: 'created_at:desc' },
])
expect(headerArrow('Created')).toBe('down')
})
it('falls back to the default for an invalid sort and replaces the URL', async () => {
await mountAt('/article?sort=body:asc')
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(headerArrow('Updated')).toBe('down')
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
describe('8e No client-side re-sorting', () => {
it('renders the rows in exactly the order the server returned', async () => {
listHandler = (params) => {
const page = articleEnvelope(articles, params)
return mockResponse(200, { ...page, items: [...page.items].reverse() })
}
await mountAt('/article?sort=title:asc')
const rendered = Array.from(document.body.querySelectorAll('table [data-row-title]')).map(
(row) => Number(row.getAttribute('data-row-title')),
)
const expected = articleEnvelope(articles, { sort: 'title:asc' })
.items.map((article) => article.id)
.reverse()
expect(rendered).toEqual(expected)
})
})
describe('8f Caption follows the sort', () => {
it('reads the default caption, then the sorted one', async () => {
await mountAt('/article')
expect(document.body.querySelector('caption')?.textContent?.trim()).toBe(
'Articles, sorted by last updated, newest first',
)
await click(headerButton('Title'))
expect(document.body.querySelector('caption')?.textContent?.trim()).toBe(
'Articles, sorted by title, ascending',
)
expect(document.body.querySelector('th[aria-sort="ascending"]')?.textContent).toContain(
'Title',
)
})
})
describe('8g Mobile card list', () => {
it('has no sort control', async () => {
await mountAt('/article')
expect(document.body.querySelectorAll('ul button[aria-sort], ul [aria-sort]')).toHaveLength(
0,
)
expect(
Array.from(document.body.querySelectorAll('ul button')).filter((button) =>
/^(Title|Status|Created|Updated)$/.test(button.textContent?.trim() ?? ''),
),
).toHaveLength(0)
})
})
@@ -8,9 +8,10 @@ import { useSessionStore } from '@/stores/session'
import type { ActivityItem } from '@/lib/activity' import type { ActivityItem } from '@/lib/activity'
import HomeView from '../HomeView.vue' import HomeView from '../HomeView.vue'
import { anonymous, makeItem, target } from './activityFixtures' import { anonymous, makeItem, target } from './activityFixtures'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
const ACTIVITY_ROUTE = 'GET /v1/activity?size=5' const ACTIVITY_ROUTE = 'GET /v1/activity?size=5'
const ARTICLES_ROUTE = 'GET /v1/article/?format=html' const ARTICLES_ROUTE = articleListRoute({ size: 5, sort: 'updated_at:desc' })
type Handler = () => Response | Promise<Response> type Handler = () => Response | Promise<Response>
@@ -95,7 +96,7 @@ describe('Home, owner or admin, the last five events with the actor name', () =>
it.each(['owner', 'admin'] as const)('shows five rows with names for %s', async (role) => { it.each(['owner', 'admin'] as const)('shows five rows with names for %s', async (role) => {
await mountHome(role, { await mountHome(role, {
[ACTIVITY_ROUTE]: () => page(recentItems(5)), [ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(200, articles), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
}) })
expect(card().find('h2').text()).toBe('Recent activity') expect(card().find('h2').text()).toBe('Recent activity')
@@ -125,7 +126,7 @@ describe('Home, regular user, own events only and no actor name', () => {
it.each([['editor' as const], [undefined]])('shows only the time for %s', async (role) => { it.each([['editor' as const], [undefined]])('shows only the time for %s', async (role) => {
await mountHome(role as undefined, { await mountHome(role as undefined, {
[ACTIVITY_ROUTE]: () => page(recentItems(3)), [ACTIVITY_ROUTE]: () => page(recentItems(3)),
[ARTICLES_ROUTE]: () => response(200, articles), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
}) })
expect(card().find('h2').text()).toBe('Your recent activity') expect(card().find('h2').text()).toBe('Your recent activity')
@@ -146,7 +147,7 @@ describe('Home rows, failure and security markers', () => {
] ]
await mountHome('owner', { await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items), [ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articles), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
}) })
const marks = rows().map((row) => ({ const marks = rows().map((row) => ({
@@ -177,7 +178,7 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
] ]
await mountHome('owner', { await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items), [ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articles), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
}) })
expect(rows()[0]!.text()).toContain('article.archive') expect(rows()[0]!.text()).toContain('article.archive')
@@ -188,7 +189,7 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
it('has no button or link inside a row and keeps the route on a click', async () => { it('has no button or link inside a row and keeps the route on a click', async () => {
await mountHome('owner', { await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(2)), [ACTIVITY_ROUTE]: () => page(recentItems(2)),
[ARTICLES_ROUTE]: () => response(200, articles), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
}) })
const row = rows()[0]! const row = rows()[0]!
@@ -202,7 +203,7 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
const items = [makeItem({ id: 1, target: target('article', '8', 'T'.repeat(150)) })] const items = [makeItem({ id: 1, target: target('article', '8', 'T'.repeat(150)) })]
await mountHome('owner', { await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items), [ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articles), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
}) })
expect(rows()[0]!.find('[data-testid="recent-activity-sentence"]').classes()).toContain( expect(rows()[0]!.find('[data-testid="recent-activity-sentence"]').classes()).toContain(
@@ -223,7 +224,7 @@ describe('Home card, skeleton rows only after 150 ms', () => {
new Promise((done) => { new Promise((done) => {
resolve = () => done(page(recentItems(5))) resolve = () => done(page(recentItems(5)))
}), }),
[ARTICLES_ROUTE]: () => response(200, articles), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
}) })
expect(card().attributes('aria-busy')).toBeUndefined() expect(card().attributes('aria-busy')).toBeUndefined()
@@ -246,7 +247,7 @@ describe('Home card, skeleton rows only after 150 ms', () => {
vi.useFakeTimers() vi.useFakeTimers()
await mountHome('owner', { await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(5)), [ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(200, articles), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
}) })
await vi.advanceTimersByTimeAsync(200) await vi.advanceTimersByTimeAsync(200)
@@ -267,7 +268,7 @@ describe('Home card, error shows a retryable alert that leaves the page alone',
attempt += 1 attempt += 1
return attempt === 1 ? (failure() as Response) : page(recentItems(2)) return attempt === 1 ? (failure() as Response) : page(recentItems(2))
}, },
[ARTICLES_ROUTE]: () => response(200, articles), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
}) })
const alert = card().find('[role="alert"]') const alert = card().find('[role="alert"]')
@@ -292,7 +293,7 @@ describe('Home card, empty when there are no events yet', () => {
])('says so for %s', async (role, text) => { ])('says so for %s', async (role, text) => {
await mountHome(role as 'owner', { await mountHome(role as 'owner', {
[ACTIVITY_ROUTE]: () => page([], 0), [ACTIVITY_ROUTE]: () => page([], 0),
[ARTICLES_ROUTE]: () => response(200, articles), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
}) })
const empty = card().find('[data-testid="recent-activity-empty"]') const empty = card().find('[data-testid="recent-activity-empty"]')
@@ -316,7 +317,7 @@ describe('Home card and article list are independent', () => {
it('shows the articles empty state and the card error together', async () => { it('shows the articles empty state and the card error together', async () => {
await mountHome('owner', { await mountHome('owner', {
[ACTIVITY_ROUTE]: () => response(500), [ACTIVITY_ROUTE]: () => response(500),
[ARTICLES_ROUTE]: () => response(200, []), [ARTICLES_ROUTE]: () => response(200, articleEnvelope([], { size: 5 })),
}) })
expect(wrapper!.text()).toContain('Write your first article') expect(wrapper!.text()).toContain('Write your first article')
@@ -328,7 +329,7 @@ describe('Home layout puts Recent activity full width below Recently updated', (
it('renders the section after the recent list inside the same single column', async () => { it('renders the section after the recent list inside the same single column', async () => {
await mountHome('owner', { await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(1)), [ACTIVITY_ROUTE]: () => page(recentItems(1)),
[ARTICLES_ROUTE]: () => response(200, articles), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
}) })
const column = card().element.parentElement! const column = card().element.parentElement!
@@ -344,7 +345,7 @@ describe('Home card, the request URL', () => {
it('asks for exactly /v1/activity?size=5 and nothing else', async () => { it('asks for exactly /v1/activity?size=5 and nothing else', async () => {
await mountHome('owner', { await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(5)), [ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(200, articles), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
}) })
const activityCalls = calls.filter((call) => call.includes('/v1/activity')) const activityCalls = calls.filter((call) => call.includes('/v1/activity'))
@@ -356,7 +357,7 @@ describe('Home card, "View all" opens the activity page', () => {
it('navigates to /activity', async () => { it('navigates to /activity', async () => {
await mountHome('owner', { await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(1)), [ACTIVITY_ROUTE]: () => page(recentItems(1)),
[ARTICLES_ROUTE]: () => response(200, articles), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
}) })
const push = vi.spyOn(router, 'push') const push = vi.spyOn(router, 'push')
@@ -373,7 +374,7 @@ describe('Home card, the French interface', () => {
setLocale('fr') setLocale('fr')
await mountHome('owner', { await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page([], 0), [ACTIVITY_ROUTE]: () => page([], 0),
[ARTICLES_ROUTE]: () => response(200, articles), [ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
}) })
expect(card().find('h2').text()).toBe('Activité récente') expect(card().find('h2').text()).toBe('Activité récente')
@@ -10,6 +10,7 @@ import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport' import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import HomeView from '../HomeView.vue' import HomeView from '../HomeView.vue'
import { NEW_TAG, newerAvailable, upToDate } from './updateTestKit' import { NEW_TAG, newerAvailable, upToDate } from './updateTestKit'
import { articleEnvelope, articleListUrl } from './articleEnvelope'
function mockResponse(status: number, body: unknown = null) { function mockResponse(status: number, body: unknown = null) {
return { return {
@@ -35,8 +36,8 @@ async function mountHome({ role, status, width = DESKTOP_WIDTH }: MountOptions)
session.session = { verified: true, username: 'marta', role } session.session = { verified: true, username: 'marta', role }
session.status = 'ready' session.status = 'ready'
global.fetch = vi.fn((url: string) => { global.fetch = vi.fn((url: string) => {
if (url === '/v1/article/?format=html') { if (url === articleListUrl({ size: 5, sort: 'updated_at:desc' })) {
return Promise.resolve(mockResponse(200, [])) return Promise.resolve(mockResponse(200, articleEnvelope([], { size: 5 })))
} }
if (url === '/v1/system/update') { if (url === '/v1/system/update') {
return Promise.resolve(mockResponse(200, status)) return Promise.resolve(mockResponse(200, status))
@@ -17,6 +17,7 @@ import {
startable, startable,
verifying, verifying,
} from './updateTestKit' } from './updateTestKit'
import { articleEnvelope, articleListUrl } from './articleEnvelope'
function mockResponse(status: number, body: unknown = null) { function mockResponse(status: number, body: unknown = null) {
return { return {
@@ -35,8 +36,8 @@ async function mountHome(role: UserRole, status: UpdateStatus) {
session.session = { verified: true, username: 'marta', role } session.session = { verified: true, username: 'marta', role }
session.status = 'ready' session.status = 'ready'
global.fetch = vi.fn((url: string) => { global.fetch = vi.fn((url: string) => {
if (url === '/v1/article/?format=html') { if (url === articleListUrl({ size: 5, sort: 'updated_at:desc' })) {
return Promise.resolve(mockResponse(200, [])) return Promise.resolve(mockResponse(200, articleEnvelope([], { size: 5 })))
} }
if (url === '/v1/system/update') { if (url === '/v1/system/update') {
return Promise.resolve(mockResponse(200, status)) return Promise.resolve(mockResponse(200, status))
+36 -23
View File
@@ -6,6 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session' import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles' import type { Article } from '@/composables/useArticles'
import HomeView from '../HomeView.vue' import HomeView from '../HomeView.vue'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
let toastAdd: ReturnType<typeof vi.fn> let toastAdd: ReturnType<typeof vi.fn>
@@ -42,6 +43,8 @@ function fetchRouter(handlers: Record<string, () => Response | Promise<Response>
}) })
} }
const HOME_ARTICLES_ROUTE = articleListRoute({ size: 5, sort: 'updated_at:desc' })
const sixArticles: Article[] = [ const sixArticles: Article[] = [
{ {
id: 1, id: 1,
@@ -140,9 +143,10 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
vi.useFakeTimers() vi.useFakeTimers()
let resolveList: (() => void) | null = null let resolveList: (() => void) | null = null
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/?format=html': () => [HOME_ARTICLES_ROUTE]: () =>
new Promise((resolve) => { new Promise((resolve) => {
resolveList = () => resolve(mockResponse(200, sixArticles)) resolveList = () =>
resolve(mockResponse(200, articleEnvelope(sixArticles, { size: 5 })))
}), }),
}) })
@@ -164,7 +168,8 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('never shows a skeleton when the response resolves in under 150 ms', async () => { it('never shows a skeleton when the response resolves in under 150 ms', async () => {
vi.useFakeTimers() vi.useFakeTimers()
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/?format=html': () => Promise.resolve(mockResponse(200, sixArticles)), [HOME_ARTICLES_ROUTE]: () =>
Promise.resolve(mockResponse(200, articleEnvelope(sixArticles, { size: 5 }))),
}) })
await vi.advanceTimersByTimeAsync(0) await vi.advanceTimersByTimeAsync(0)
@@ -178,7 +183,8 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
describe('Default — the list loaded with a mix of online and draft articles', () => { describe('Default — the list loaded with a mix of online and draft articles', () => {
it('shows the subtitle, stat tiles and the first 5 articles in order', async () => { it('shows the subtitle, stat tiles and the first 5 articles in order', async () => {
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), [HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
}) })
expect(wrapper.text()).toContain("Here's where things stand on your site.") expect(wrapper.text()).toContain("Here's where things stand on your site.")
@@ -221,7 +227,8 @@ describe('A successful create hands off to the editor with a toast', () => {
updated_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
} }
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), [HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
'POST /v1/article/?format=html': () => mockResponse(201, created), 'POST /v1/article/?format=html': () => mockResponse(201, created),
}) })
@@ -257,7 +264,7 @@ describe('A successful create hands off to the editor with a toast', () => {
describe('Empty, first run — zero articles', () => { describe('Empty, first run — zero articles', () => {
it('shows the empty state and opens NewArticleModal', async () => { it('shows the empty state and opens NewArticleModal', async () => {
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, []), [HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
}) })
expect(wrapper.text()).toContain('Welcome to RenewCMS.') expect(wrapper.text()).toContain('Welcome to RenewCMS.')
@@ -283,7 +290,7 @@ describe('Error — a 500 or a network failure shows the same inline alert', ()
}, },
])('shows the alert with identical copy for $name', async ({ handler }) => { ])('shows the alert with identical copy for $name', async ({ handler }) => {
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/?format=html': handler, [HOME_ARTICLES_ROUTE]: handler,
}) })
expect(wrapper.text()).toContain("Here's where things stand on your site.") expect(wrapper.text()).toContain("Here's where things stand on your site.")
@@ -301,9 +308,9 @@ describe('Retry re-issues the request and recovers into the Default state', () =
const listCall = vi const listCall = vi
.fn() .fn()
.mockResolvedValueOnce(mockResponse(500)) .mockResolvedValueOnce(mockResponse(500))
.mockResolvedValueOnce(mockResponse(200, sixArticles)) .mockResolvedValueOnce(mockResponse(200, articleEnvelope(sixArticles, { size: 5 })))
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/?format=html': listCall, [HOME_ARTICLES_ROUTE]: listCall,
}) })
expect(wrapper.find('[role="alert"]').exists()).toBe(true) expect(wrapper.find('[role="alert"]').exists()).toBe(true)
@@ -320,9 +327,9 @@ describe('Retry re-issues the request and recovers into the Default state', () =
describe('The list is fetched exactly once per navigation', () => { describe('The list is fetched exactly once per navigation', () => {
it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => { it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => {
const listCall = vi.fn(() => mockResponse(200, sixArticles)) const listCall = vi.fn(() => mockResponse(200, articleEnvelope(sixArticles, { size: 5 })))
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/?format=html': listCall, [HOME_ARTICLES_ROUTE]: listCall,
}) })
expect(listCall).toHaveBeenCalledTimes(1) expect(listCall).toHaveBeenCalledTimes(1)
@@ -343,7 +350,8 @@ describe('The greeting matches the hour of day', () => {
vi.useFakeTimers() vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0)) vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0))
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), [HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
}) })
expect(wrapper.text()).toContain(`${greeting}, marta`) expect(wrapper.text()).toContain(`${greeting}, marta`)
@@ -356,23 +364,24 @@ describe('The greeting is identical across every load state at a fixed hour', ()
vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0)) vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0))
const loadingWrapper = await mountHome({ const loadingWrapper = await mountHome({
'GET /v1/article/?format=html': () => new Promise(() => {}), [HOME_ARTICLES_ROUTE]: () => new Promise(() => {}),
}) })
await vi.advanceTimersByTimeAsync(150) await vi.advanceTimersByTimeAsync(150)
expect(loadingWrapper.text()).toContain('Good morning, marta') expect(loadingWrapper.text()).toContain('Good morning, marta')
const defaultWrapper = await mountHome({ const defaultWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), [HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
}) })
expect(defaultWrapper.text()).toContain('Good morning, marta') expect(defaultWrapper.text()).toContain('Good morning, marta')
const emptyWrapper = await mountHome({ const emptyWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, []), [HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
}) })
expect(emptyWrapper.text()).toContain('Good morning, marta') expect(emptyWrapper.text()).toContain('Good morning, marta')
const errorWrapper = await mountHome({ const errorWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(500), [HOME_ARTICLES_ROUTE]: () => mockResponse(500),
}) })
expect(errorWrapper.text()).toContain('Good morning, marta') expect(errorWrapper.text()).toContain('Good morning, marta')
}) })
@@ -393,7 +402,8 @@ describe('The greeting names the person by display name, else username', () => {
session.session = { verified: true, username: 'marta', displayName } session.session = { verified: true, username: 'marta', displayName }
session.status = 'ready' session.status = 'ready'
global.fetch = fetchRouter({ global.fetch = fetchRouter({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), [HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
}) as unknown as typeof fetch }) as unknown as typeof fetch
await router.push('/home') await router.push('/home')
const wrapper = mount(HomeView, { const wrapper = mount(HomeView, {
@@ -413,7 +423,8 @@ describe('The greeting names the person by display name, else username', () => {
session.session = { verified: true, username: 'marta', displayName: '' } session.session = { verified: true, username: 'marta', displayName: '' }
session.status = 'ready' session.status = 'ready'
global.fetch = fetchRouter({ global.fetch = fetchRouter({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), [HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
}) as unknown as typeof fetch }) as unknown as typeof fetch
await router.push('/home') await router.push('/home')
const wrapper = mount(HomeView, { const wrapper = mount(HomeView, {
@@ -439,7 +450,8 @@ describe('The greeting handles a missing username', () => {
session.session = { verified: true } session.session = { verified: true }
session.status = 'ready' session.status = 'ready'
global.fetch = fetchRouter({ global.fetch = fetchRouter({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), [HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
}) as unknown as typeof fetch }) as unknown as typeof fetch
await router.push('/home') await router.push('/home')
const wrapper = mount(HomeView, { const wrapper = mount(HomeView, {
@@ -460,7 +472,7 @@ describe('The "Connect your website" card renders in every state', () => {
it('renders the same card while loading, in Default, Empty and Error', async () => { it('renders the same card while loading, in Default, Empty and Error', async () => {
vi.useFakeTimers() vi.useFakeTimers()
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/?format=html': () => new Promise(() => {}), [HOME_ARTICLES_ROUTE]: () => new Promise(() => {}),
}) })
await vi.advanceTimersByTimeAsync(150) await vi.advanceTimersByTimeAsync(150)
expect(wrapper.text()).toContain('Connect your website') expect(wrapper.text()).toContain('Connect your website')
@@ -470,17 +482,18 @@ describe('The "Connect your website" card renders in every state', () => {
vi.useRealTimers() vi.useRealTimers()
const defaultWrapper = await mountHome({ const defaultWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles), [HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
}) })
expect(defaultWrapper.text()).toContain('Connect your website') expect(defaultWrapper.text()).toContain('Connect your website')
const emptyWrapper = await mountHome({ const emptyWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, []), [HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
}) })
expect(emptyWrapper.text()).toContain('Connect your website') expect(emptyWrapper.text()).toContain('Connect your website')
const errorWrapper = await mountHome({ const errorWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(500), [HOME_ARTICLES_ROUTE]: () => mockResponse(500),
}) })
expect(errorWrapper.text()).toContain('Connect your website') expect(errorWrapper.text()).toContain('Connect your website')
}) })
@@ -0,0 +1,70 @@
import type { Article, ArticlePage, ArticleStatus } from '@/composables/useArticles'
export interface EnvelopeParams {
page?: number
size?: number
q?: string
status?: ArticleStatus | ''
sort?: string
}
function fold(value: string) {
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): ArticlePage {
const { page = 1, size = 25, q = '', status = '', sort = '' } = params
const filtered = all
.filter((article) => !status || article.is_online === (status === 'online'))
.filter((article) => !q || fold(article.title).includes(fold(q)))
if (sort) {
const [name, direction] = sort.split(':') as [string, string]
const field = (name === 'status' ? 'is_online' : name) as keyof Article
filtered.sort((left, right) => {
const order = String(left[field]).localeCompare(String(right[field]))
return direction === 'desc' ? -order : order
})
}
const online = all.filter((article) => article.is_online).length
return {
items: filtered.slice((page - 1) * size, page * size),
total: filtered.length,
page,
size,
counts: { all: all.length, online, draft: all.length - online },
}
}
export function articleListUrl(params: EnvelopeParams = {}) {
const { page = 1, size = 25, q = '', status = '', sort = '' } = params
const query = new URLSearchParams({
format: 'html',
page: String(page),
size: String(size),
})
if (q) {
query.set('q', q)
}
if (status) {
query.set('status', status)
}
if (sort) {
query.set('sort', sort)
}
return `/v1/article/?${query.toString()}`
}
export function articleListRoute(params: EnvelopeParams = {}) {
return `GET ${articleListUrl(params)}`
}
export function parseListUrl(url: string): EnvelopeParams {
const query = new URL(url, 'http://localhost').searchParams
return {
page: Number(query.get('page')),
size: Number(query.get('size')),
q: query.get('q') ?? '',
status: (query.get('status') ?? '') as ArticleStatus | '',
sort: query.get('sort') ?? '',
}
}
+20
View File
@@ -32,6 +32,26 @@ const VISUAL_REFERENCE =
const officeBoard = 'RenewCMS Screens Back-office.dc.html' const officeBoard = 'RenewCMS Screens Back-office.dc.html'
const extraSources: Record<string, Source[]> = { const extraSources: Record<string, Source[]> = {
articles: [
{
board: officeBoard,
group: 'Articles: pagination',
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',
],
alias: {},
},
],
activity: [ activity: [
{ {
board: officeBoard, board: officeBoard,
+77 -17
View File
@@ -673,6 +673,40 @@ function articlesFixture() {
] ]
} }
interface ListedArticle {
title: string
is_online: boolean
}
function foldTitle(value: string) {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
}
function articleEnvelopeFor(all: ListedArticle[], url: URL) {
const page = Number(url.searchParams.get('page') ?? 1)
const size = Number(url.searchParams.get('size') ?? 25)
const q = url.searchParams.get('q') ?? ''
const status = url.searchParams.get('status') ?? ''
const matching = all
.filter((article) => !status || article.is_online === (status === 'online'))
.filter((article) => !q || foldTitle(article.title).includes(foldTitle(q)))
const online = all.filter((article) => article.is_online).length
return {
items: matching.slice((page - 1) * size, page * size),
total: matching.length,
page,
size,
counts: { all: all.length, online, draft: all.length - online },
}
}
function articleListMock(all: ListedArticle[]): ApiMock {
return { status: 200, bodyFor: (url) => articleEnvelopeFor(all, url) }
}
function articlesFixtureWithImages() { function articlesFixtureWithImages() {
return articlesFixture().map((article) => return articlesFixture().map((article) =>
article.id === 4 article.id === 4
@@ -691,11 +725,37 @@ function articlesFixtureWithImages() {
function articlesApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> { function articlesApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return { return {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: articlesFixture() }, 'GET /v1/article/': articleListMock(articlesFixture()),
...overrides, ...overrides,
} }
} }
const clockSensitiveArticles = (path: string): VisualState => ({
path,
skip: 'clock-sensitive',
})
const pagination: Record<string, VisualState> = {
'First page': clockSensitiveArticles('/article'),
'Middle page': clockSensitiveArticles('/article?page=7'),
'Last page': clockSensitiveArticles('/article?page=13'),
'Single page': clockSensitiveArticles('/article'),
'Loading next page': clockSensitiveArticles('/article?page=6'),
'Error loading a page': clockSensitiveArticles('/article?page=6'),
'Page out of range': clockSensitiveArticles('/article?page=99'),
'Search resets to page 1': clockSensitiveArticles('/article?page=7'),
'Empty after search': {
path: '/article',
api: articlesApi(),
steps: async (page) => {
await page.getByPlaceholder('Search by title').fill('tiles')
await page.getByText('No articles match "tiles"').waitFor()
},
},
'Page size menu': clockSensitiveArticles('/article?page=2'),
'Focus-visible': clockSensitiveArticles('/article?page=7'),
}
async function openArticleRowMenu(page: Page, title: string) { async function openArticleRowMenu(page: Page, title: string) {
await page.getByRole('button', { name: `Actions for ${title}` }).click() await page.getByRole('button', { name: `Actions for ${title}` }).click()
} }
@@ -4000,7 +4060,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
api: { api: {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock, 'GET /v1/activity': homeActivityMock,
'GET /v1/article/': { status: 200, body: sixArticlesFixture }, 'GET /v1/article/': articleListMock(sixArticlesFixture),
'GET /v1/system/update': { status: 200, body: updateStatusBody() }, 'GET /v1/system/update': { status: 200, body: updateStatusBody() },
}, },
steps: openAccountMenu, steps: openAccountMenu,
@@ -4324,6 +4384,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
}), }),
}, },
articles: { articles: {
...pagination,
Default: { Default: {
path: '/article', path: '/article',
now: articlesNow, now: articlesNow,
@@ -4351,7 +4412,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/article', path: '/article',
now: articlesNow, now: articlesNow,
api: articlesApi({ api: articlesApi({
'GET /v1/article/': { status: 200, body: articlesFixture(), hang: true }, 'GET /v1/article/': { ...articleListMock(articlesFixture()), hang: true },
}), }),
exception: { exception: {
maxPercent: 10.6, maxPercent: 10.6,
@@ -4362,7 +4423,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
Empty: { Empty: {
path: '/article', path: '/article',
now: articlesNow, now: articlesNow,
api: articlesApi({ 'GET /v1/article/': { status: 200, body: [] } }), api: articlesApi({ 'GET /v1/article/': articleListMock([]) }),
exception: { exception: {
maxPercent: 3.6, maxPercent: 3.6,
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the empty layout', reason: 'Same UTable/EmptyState geometry offset as Default, carried into the empty layout',
@@ -4387,12 +4448,11 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/article', path: '/article',
now: articlesNow, now: articlesNow,
api: articlesApi({ api: articlesApi({
'GET /v1/article/': { 'GET /v1/article/': articleListMock(
status: 200, articlesFixture()
body: articlesFixture()
.slice(0, 4) .slice(0, 4)
.map((article) => ({ ...article, is_online: true })), .map((article) => ({ ...article, is_online: true })),
}, ),
}), }),
steps: async (page) => { steps: async (page) => {
await page.getByRole('tab', { name: /^Drafts/ }).focus() await page.getByRole('tab', { name: /^Drafts/ }).focus()
@@ -4478,7 +4538,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/article', path: '/article',
now: articlesNow, now: articlesNow,
api: articlesApi({ api: articlesApi({
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() }, 'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
}), }),
steps: async (page) => { steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows') await openArticleRowMenu(page, 'Winter checklist for timber windows')
@@ -4490,7 +4550,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/article', path: '/article',
now: articlesNow, now: articlesNow,
api: articlesApi({ api: articlesApi({
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() }, 'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
'DELETE /v1/article/4': { status: 204, hang: true }, 'DELETE /v1/article/4': { status: 204, hang: true },
}), }),
steps: async (page) => { steps: async (page) => {
@@ -4506,7 +4566,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/article', path: '/article',
now: articlesNow, now: articlesNow,
api: articlesApi({ api: articlesApi({
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() }, 'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
'DELETE /v1/article/4': { status: 500 }, 'DELETE /v1/article/4': { status: 500 },
}), }),
steps: async (page) => { steps: async (page) => {
@@ -4523,7 +4583,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/article', path: '/article',
now: articlesNow, now: articlesNow,
api: articlesApi({ api: articlesApi({
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() }, 'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
'DELETE /v1/article/4': { status: 204 }, 'DELETE /v1/article/4': { status: 204 },
}), }),
steps: async (page) => { steps: async (page) => {
@@ -4545,7 +4605,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock, 'GET /v1/activity': homeActivityMock,
'GET /v1/system/update': { status: 200, body: updateStatusBody() }, 'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: sixArticlesFixture }, 'GET /v1/article/': articleListMock(sixArticlesFixture),
}, },
}, },
Loading: { Loading: {
@@ -4555,7 +4615,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': { ...homeActivityMock, hang: true }, 'GET /v1/activity': { ...homeActivityMock, hang: true },
'GET /v1/system/update': { status: 200, body: updateStatusBody() }, 'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true }, 'GET /v1/article/': { ...articleListMock(sixArticlesFixture), hang: true },
}, },
}, },
'Empty, first run': { 'Empty, first run': {
@@ -4565,7 +4625,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock, 'GET /v1/activity': homeActivityMock,
'GET /v1/system/update': { status: 200, body: updateStatusBody() }, 'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: [] }, 'GET /v1/article/': articleListMock([]),
}, },
}, },
Error: { Error: {
@@ -4584,7 +4644,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
api: { api: {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock, 'GET /v1/activity': homeActivityMock,
'GET /v1/article/': { status: 200, body: sixArticlesFixture }, 'GET /v1/article/': articleListMock(sixArticlesFixture),
'GET /v1/system/update': { status: 200, body: updateAvailable() }, 'GET /v1/system/update': { status: 200, body: updateAvailable() },
}, },
}, },
@@ -4594,7 +4654,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
api: { api: {
'GET /v1/auth/me': verifiedSession, 'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock, 'GET /v1/activity': homeActivityMock,
'GET /v1/article/': { status: 200, body: sixArticlesFixture }, 'GET /v1/article/': articleListMock(sixArticlesFixture),
'GET /v1/system/update': { status: 200, body: updateJob('ready') }, 'GET /v1/system/update': { status: 200, body: updateJob('ready') },
}, },
}, },