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