fix: technical debts left by the Sites, Authors and SEO slices, plus backend support for the new Sites and Authors pagination
This commit is contained in:
1 parent
9f4ce426a8
commit
daf3594316
86 files changed
+4223
-1485
No files matched your search
@@ -48,6 +48,7 @@ task dev # API with hot reload + Vite HMR → http://localhost:51
|
||||
| [docs/docker.md](docs/docker.md) | Running RenewCMS in Docker: image, Compose, data volume, backups, reverse proxy, tags, upgrades, publishing |
|
||||
| [docs/configuration.md](docs/configuration.md) | Every environment variable |
|
||||
| [docs/architecture.md](docs/architecture.md) | Backend layering and how the SPA is embedded |
|
||||
| [docs/performance.md](docs/performance.md) | Measured costs, such as the `?seo=` article filter |
|
||||
| [docs/api.md](docs/api.md) | API reference: authentication, API keys, routes and the activity log |
|
||||
| [docs/api/articles.md](docs/api/articles.md) | Reading articles: pagination, search, `format` and body schemas |
|
||||
| [docs/image-variants.md](docs/image-variants.md) | Image variants and their cache |
|
||||
|
||||
@@ -121,7 +121,7 @@ func readPatchAuthor(r *http.Request) (PatchAuthor, bool) {
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return PatchAuthor{}, false
|
||||
}
|
||||
known := 0
|
||||
known := false
|
||||
for _, name := range requiredNonNullFields {
|
||||
value, present := fields[name]
|
||||
if !present {
|
||||
@@ -130,14 +130,14 @@ func readPatchAuthor(r *http.Request) (PatchAuthor, bool) {
|
||||
if string(value) == "null" {
|
||||
return PatchAuthor{}, false
|
||||
}
|
||||
known++
|
||||
known = true
|
||||
}
|
||||
for _, name := range nullableFields {
|
||||
if _, present := fields[name]; present {
|
||||
known++
|
||||
known = true
|
||||
}
|
||||
}
|
||||
if known == 0 {
|
||||
if !known {
|
||||
return PatchAuthor{}, false
|
||||
}
|
||||
var request PatchAuthor
|
||||
@@ -288,6 +288,7 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
for _, item := range page.Items {
|
||||
items = append(items, presentAuthor(item))
|
||||
}
|
||||
w.Header().Set("X-Total-Count", strconv.Itoa(page.Total))
|
||||
writeJSON(w, http.StatusOK, listResponse{Items: items, Total: page.Total, Page: page.Page, Size: page.Size})
|
||||
}
|
||||
|
||||
|
||||
@@ -250,6 +250,19 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseImageId(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := h.ListUseCase.Get(id)
|
||||
if err != nil {
|
||||
writeImageError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toResponse(item))
|
||||
}
|
||||
|
||||
func (h *Handler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
userId, _ := claims.UserId(r)
|
||||
input, closeFile, ok := upload.ReadImageInput(w, r, userId)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -135,17 +136,60 @@ func writeSiteError(w http.ResponseWriter, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, _ *http.Request) {
|
||||
sites, err := h.ListUseCase.List()
|
||||
const (
|
||||
defaultListPage = 1
|
||||
defaultListSize = 25
|
||||
)
|
||||
|
||||
type listQuery struct {
|
||||
Page int `validate:"gte=1"`
|
||||
Size int `validate:"omitempty,oneof=5 25 50 100"`
|
||||
Q string `validate:"max=200"`
|
||||
}
|
||||
|
||||
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) {
|
||||
paged := values.Has("page") || values.Has("size")
|
||||
page, err := intQueryParam(values, "page", defaultListPage)
|
||||
if err != nil {
|
||||
return listQuery{}, err
|
||||
}
|
||||
size := 0
|
||||
if paged {
|
||||
size, err = intQueryParam(values, "size", defaultListSize)
|
||||
if err != nil {
|
||||
return listQuery{}, err
|
||||
}
|
||||
if size == 0 {
|
||||
return listQuery{}, errors.New("invalid size")
|
||||
}
|
||||
}
|
||||
return listQuery{Page: page, Size: size, Q: values.Get("q")}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
query, err := readListQuery(r.URL.Query())
|
||||
if err != nil || validator.Validate.Struct(query) != nil {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
page, err := h.ListUseCase.ListPage(useCases.ListSitesCommand{Q: query.Q, Page: query.Page, Size: query.Size})
|
||||
if err != nil {
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body, err := json.Marshal(sites)
|
||||
body, err := json.Marshal(page.Items)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-Total-Count", strconv.Itoa(page.Total))
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
|
||||
@@ -350,6 +350,7 @@ func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
|
||||
r.Post("/merge", mediaHandler.Merge)
|
||||
r.Patch("/{id}", mediaHandler.Rename)
|
||||
r.Delete("/{id}", mediaHandler.Delete)
|
||||
r.Get("/{id}", mediaHandler.Get)
|
||||
r.Get("/{id}/usage", mediaHandler.Usage)
|
||||
return r
|
||||
}
|
||||
|
||||
+9
-4
@@ -22,11 +22,11 @@ for example `https://cms.example/v1/article/`.
|
||||
under `/v1/site`) and the author writes (`POST`, `PATCH` and `DELETE` under `/v1/author`, `POST /v1/author/me` included) are session only too.
|
||||
- Each key can make 120 requests per minute. Beyond that: 429 with a `Retry-After` header.
|
||||
|
||||
## Routes a key can call (29)
|
||||
## Routes a key can call (30)
|
||||
| Group | Routes |
|
||||
| --- | --- |
|
||||
| Articles | GET /v1/article/, GET /v1/article/{id}, GET /v1/article/{id}/seo, POST /v1/article/{id}/seo/check, POST /v1/article/, PATCH /v1/article/{id}, PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish, DELETE /v1/article/{id} |
|
||||
| Media | GET /v1/media/, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
|
||||
| Media | GET /v1/media/, GET /v1/media/{id}, POST /v1/media/, PATCH /v1/media/{id}, DELETE /v1/media/{id}, POST /v1/media/merge, GET /v1/media/{id}/usage, GET /v1/media/purge-settings (owner or admin), PATCH /v1/media/purge-settings (owner or admin), GET /v1/media/purge-preview (owner or admin), POST /v1/media/purge (owner or admin), GET /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants (owner or admin, session only), POST /v1/media/{id}/variants/{name} (owner or admin, session only), DELETE /v1/media/{id}/variants/{name} (owner or admin, session only) |
|
||||
| Users and invites | GET /v1/user/, PATCH /v1/user/me, POST /v1/user/invite (owner only), DELETE /v1/user/invite/{id} (owner only), POST /v1/user/invite/{id}/renew (owner only), GET /v1/auth/me (with the linked `author`) |
|
||||
| Settings | GET /v1/settings |
|
||||
| Sites | GET /v1/site/, GET /v1/site/{key}/sitemap |
|
||||
@@ -115,6 +115,11 @@ with `items: []` and the real `total`. Any invalid `page`, `size`, `filter`, `so
|
||||
`hash` is 400 "The media query is invalid.". Every item carries `variant_count` and `variant_size`
|
||||
(the index rows of the image and their bytes, 0 when none).
|
||||
|
||||
`GET /v1/media/{id}` reads one image with the same auth as the list (a verified session or an API key; 403 for an
|
||||
unverified account, 401 without credentials) and writes nothing, so it records no activity event. It answers 200 with
|
||||
the object of one `items` element of the list, a missing image is 404 "Image not found" and a malformed id is 400 with
|
||||
the id-format message.
|
||||
|
||||
Breaking change: `next_cursor` is gone and no `X-Total-Count` header is sent. `cursor` and `limit`
|
||||
are no longer parameters; they are ignored, never an error, and have no effect on paging.
|
||||
|
||||
@@ -132,7 +137,7 @@ session." with a key). The role is checked before the body.
|
||||
|
||||
| Route | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /v1/site/` | The sites, default first and then by name, each with `article_count`. |
|
||||
| `GET /v1/site/` | The sites, default first and then by name, each with `article_count`. Query `q` (up to 200 characters, matched in `name`, `key` or `base_url`, ignoring case and accents, `%` and `_` literal), `page` (a whole number from 1) and `size` (5, 25, 50 or 100). Without `page` and `size` every matching site is returned; with only `page`, `size` is 25; with only `size`, `page` is 1. The body stays a JSON array (`[]` when nothing matches, never `null`) and `X-Total-Count` holds the number of matching sites before paging. A page past the end is `200 []` with the total. An invalid `page`, `size` or `q` is 400 with the body-mismatch message and no `X-Total-Count`. |
|
||||
| `POST /v1/site/` | Create. 201 with the site. `is_default: true` makes it the default. `site_name`, `title_template` and `default_description` are optional and validated like on `PATCH`. |
|
||||
| `PATCH /v1/site/{id}` | Edit `name`, `base_url`, `default_robots`, `locale`, `default_social_image_id`, `site_name`, `title_template`, `default_description`. Absent fields are untouched. `key` may only be repeated unchanged; `is_default` is refused. |
|
||||
| `PATCH /v1/site/{id}/default` | Make the site the default, atomically. Setting the current default is a no-op. |
|
||||
@@ -238,7 +243,7 @@ article is allowed and never blocks publishing.
|
||||
|
||||
| Route | Who | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `GET /v1/author/` | verified session or API key | The authors newest first. Query `q` (name or slug, accent and case insensitive), `page` (default 1), `size` (5, 25, 50 or 100, default 25). Body `{ items, total, page, size }`; a bad query is 400 with the body-mismatch message. |
|
||||
| `GET /v1/author/` | verified session or API key | The authors newest first. Query `q` (name or slug, accent and case insensitive), `page` (default 1), `size` (5, 25, 50 or 100, default 25). Body `{ items, total, page, size }`, with `total` repeated in the `X-Total-Count` header on every 200; a bad query is 400 with the body-mismatch message and no header. |
|
||||
| `POST /v1/author/` | owner or admin, session only | Create. 201 with the author. `user_id` links an account that has no author. |
|
||||
| `POST /v1/author/me` | any verified session | Create the caller's own author from the display name (or the username). A taken slug gets `-2`, `-3`... silently. 201 with the author plus `slug_adjusted`: `true` when the stored slug differs from the plain slug of the name (a collision suffix was added), `false` otherwise. No other author response carries this field. |
|
||||
| `PATCH /v1/author/{id}` | owner or admin on any author; any verified session on the author linked to it; session only | Edit `name`, `slug`, `bio`, `avatar_id`, `website`, `social_links`, `user_id`. Absent fields are untouched, `avatar_id`, `website` and `user_id` accept `null`, `social_links` is replaced as a whole. `user_id` links or unlinks the account and is owner or admin only. |
|
||||
|
||||
@@ -25,7 +25,7 @@ Visibility is unchanged: anonymous and unverified callers only see online articl
|
||||
"total": 30,
|
||||
"page": 1,
|
||||
"size": 25,
|
||||
"counts": { "all": 30, "online": 30, "draft": 0 }
|
||||
"counts": { "all": 30, "online": 30, "draft": 0, "suggestions": 4 }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -42,7 +42,7 @@ Visibility is unchanged: anonymous and unverified callers only see online articl
|
||||
- `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`.
|
||||
- `counts` ignores `q`, `status`, `site`, `seo`, `sort`, `page` and `size`, so tab counts do not move while searching. `suggestions` is the number of visible articles whose `seo_status` is `suggestions` (hidden and ready articles and dismissed codes are not counted); it equals the `total` of `?seo=suggestions` sent with no other filter by the same caller. For anonymous and unverified callers `all` equals `online` and `draft` is `0`, and `status=draft` answers `items: []` and `total: 0`.
|
||||
- Each item also carries `seo_status` and `seo_suggestions` (see [SEO status and suggestions](#seo-status-and-suggestions)). `seo` filters on the status before paging, so `total` follows it while `counts` do not.
|
||||
- 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.
|
||||
|
||||
@@ -72,6 +72,11 @@ task clean # remove bin/, tmp/ and the built SPA
|
||||
- All Go tests live in [`test/`](../test) (package `test`, black-box). They use in-memory fakes of the domain `Repository` interfaces, so they need no database, SMTP server or network.
|
||||
- HTTP-level tests use `net/http/httptest` against a chi router. Call `validator.InitValidator()` before invoking a handler.
|
||||
- SPA component tests use Vitest + Vue Test Utils (`task test:web`). `task lint:web` runs eslint and a type check.
|
||||
- A few tests open a real SQLite file in a temporary directory (the chunked id lookups, the slug backfill and the `?seo=` measurement). They never touch the real database.
|
||||
- The tests that need a real MySQL, MariaDB or PostgreSQL server skip themselves when `RENEWCMS_TEST_MYSQL_DSN` or `RENEWCMS_TEST_POSTGRES_DSN` is unset. No CI exists, so run them by hand before a release that touches the repositories:
|
||||
- `task test:mysql` runs `mysql:8.4` then `mariadb:11` in Docker, and `task test:postgres` runs `postgres:16` then `postgres:17`. Docker is required; the tasks start and remove the containers. Extra arguments go after `--`, for example `task test:postgres -- -run Postgres.*Chunked -v`.
|
||||
- To use your own server on a scratch database: `RENEWCMS_TEST_MYSQL_DSN='user:pass@tcp(host:3306)/db' go test -tags nofrontend ./test/... -run MySQL -count=1`, or `RENEWCMS_TEST_POSTGRES_DSN='postgres://user:pass@host:5432/db?sslmode=disable' go test -tags nofrontend ./test/... -run Postgres -count=1`.
|
||||
- `TestMySQLChunkedLookups` and `TestPostgresChunkedLookups` check that the batched `IN` lookups of the article repository (500 ids per batch) return the same rows as SQLite.
|
||||
|
||||
## Visual check against the design
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Performance notes
|
||||
|
||||
## The `?seo=` article filter
|
||||
|
||||
`GET /v1/article/` assesses the SEO status of articles in memory, because the status depends on the body, the site template and the authors. Every request assesses every visible article once, to fill `counts.suggestions`, and `?seo=` reuses that assessment to filter before paging. The cost therefore grows with the number of articles, not with the page size.
|
||||
|
||||
Measured by `TestSeoFilterPerformance` in `test/seo_filter_performance_test.go` (skipped with `-short`): 5,000 articles of about 5 KB each in one site, on a SQLite file in a temporary directory, every article with suggestions, `?seo=suggestions&size=25`, slowest of 3 runs:
|
||||
|
||||
| Version | Time |
|
||||
| --- | --- |
|
||||
| `counts.suggestions` added, assessment done twice | about 430 ms |
|
||||
| assessment shared between the counts and the filter | about 270 ms |
|
||||
|
||||
The target is 300 ms for 5,000 articles. The test also measures the default list (`GET /v1/article/` with no `?seo=`), which pays the same assessment for `counts.suggestions`. Latest run on the development machine, slowest of 3 runs:
|
||||
|
||||
| Request | Time |
|
||||
| --- | --- |
|
||||
| `?seo=suggestions&size=25` | 315 to 338 ms |
|
||||
| default page, no `?seo=` | 307 to 329 ms |
|
||||
|
||||
Both are slightly above the 300 ms target on this machine (they were about 270 ms in the earlier measurement), so the target is logged by the test but not enforced; no cache or index was added. Re-run the test with `go test -tags nofrontend ./test/... -run TestSeoFilterPerformance -v -count=1` after a change to the assessment rules or the article repository. The time depends on the machine; the test only fails above 500 ms.
|
||||
@@ -41,11 +41,16 @@ Articles and sites now store the data a website needs for search and sharing, an
|
||||
- `POST /v1/article/{id}/seo/check` evaluates an article without saving, with optional overrides, and reports the suggestions, the status and whether the slug is free.
|
||||
- `GET /v1/article/` items carry `seo_status` and `seo_suggestions`, and the new `seo` parameter filters on the status.
|
||||
- `GET /v1/site/{key}/sitemap` lists the published URLs of a site, paged, with the total in `X-Total-Count`, which is now exposed to browsers through CORS.
|
||||
- `GET /v1/article/` counts gain `suggestions`, the number of visible articles that still have SEO suggestions.
|
||||
- `GET /v1/site/` takes `q`, `page` and `size` and sends the number of matching sites in `X-Total-Count`; without `page` and `size` every site is still returned, as a JSON array.
|
||||
- `GET /v1/author/` also sends its total in `X-Total-Count`.
|
||||
- `GET /v1/media/{id}` reads one image, with the fields of a media list item; a missing image answers `404` and a malformed id `400`.
|
||||
|
||||
### Upgrading
|
||||
|
||||
- The first start after the upgrade adds the new columns, gives every existing article a slug generated from its title (in id order within each site, with `-2`, `-3` on conflicts) and creates the unique `(site_id, slug)` index. Nothing else changes, and a second start changes nothing.
|
||||
- A deleted article now frees its slug.
|
||||
- The slug backfill writes in batches of 500 rows, so a large database is migrated without one long transaction, and a start that was interrupted resumes where it stopped.
|
||||
|
||||
## Published releases
|
||||
|
||||
|
||||
@@ -189,3 +189,76 @@ Feature: Paginated article listing
|
||||
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"
|
||||
|
||||
Rule: counts.suggestions
|
||||
|
||||
Background:
|
||||
Given counts holds all, online, draft and suggestions, and the rest of the body shape ({"items","total","page","size","counts"}) is unchanged
|
||||
And suggestions is the number of visible articles whose seo_status is "suggestions"
|
||||
And it ignores q, status, site, seo, sort, page and size, like the other counts
|
||||
And an article whose seo_status is "hidden" or "ready" is not counted
|
||||
And a suggestion code listed in the article's seo_dismissed is never counted
|
||||
And it equals the total of GET /v1/article/?seo=suggestions sent with no other filter by the same caller
|
||||
|
||||
Scenario: counts carries the suggestions count
|
||||
Given 4 online and 2 draft articles of which 3 have suggestions
|
||||
When a verified account sends GET /v1/article/
|
||||
Then counts is {"all": 6, "online": 4, "draft": 2, "suggestions": 3}
|
||||
|
||||
Scenario: It is zero when nothing needs attention
|
||||
Given 5 articles that all have a clean SEO check
|
||||
When a verified account sends GET /v1/article/
|
||||
Then counts.suggestions is 0
|
||||
|
||||
Scenario: It equals the total of the suggestions filter
|
||||
Given 30 articles of which 12 have suggestions
|
||||
When a verified account sends GET /v1/article/?seo=suggestions
|
||||
Then total is 12 and counts.suggestions is 12
|
||||
|
||||
Scenario Outline: It ignores every other query parameter
|
||||
Given 6 articles of which 3 have suggestions, on the sites "renew" and "journal"
|
||||
When a verified account sends GET /v1/article/?<params>
|
||||
Then counts.suggestions is 3
|
||||
|
||||
Examples:
|
||||
| params |
|
||||
| q=tile |
|
||||
| status=draft |
|
||||
| site=journal |
|
||||
| seo=ready |
|
||||
| seo=hidden |
|
||||
| sort=title:asc |
|
||||
| size=5&page=99 |
|
||||
|
||||
Scenario: Hidden articles are not counted
|
||||
Given an article with suggestions that is noindex
|
||||
When a verified account sends GET /v1/article/
|
||||
Then counts.suggestions is 0
|
||||
|
||||
Scenario: Dismissed suggestions are not counted
|
||||
Given an article whose only suggestion is dismissed
|
||||
When a verified account sends GET /v1/article/
|
||||
Then counts.suggestions is 0
|
||||
|
||||
Scenario: A verified caller counts offline articles too
|
||||
Given an online and a draft article, both with suggestions
|
||||
When a verified account sends GET /v1/article/
|
||||
Then counts.suggestions is 2
|
||||
|
||||
Scenario: A public caller counts online articles only
|
||||
Given an online and an offline article, both with suggestions
|
||||
When a caller without a verified account sends GET /v1/article/
|
||||
Then counts is {"all": 1, "online": 1, "draft": 0, "suggestions": 1}
|
||||
|
||||
Scenario: An empty library
|
||||
When a verified account sends GET /v1/article/ with no article stored
|
||||
Then counts is {"all": 0, "online": 0, "draft": 0, "suggestions": 0}
|
||||
|
||||
Scenario: A failure to compute the count fails the request
|
||||
Given the repository fails while the suggestions are evaluated
|
||||
When a verified account sends GET /v1/article/
|
||||
Then the status is 500 and the body is "Failed to retrieve articles"
|
||||
|
||||
Scenario: Existing fields are unchanged
|
||||
Then total, page, size, ordering and every item field are as before
|
||||
And counts.all, counts.online and counts.draft keep their values
|
||||
@@ -30,6 +30,21 @@ Helper: decode the body into {items []map, total, page, size int, counts {all, o
|
||||
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.
|
||||
|
||||
## counts.suggestions (Go, `test/`, `task test`)
|
||||
|
||||
Build `article.NewHandler(...)` over the real use cases and the extended `mockArticleRepo`, as in the section above. The fixture articles carry bodies that yield suggestions, hidden (noindex) or ready SEO status and a `seo_dismissed` list. Mount on a fresh `chi.NewRouter()` that mirrors `NewArticleRouter` and call `validator.InitValidator()`.
|
||||
|
||||
20. Counts table: 4 online and 2 draft with 3 suggestions gives `counts` exactly `{all:6, online:4, draft:2, suggestions:3}`. Decode into a map and assert the key set is exactly those four.
|
||||
21. Zero case: 5 clean articles gives `suggestions` 0. Empty library gives all four at 0.
|
||||
22. Equality: 30 articles, 12 with suggestions; `GET /?seo=suggestions` gives `total` 12 and `counts.suggestions` 12.
|
||||
23. Independence table: `q=tile`, `status=draft`, `site=journal`, `seo=ready`, `seo=hidden`, `sort=title:asc`, `size=5&page=99` all give 3.
|
||||
24. Exclusions: a noindex article with suggestions and an article whose only suggestion is dismissed are not counted; one with two suggestions of which one is dismissed is counted once.
|
||||
25. Visibility: a verified caller counts an online and a draft article (2); an anonymous caller and an unverified caller count the online one only, with `draft` 0.
|
||||
26. Repository failure while evaluating gives 500 "Failed to retrieve articles" and no partial body.
|
||||
27. Regression: the existing assertions on `total`, `page`, `size`, `counts.all/online/draft` and items stay green without edits.
|
||||
28. Docs, read by hand: `docs/api/articles.md` (example JSON gets `"suggestions"`, and the `counts` bullet says global, visibility-scoped, noindex and dismissed excluded, equal to the unfiltered `seo=suggestions` total); `web/src/data/apiCatalogue.ts` list response fields with its spec and `IntegrationView.spec.ts` counts; `docs/release-notes.md` line.
|
||||
29. Scoped mutation through `task mutate` only: `MUTATE_FILES=ListArticles.go MUTATE_RUN='TestArticle.*Count' MUTATE_WORKERS=2 task mutate -- ./internal/infrastructure/useCases`, plus any other file touched by the change. Re-check LIVED or NOT COVERED suspects without `MUTATE_RUN`. Then `task lint:go` and `task fmt`.
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -865,8 +865,64 @@ Feature: Authors backend, public bylines that articles credit in order
|
||||
When it is read back from the repository
|
||||
Then the authors come back as 7, 5, 6
|
||||
|
||||
Rule: Total in X-Total-Count
|
||||
|
||||
Background:
|
||||
Given GET /v1/author/ keeps its {"items","total","page","size"} body and also sends total in the X-Total-Count header on every 200
|
||||
And the header equals the body total
|
||||
And X-Total-Count is already in the CORS ExposedHeaders
|
||||
And a refused request sends no X-Total-Count
|
||||
|
||||
Scenario: The total is also sent as a header
|
||||
Given 30 authors
|
||||
When a verified account sends GET /v1/author/?size=5
|
||||
Then the body total is 30 and the header X-Total-Count is "30"
|
||||
|
||||
Scenario: The header follows the search
|
||||
Given 30 authors of which 7 match "mar"
|
||||
When a verified account sends GET /v1/author/?q=mar
|
||||
Then X-Total-Count is "7" and the body total is 7
|
||||
|
||||
Scenario: A page past the end keeps the total in the header
|
||||
Given 30 authors
|
||||
When a verified account sends GET /v1/author/?page=3&size=100
|
||||
Then items is [], the body total is 30 and X-Total-Count is "30"
|
||||
|
||||
Scenario: No match
|
||||
When a verified account sends GET /v1/author/?q=nobody
|
||||
Then items is [], the body total is 0 and X-Total-Count is "0"
|
||||
|
||||
Scenario Outline: Every reader gets the header
|
||||
When GET /v1/author/ is sent with <credentials>
|
||||
Then the status is 200 and X-Total-Count equals the body total
|
||||
|
||||
Examples:
|
||||
| credentials |
|
||||
| the owner's jwt cookie |
|
||||
| a verified admin's jwt cookie |
|
||||
| a verified account with another role |
|
||||
| a valid API key |
|
||||
|
||||
Scenario Outline: Refused requests send no header
|
||||
When GET /v1/author/?<params> is sent with <credentials>
|
||||
Then the status is <status> and no X-Total-Count is sent
|
||||
|
||||
Examples:
|
||||
| params | credentials | status |
|
||||
| size=30 | a verified account | 400 |
|
||||
| page=0 | a verified account | 400 |
|
||||
| page=abc | a verified account | 400 |
|
||||
| | an unverified account's jwt cookie | 403 |
|
||||
| | no credentials | 401 |
|
||||
|
||||
Scenario: CORS exposes the header
|
||||
Given CORS_ALLOWED_ORIGINS includes "https://app.example.com"
|
||||
When a preflighted GET /v1/author/ comes from that origin
|
||||
Then Access-Control-Expose-Headers lists X-Total-Count
|
||||
|
||||
Rule: Documentation
|
||||
|
||||
Scenario: The docs describe the new surface
|
||||
Then docs/api.md documents the /v1/author routes with auth and roles, the error tokens, the author shape, article author_ids and authors, the author field of /v1/auth/me and the authors_updated media field
|
||||
Then docs/api.md names X-Total-Count on the GET /v1/author/ row
|
||||
And docs/api.md documents the /v1/author routes with auth and roles, the error tokens, the author shape, article author_ids and authors, the author field of /v1/auth/me and the authors_updated media field
|
||||
And docs/architecture.md mentions the author aggregate, its repository and its use cases
|
||||
@@ -102,6 +102,13 @@ Spec: `features/authors_backend.feature`. Go tests only, in `test/` (package `te
|
||||
57. Scoped mutation run through `task mutate` only, for example `MUTATE_FILES=<author and article use case files> MUTATE_RUN='TestAuthor' MUTATE_WORKERS=2 task mutate -- <use case package path>`; report LIVED and NOT COVERED mutants; re-check filtered suspects without `MUTATE_RUN`. Also run `task lint:go` and `task fmt`.
|
||||
58. Docs, checked by reading the files: docs/api.md documents the five /v1/author routes (GET, POST, POST /me, PATCH, DELETE) with auth and roles, error tokens, the author and article-author shapes, `author_ids`, the `/auth/me` `author` field and `authors_updated`; docs/architecture.md names the author aggregate, repository and use cases.
|
||||
|
||||
## M. X-Total-Count (Go, `test/`, `task test`)
|
||||
|
||||
59. On the same `mockAuthorRepo` and router as item 4, assert `X-Total-Count` equals the decoded body `total` for: size=5 of 30, `q=mar` with 7 matches, `page=3&size=100` (items empty, total 30), `q=nobody` (0), and for owner, admin, "editor" and API key. Assert the header is absent for `size=30`, `page=0`, `page=abc`, unverified (403) and none (401). The body assertions of item 4 stay unchanged.
|
||||
60. On the SQLite contract harness, the author list total (and so the header) follows `q` and does not depend on `page`/`size`; `task test:mysql` and `task test:postgres` run the same case.
|
||||
61. CORS preflight from an allowed origin exposes `X-Total-Count`.
|
||||
62. Scoped `task mutate` on the author list use case and handler files only. `docs/api.md` author row and `web/src/data/apiCatalogue.ts` (`X-Total-Count` response header on `GET /v1/author/`) with its spec, read by hand. No comment added.
|
||||
|
||||
## Manual
|
||||
|
||||
- MySQL and Postgres: run the contract suite with the opt-in env vars against real servers.
|
||||
|
||||
@@ -8,8 +8,8 @@ Feature: SEO round, slice 4: Sites defaults, Articles search status, Integration
|
||||
Given the screens are SitesView (/settings/sites), ArticleView (/article, also reached as /articles), IntegrationView (/integration) and the Editor SEO panel (/article/:id/edit), inside DashboardShell, per docs/design/routes.md
|
||||
And the visual spec of record is docs/design/screens/sites.md ("Sites: SEO defaults", 5 states), articles.md ("Articles: search status", 3 states), integration.md ("Search and sharing"), editor.md (SuggestionList), docs/design/components.md (SeoStatusChip, SuggestionList), docs/design/seo-slices.md and docs/design/states.md ("SEO round"). Exact copy is read from those files and is not repeated here unless a scenario needs it
|
||||
And the board frames of docs/design/source/DirectoryFrame.dc.html (Sites form, SuggestionList), AppFrame.dc.html (Articles rows, cards and toolbar) and IntegrationPage.dc.html (group and index) are the layout tiebreaker
|
||||
And this slice is SPA only and uses the API of features/seo_data_backend.feature and features/seo_output_backend.feature without change. No Go file changes
|
||||
And the SPA calls, with relative URLs through useApi only: GET /v1/site/, POST /v1/site/ and PATCH /v1/site/{id} (site_name, title_template, default_description), GET /v1/article/ with the optional seo=suggestions parameter, and PATCH /v1/article/{id} with seo_dismissed. The Integration page makes no request
|
||||
And this slice is SPA only and uses the API of features/seo_data_backend.feature and features/seo_output_backend.feature, plus counts.suggestions added by features/articles_pagination_api.feature. No other Go file changes
|
||||
And the SPA calls, with relative URLs through useApi only: GET /v1/site/, POST /v1/site/ and PATCH /v1/site/{id} (site_name, title_template, default_description), GET /v1/article/ with the optional seo=suggestions parameter, whose response also carries counts.suggestions, and PATCH /v1/article/{id} with seo_dismissed. The Integration page makes no request
|
||||
And an article list item carries seo_status ("ready", "suggestions" or "hidden") and seo_suggestions (a count)
|
||||
And the SPA never re-implements a suggestion rule or an SEO status. It draws the status and the count the list returned
|
||||
And the API refuses a title_template without the exact, case-sensitive token %title% with 400 invalid_title_template, and refuses site_name above 100, title_template above 200 and default_description above 320 characters
|
||||
@@ -306,23 +306,30 @@ Feature: SEO round, slice 4: Sites defaults, Articles search status, Integration
|
||||
And the switch, the chips and ?seo=suggestions behave exactly as with several sites
|
||||
|
||||
Scenario Outline: The count on the switch
|
||||
Given the count request answers <answer>
|
||||
Given the switch is on or off
|
||||
When the list response <answer>
|
||||
Then the switch reads <label>
|
||||
|
||||
Examples:
|
||||
| answer | label |
|
||||
| total 3 | "Needs attention" with the count "3" in the mono face |
|
||||
| total 0 | "Needs attention" with the count "0" |
|
||||
| still pending | "Needs attention" with no count |
|
||||
| status 500 or a network error | "Needs attention" with no count and no notice |
|
||||
| answer | label |
|
||||
| holds counts.suggestions 3 | "Needs attention" with the count "3" in the mono face |
|
||||
| holds counts.suggestions 0 | "Needs attention" with the count "0" |
|
||||
| has no counts.suggestions | "Needs attention" with no count |
|
||||
| has not arrived yet (first load) | "Needs attention" with no count |
|
||||
| failed with status 500 or a network error | "Needs attention" with no count and no extra notice |
|
||||
|
||||
@not-in-design
|
||||
Scenario: Where the count comes from
|
||||
Given the switch is off
|
||||
Then one extra GET /v1/article/?seo=suggestions&page=1&size=5 is sent with the current q, status and site, and its total is the count
|
||||
And the count is read from the main response total when the switch is on, with no extra request
|
||||
And the count request is refreshed whenever q, tab or site change, and never when only the page, the size or the sort change
|
||||
And a failing count request never blocks the list, never shows a notice and never changes the URL
|
||||
Given the switch is on or off
|
||||
Then the count is counts.suggestions of the latest successful GET /v1/article/ response
|
||||
And no request with seo=suggestions and size=5 is sent for the count
|
||||
And the count does not change when only q, tab, site, sort, page or size change, because counts.suggestions is global like the tab counts
|
||||
|
||||
@not-in-design
|
||||
Scenario: The count survives a page or search change that is still loading
|
||||
Given the count "3" was shown from an earlier response
|
||||
When a new list request is in flight
|
||||
Then the switch keeps showing "3"
|
||||
|
||||
Scenario: Needs attention: none
|
||||
Given ?seo=suggestions, no search, the tab "All", and the response is empty
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# QA: SEO round, slice 4 (Sites defaults, Articles search status, Integration, suggestion dismissal)
|
||||
|
||||
SPA slice only. No Go change: the API of `features/seo_data_backend.feature` and `features/seo_output_backend.feature` already exists, so this slice has no httptest part. A wrong contract is reported as a backend defect.
|
||||
SPA slice only. No Go change here: the API of `features/seo_data_backend.feature` and `features/seo_output_backend.feature` already exists, and `counts.suggestions` is covered by the httptest part of `features/articles_pagination_api.qa.md`, so this file has no httptest part. A wrong contract is reported as a backend defect.
|
||||
|
||||
Roles: the domain defines only `owner` and `admin`. "verified" is a verified account without either role.
|
||||
|
||||
@@ -8,7 +8,7 @@ Roles: the domain defines only `owner` and `admin`. "verified" is a verified acc
|
||||
|
||||
Mock `useApi`'s `request` per case: GET/POST/PATCH /v1/site/..., GET /v1/article/ (items with `seo_status` and `seo_suggestions`, `counts`, `total`), PATCH /v1/article/{id}, POST /v1/article/{id}/seo/check, and the media routes of the shared picker. Mount SitesView, ArticleView, IntegrationView and EditorView inside DashboardShell with a resolved session and a test router. Mock matchMedia for desktop and mobile. Use fake timers for the search debounce. Do not freeze the system date. The rendering of "Updated …" is not asserted. Report the web tests as run or NOT RUN, never as passing when not run. One spec file per numbered group, under `web/src/views/__tests__/` (`SeoSites*.spec.ts`, `SeoArticles*.spec.ts`, `SeoDismiss*.spec.ts`, `SeoIntegration*.spec.ts`).
|
||||
|
||||
Regression: every existing Sites, Articles (ArticlesPagination, ArticlesPageSizeMemory, ArticlesSitesAuthors), Integration and EditorSeo* spec now sees the toolbar count request, the new fields and the dismiss buttons. Give them default mocks (count request answers total 3 on the request carrying `seo=suggestions`; sites with the three SEO fields) rather than loosening unmocked-call checks, and show that they still pass.
|
||||
Regression: every existing Sites, Articles (ArticlesPagination, ArticlesPageSizeMemory, ArticlesSitesAuthors), Integration and EditorSeo* spec now sees the toolbar count request, the new fields and the dismiss buttons. Give them default mocks (every mocked `GET /v1/article/` envelope carries `counts.suggestions: 3`, and no spec mocks an extra count request any more; sites with the three SEO fields) rather than loosening unmocked-call checks, and show that they still pass.
|
||||
|
||||
1. Sites form fields: order, copy of states 1 and 2 (edit and add), placeholders, helpers, the counter, no "(optional)" on Site name, the "How articles use these defaults" block and its placement under the preview block.
|
||||
2. Live example: the Examples table (site name, name fallback, placeholder "Renew" fallback, `%title%` only, two `%site%`), updated on each keystroke, no request sent; global replace, not first occurrence.
|
||||
@@ -17,8 +17,8 @@ Regression: every existing Sites, Articles (ArticlesPagination, ArticlesPageSize
|
||||
5. Save payloads: PATCH holds only changed fields (assert exact JSON) for template only and for name + description + robots; POST omits the three fields when untouched and sends them when changed; the discard dialog on a changed SEO field; a site without the fields opens with defaults and "Save changes" sends nothing.
|
||||
6. Save failure and sessions: 500 and network failure keep the panel and the notice; 403 `owner_only` gives the existing toast; 401 goes to `/login?redirect=/settings/sites&reason=expired`; admin and verified users get the 403 page and no form.
|
||||
7. SeoStatusChip: the status table (ready, suggestions 1 and 2, hidden with and without a count), placement in the Status cell and in the card meta row, tooltip/accessible name (singular and plural), the unusable items (no seo_status, suggestions with 0, unknown status), the row action that keeps the chip.
|
||||
8. Toggle and URL: parse and write `seo` (`parseArticleQuery`, `toRouteQuery`, `sameArticleQuery`), page reset, q/status/sort/site/size kept, other changes keep seo, reset-to-1 table, opened with `?seo=suggestions&page=2`, unknown values dropped silently via replace (the six values), the request carries `seo=suggestions` only when on, tab counts unchanged.
|
||||
9. Count: total 3, total 0, pending, failure (no count, no notice, no URL change); the extra request (`seo=suggestions&page=1&size=5`, with q, status and site) is made only while off, only when q/tab/site change, and the main response total is used while on.
|
||||
8. Toggle and URL: parse and write `seo` (`parseArticleQuery`, `toRouteQuery`, `sameArticleQuery`), page reset, q/status/sort/site/size kept, other changes keep seo, reset-to-1 table, opened with `?seo=suggestions&page=2`, unknown values dropped silently via replace (the six values), the request carries `seo=suggestions` only when on, tab counts unchanged and `counts.suggestions` unchanged by the toggle.
|
||||
9. Count: `counts.suggestions` 3, 0, absent (no count shown), list pending on first load (no count), list failure 500 and network error (no count, no extra notice, URL unchanged); the count keeps its last value while a new request is in flight; no request carrying `seo=suggestions&page=1&size=5` is ever sent (assert the mocked `useApi` is never called with it, with the toggle off, with q, status and site set, and after a change of each); the count is the same on and off, and unchanged by q, tab, site, sort, page and size.
|
||||
10. Empty states: "Nothing needs attention" with its action (clears seo only), the not-in-design combinations (search, Draft, Online, a site), paginator hidden, Retry notice still works.
|
||||
11. Single-site install: the Site column and filter are absent and everything above behaves the same.
|
||||
12. Dismiss ×, rendering: one button per row of the panel list, none in the compact section list; the footnote; accessible name with the message interpolated; Tab order (after "Go to …"); Enter and Space; visible focus class.
|
||||
@@ -32,7 +32,7 @@ Regression: every existing Sites, Articles (ArticlesPagination, ArticlesPageSize
|
||||
Setups go in `web/visual/states.ts`, one per state, with mocked /v1 and the board's own sample data. Hard gate: every capture within 10 %, no per-state exception. Reference frames: `DirectoryFrame.dc.html` (Sites form, SuggestionList), `AppFrame.dc.html` (Articles) and `IntegrationPage.dc.html`.
|
||||
|
||||
- Sites: SEO defaults (5): Edit: SEO defaults (Renew, `f.sname`, template `%title% | %site%`, example "How we price a bathroom renovation | Renew"), Add: defaults, Template: custom (site name Journal, template `%title% – %site%`), Template: not valid, Save failed. The existing Sites form frames (7) now contain the three fields and the new block: update their setups with the board values.
|
||||
- Articles: search status (3): Status chips (rows: 2 suggestions, hidden, 3 suggestions, 1 suggestion), Needs attention on, Needs attention: none. Every existing Articles and pagination frame now shows the toggle with the count 3 and the chips of the board rows, so each setup mocks the count request with total 3. States whose "Updated" column or text is clock-sensitive follow what the existing setups already do (`skip: clock-sensitive`), and state 8 "Published" stays skipped.
|
||||
- Articles: search status (3): Status chips (rows: 2 suggestions, hidden, 3 suggestions, 1 suggestion), Needs attention on, Needs attention: none. Every existing Articles and pagination frame now shows the toggle with the count 3 and the chips of the board rows, so each setup in `web/visual/states.ts` puts `counts.suggestions: 3` in the list mock and mocks no extra request. States whose "Updated" column or text is clock-sensitive follow what the existing setups already do (`skip: clock-sensitive`), and state 8 "Published" stays skipped.
|
||||
- Integration (8 existing states): the index and the mobile select now contain "Search and sharing", and the page has the new group (collapsed). Mock nothing new. The Navigation state's open select must list the new option. If the 403 note makes a capture exceed 10 %, report it to the user; do not add an exception.
|
||||
- Editor: the dismiss column is now implemented. Remove the five `withMobileException(..., dismissColumnReason)` wrappers (and the `dismissColumnReason` constant, `withMobileException` if nothing else uses it) in `web/visual/states.ts`, covering 'Panel: defaults', 'Panel: outside the soft ranges', 'Panel: cover without alt', 'Panel: choose cover' and the fifth state at line ~5828. Those states must pass `task visual:check -- editor` at the default 10 % in both viewports. If one still fails, report it; do not restore an exception.
|
||||
- Re-run Settings, Users, Profile and Home: report any capture above 10 %. Any failure needs the user's decision (an `approvedBy` exception or a design update).
|
||||
@@ -65,8 +65,8 @@ Setups go in `web/visual/states.ts`, one per state, with mocked /v1 and the boar
|
||||
|
||||
Each has a French entry in `web/src/i18n/messages.ts`.
|
||||
|
||||
1. Source of the "Needs attention" count (extra request, `total`).
|
||||
2. Zero count shown as "0", no count while pending or on failure.
|
||||
1. Source of the count (`counts.suggestions` of the list response).
|
||||
2. Zero shown as "0"; no count while pending, absent or on failure.
|
||||
3. Empty-state precedence when `seo=suggestions` is combined with a search, a tab or a site.
|
||||
4. Chips refresh only with the next list load after a row action.
|
||||
5. Items without a usable status show no chip.
|
||||
|
||||
@@ -632,9 +632,153 @@ Feature: Sites backend, the owner manages sites and every article belongs to exa
|
||||
When a verified account sends GET /v1/activity/?category=sites
|
||||
Then the site events are returned with category "sites"
|
||||
|
||||
Rule: Pagination and search of the site list
|
||||
|
||||
Background:
|
||||
Given GET /v1/site/ accepts q, page and size, keeps the body a JSON array, and sends the number of matching sites in the X-Total-Count header on every 200
|
||||
And when neither page nor size is sent, every matching site is returned
|
||||
And when page or size is sent, size is one of 5, 25, 50 or 100 (default 25 if only page is sent) and page is a positive integer (default 1 if only size is sent)
|
||||
And q is at most 200 characters and matches name, key or base_url as a case- and accent-insensitive substring
|
||||
And the order is unchanged and applied before paging: the default site first, then by name, then by id
|
||||
And X-Total-Count is already in the CORS ExposedHeaders, so the router needs no change
|
||||
And an invalid page, size or q answers 400 with apperr.BodyErrorMessage and sends no X-Total-Count, like GET /v1/article/
|
||||
And access is unchanged: a verified session or an API key reads, an unverified account gets 403, no credentials get 401
|
||||
And the body is [] and never null when nothing matches
|
||||
|
||||
Scenario: With no paging parameter every site is returned
|
||||
Given 61 sites of which "Renew" is the default
|
||||
When a verified account sends GET /v1/site/
|
||||
Then the status is 200
|
||||
And the body is an array of 61 sites starting with "Renew"
|
||||
And X-Total-Count is "61"
|
||||
|
||||
Scenario: q alone returns every match
|
||||
Given 61 sites of which 40 match "shop"
|
||||
When a verified account sends GET /v1/site/?q=shop
|
||||
Then the body holds 40 sites and X-Total-Count is "40"
|
||||
|
||||
Scenario Outline: Paging and sizes
|
||||
Given 61 sites
|
||||
When a verified account sends GET /v1/site/?<params>
|
||||
Then the body holds <count> sites
|
||||
And X-Total-Count is "61"
|
||||
|
||||
Examples:
|
||||
| params | count |
|
||||
| page=1 | 25 |
|
||||
| page=2 | 25 |
|
||||
| page=3 | 11 |
|
||||
| size=5 | 5 |
|
||||
| size=25 | 25 |
|
||||
| size=50&page=2 | 11 |
|
||||
| size=100 | 61 |
|
||||
|
||||
Scenario: Pages never overlap and keep the order
|
||||
Given 61 sites
|
||||
When pages 1 to 3 are read with the default size
|
||||
Then the ids are all distinct, the default site is first on page 1, and the concatenation equals the unpaged list
|
||||
|
||||
Scenario: A page past the end is an empty array with the total
|
||||
Given 61 sites
|
||||
When a verified account sends GET /v1/site/?page=99
|
||||
Then the status is 200 and the body is []
|
||||
And X-Total-Count is "61"
|
||||
|
||||
Scenario: No match is an empty array
|
||||
When a verified account sends GET /v1/site/?q=nomatch
|
||||
Then the status is 200 and the body is [] and not null
|
||||
And X-Total-Count is "0"
|
||||
|
||||
Scenario Outline: Search matches name, key and base URL
|
||||
Given the sites "Renew" (key "renew", https://renew.example.com), "Journal" (key "blog", https://journal.example.org) and "Archive" (key "archive", https://old.example.net)
|
||||
When a verified account sends GET /v1/site/?q=<q>
|
||||
Then the names are <names>
|
||||
And X-Total-Count is "<total>"
|
||||
|
||||
Examples:
|
||||
| q | names | total |
|
||||
| renew | "Renew" | 1 |
|
||||
| JOURNAL | "Journal" | 1 |
|
||||
| blog | "Journal" | 1 |
|
||||
| example.org | "Journal" | 1 |
|
||||
| example | "Renew", "Journal", "Archive" | 3 |
|
||||
| nobody | none | 0 |
|
||||
|
||||
Scenario: Search ignores accents
|
||||
Given the site "Café" with key "cafe"
|
||||
When a verified account sends GET /v1/site/?q=cafe and GET /v1/site/?q=CAFÉ
|
||||
Then both answer "Café" with X-Total-Count "1"
|
||||
|
||||
Scenario: Search and paging compose and the total follows the search
|
||||
Given 61 sites of which 27 match "shop"
|
||||
When a verified account sends GET /v1/site/?q=shop&page=2
|
||||
Then the body holds 2 sites and X-Total-Count is "27"
|
||||
|
||||
Scenario: The default site is not forced into the results
|
||||
Given the default site "Renew" and the site "Journal"
|
||||
When a verified account sends GET /v1/site/?q=journal
|
||||
Then the body is "Journal" only
|
||||
|
||||
Scenario: A wildcard in q is literal
|
||||
Given the site "100% Shop" and the site "Main"
|
||||
When a verified account sends GET /v1/site/?q=%25
|
||||
Then the body is "100% Shop" only
|
||||
|
||||
Scenario Outline: Invalid page, size or q
|
||||
When a verified account sends GET /v1/site/?<params>
|
||||
Then the status is 400
|
||||
And the body is apperr.BodyErrorMessage
|
||||
And no X-Total-Count header is sent
|
||||
|
||||
Examples:
|
||||
| params |
|
||||
| page=0 |
|
||||
| page=-1 |
|
||||
| page=abc |
|
||||
| page= |
|
||||
| size=0 |
|
||||
| size=30 |
|
||||
| size=101 |
|
||||
| size=abc |
|
||||
| size= |
|
||||
| q=(201 characters) |
|
||||
|
||||
Scenario: q at the limit is accepted
|
||||
When a verified account sends GET /v1/site/?q=(200 characters)
|
||||
Then the status is 200 and the body is [] and X-Total-Count is "0"
|
||||
|
||||
Scenario: The other fields are unchanged
|
||||
When a verified account sends GET /v1/site/?size=5
|
||||
Then every site carries the same fields as before, article_count included, and the owner-only writes are unchanged
|
||||
|
||||
Scenario Outline: Every reader gets the header
|
||||
When GET /v1/site/?size=5 is sent with <credentials>
|
||||
Then the status is 200 and X-Total-Count is present
|
||||
|
||||
Examples:
|
||||
| credentials |
|
||||
| the owner's jwt cookie |
|
||||
| a verified admin's jwt cookie |
|
||||
| a valid API key |
|
||||
|
||||
Scenario Outline: Refused readers get no header
|
||||
When GET /v1/site/?size=5 is sent with <credentials>
|
||||
Then the status is <status> and no X-Total-Count is sent
|
||||
|
||||
Examples:
|
||||
| credentials | status |
|
||||
| an unverified account's jwt cookie | 403 |
|
||||
| no credentials | 401 |
|
||||
|
||||
Scenario: CORS exposes the header
|
||||
Given CORS_ALLOWED_ORIGINS includes "https://app.example.com"
|
||||
When a preflighted GET /v1/site/?page=2 comes from that origin
|
||||
Then Access-Control-Expose-Headers lists X-Total-Count and Link
|
||||
|
||||
Rule: Documentation
|
||||
|
||||
Scenario: The docs describe the new surface
|
||||
Then docs/api.md documents the /v1/site routes, their errors, article site_id, the ?site= filter and the media sites_updated field
|
||||
And docs/api.md documents q, page, size, the all-sites default without paging parameters, the 400, the past-the-end page and X-Total-Count on GET /v1/site/
|
||||
And docs/configuration.md states that PUBLIC_URL seeds the first site once at migration
|
||||
And docs/architecture.md mentions the site aggregate, its repository and its use cases
|
||||
@@ -88,8 +88,25 @@ Spec: `features/sites_backend.feature`. Go tests only, in `test/` (package `test
|
||||
47. Scoped mutation run through `task mutate` only, for example `MUTATE_FILES=<site and article use case files> MUTATE_RUN='TestSite' MUTATE_WORKERS=2 task mutate -- <use case package path>`; report LIVED and NOT COVERED mutants; re-check filtered suspects without `MUTATE_RUN`. Also run `task lint:go` and `task fmt`.
|
||||
48. Docs (Rule: Documentation), checked by reading the files: docs/api.md documents the five /v1/site routes with auth, error tokens, article `site_id`, the `?site=` filter, `sites_updated` on media merge and purge; docs/configuration.md says PUBLIC_URL seeds the first site once at migration; docs/architecture.md names the site aggregate, repository and use cases. No comment is added to any file.
|
||||
|
||||
## L. Pagination and search of the list (Go, `test/`, `task test`)
|
||||
|
||||
Build `site.NewHandler(...)` over `mockSiteRepo` extended with `q`, paging and a total, mounted on a `chi.NewRouter()` mirroring `NewSiteRouter`, with the same jwtauth setup and cookies as above. Call `validator.InitValidator()`. Add the CORS options of `enableCorsIfConfigured` to one test router to check the exposed header (pattern: `test/seo_output_sitemap_http_test.go`).
|
||||
|
||||
49. 61 sites, no parameter: 61 items, default first, `X-Total-Count` "61". `q=shop` alone returns every match with the matching count.
|
||||
50. Page/size table (page=1/2/3, size=5/25, size=50&page=2, size=100): count of items and `X-Total-Count` "61" each time.
|
||||
51. Pages 1-3 have distinct ids; their concatenation equals the unpaged list; the default site is first on page 1.
|
||||
52. `page=99` gives 200, body exactly `[]` (raw-bytes check, not `null`) and the real total. `q=nomatch` gives `[]` and "0".
|
||||
53. The `q` table (name, key, base URL, uppercase, accent folding, partial host, no match), search plus paging, the default site not forced into a non-matching result, and `%` and `_` in `q` matched literally.
|
||||
54. The invalid table (page 0, -1, abc, empty; size 0, 30, 101, abc, empty; q of 201 characters): 400 `apperr.BodyErrorMessage` and no `X-Total-Count`. q of 200 characters is 200 with `[]`.
|
||||
55. Credentials table: owner, admin, API key give 200 with the header; unverified 403 and none 401 give no header. The existing section F owner-only write table is rerun and stays green.
|
||||
56. CORS preflight from an allowed origin exposes `X-Total-Count`.
|
||||
57. Repository (real SQLite in the temp-dir harness, same file as the contract suite): ordering across the page boundary, `q` over the three fields, total equals matches, `article_count` correct on each page. MySQL and Postgres run the same cases through `task test:mysql` and `task test:postgres`, which use the opt-in switches `RENEWCMS_TEST_MYSQL_DSN` and `RENEWCMS_TEST_POSTGRES_DSN`.
|
||||
58. Docs, read by hand: `docs/api.md` (the `GET /v1/site/` row), `web/src/data/apiCatalogue.ts` (query parameters q/page/size and the `X-Total-Count` response header, no-paging default) with `apiCatalogue.spec.ts`, and the Integration reference counts in `IntegrationView.spec.ts`. No comment added anywhere.
|
||||
59. Scoped `task mutate` only: `MUTATE_FILES=<site list use case and repository files> MUTATE_RUN='TestSite' MUTATE_WORKERS=2 task mutate -- ./internal/infrastructure/useCases`. Report LIVED and NOT COVERED, and re-check filtered suspects without `MUTATE_RUN`. Then `task lint:go` and `task fmt`.
|
||||
|
||||
## Manual
|
||||
|
||||
- `curl -i -b <cookie> 'http://localhost:8080/v1/site/?q=ren&size=5&page=2'` against `task dev`; a browser cross-origin read with `CORS_ALLOWED_ORIGINS` set; the editor's Site select and the Articles Site filter still list all sites (they send no paging parameter).
|
||||
- MySQL and Postgres: run the contract suite with the opt-in env vars against real servers.
|
||||
- Real boot with an old database copy and PUBLIC_URL set: one "Default" site appears, articles backfilled, second boot changes nothing.
|
||||
- Docker with the `/data` volume: boot an existing database and confirm the same.
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
Feature: Hardening after the Sites, Authors and SEO slices
|
||||
|
||||
As the maintainer of a RenewCMS installation
|
||||
I want the SEO panel, the SEO check cache, the chunked lookups and the slug backfill to hold on large data
|
||||
So that behaviour stays correct beyond the first page of media, on every database, and on every boot
|
||||
|
||||
Background:
|
||||
Given this file covers four items; items 4, 8 and 9 are behaviour-preserving and item 3 adds one read endpoint, GET /v1/media/{id}
|
||||
And every existing scenario stays green without edits to its assertions
|
||||
And no comment is added to any file
|
||||
|
||||
Rule: The SEO panel names its images by id (item 3)
|
||||
|
||||
Background:
|
||||
Given GET /v1/media/{id} is mounted in NewMediaRouter behind the same middleware as GET /v1/media/: jwtauth.Verifier, the API key authentication, AuditContext, RequireSignedIn, jwtauth.Authenticator, RequireCurrentToken and RequireVerifiedUser
|
||||
And it reads one image and writes nothing, so it records no activity event
|
||||
And it answers 200 with the same JSON item shape as one element of items in GET /v1/media/
|
||||
And a missing image answers 404 with apperr.ImageNotFoundErrorMessage ("Image not found")
|
||||
And a malformed id answers 400 with apperr.IdUint32ErrorMessage
|
||||
And the SPA useMediaNames resolves the wanted ids with GET /v1/media/{id} and no longer reads the first 100 items of the list
|
||||
|
||||
Scenario Outline: Who can read one image
|
||||
Given the image 5 exists
|
||||
When GET /v1/media/5 is sent with <credentials>
|
||||
Then the status is <status>
|
||||
|
||||
Examples:
|
||||
| credentials | status |
|
||||
| the owner's jwt cookie | 200 |
|
||||
| a verified admin's jwt cookie | 200 |
|
||||
| a verified account with another role | 200 |
|
||||
| a valid API key | 200 |
|
||||
| an unverified account's jwt cookie | 403 |
|
||||
| no credentials | 401 |
|
||||
|
||||
Scenario: The item has the shape of the list
|
||||
Given the image 5 exists with a name, a hash, a size, dimensions, a used_in count and variants information
|
||||
When a verified account sends GET /v1/media/5
|
||||
Then the body is the JSON object equal to the element of items for the image 5 in GET /v1/media/
|
||||
And no other field is added or removed
|
||||
|
||||
Scenario: A missing image is not found
|
||||
When a verified account sends GET /v1/media/999
|
||||
Then the status is 404 and the body is "Image not found"
|
||||
|
||||
Scenario Outline: A malformed id is refused
|
||||
When a verified account sends GET /v1/media/<id>
|
||||
Then the status is 400 and the body is apperr.IdUint32ErrorMessage
|
||||
|
||||
Examples:
|
||||
| id |
|
||||
| abc |
|
||||
| -1 |
|
||||
| 4294967296 |
|
||||
| 1.5 |
|
||||
|
||||
Scenario: The route does not shadow the existing media routes
|
||||
When GET /v1/media/5/usage and GET /v1/media/5/variants are sent as before
|
||||
Then they answer exactly as before this change
|
||||
|
||||
Scenario: A non-GET method keeps its meaning
|
||||
When PATCH /v1/media/5 and DELETE /v1/media/5 are sent as before
|
||||
Then they behave exactly as before this change
|
||||
|
||||
Scenario: Reading one image records nothing
|
||||
When a verified account sends GET /v1/media/5
|
||||
Then no activity event is recorded
|
||||
|
||||
Scenario: A selected image outside the first 100 media items is named
|
||||
Given the article's cover image has id 150 and is not among the first 100 media items
|
||||
When the SEO panel opens
|
||||
Then the panel requests GET /v1/media/150 and shows the name and thumbnail of the image 150
|
||||
And the same holds for the social image
|
||||
|
||||
Scenario: Images already known are not fetched again
|
||||
Given the panel has resolved the images 150 and 151
|
||||
When the panel re-renders with the same selection
|
||||
Then no lookup request is sent
|
||||
|
||||
Scenario: An image that no longer exists
|
||||
Given GET /v1/media/150 answers 404
|
||||
Then the panel shows its existing "unknown image" fallback and no error notice
|
||||
|
||||
Scenario: A failing lookup never blocks the panel
|
||||
Given GET /v1/media/150 fails with a 500 or a network error
|
||||
Then the panel stays usable, shows the existing fallback and raises no notice
|
||||
|
||||
Scenario: Behaviour with fewer than 100 media items is unchanged
|
||||
Given the library holds 12 media items
|
||||
Then the names shown are the same as before
|
||||
|
||||
Scenario: The list is no longer requested for names
|
||||
When the SEO panel resolves any selected image
|
||||
Then no GET /v1/media/?page=1&size=100&sort=newest request is sent for names
|
||||
|
||||
Scenario: The docs describe the route
|
||||
Then docs/api.md documents GET /v1/media/{id} with its auth, the 200 shape, the 404 and the 400
|
||||
And web/src/data/apiCatalogue.ts lists GET /v1/media/{id} in the Media group, with its spec and the Integration reference counts updated
|
||||
|
||||
Rule: The SEO check answer cache (item 4)
|
||||
|
||||
Scenario: The same effective input is checked once
|
||||
Given a check was answered for an article body and fields
|
||||
When the same fields are checked again with a different but irrelevant difference in the request (key order or whitespace of the JSON)
|
||||
Then no second POST /v1/article/{id}/seo/check is sent and the cached answer is shown
|
||||
|
||||
Scenario Outline: A field that affects the result invalidates the cache
|
||||
Given a check was answered
|
||||
When <field> changes and the check runs again
|
||||
Then POST /v1/article/{id}/seo/check is sent again
|
||||
|
||||
Examples:
|
||||
| field |
|
||||
| the title |
|
||||
| the body |
|
||||
| the description |
|
||||
| the excerpt |
|
||||
| the slug |
|
||||
| the cover image |
|
||||
| the social image |
|
||||
| the noindex switch |
|
||||
| the site |
|
||||
| the canonical URL |
|
||||
|
||||
Scenario: A field that does not affect the result does not invalidate it
|
||||
Given a check was answered
|
||||
When only a field the check ignores changes
|
||||
Then the cached answer is reused
|
||||
|
||||
Scenario: A failed check is not cached
|
||||
Given a check failed with a 500
|
||||
When the same input is checked again
|
||||
Then the request is sent again
|
||||
|
||||
Scenario: The cache stays bounded
|
||||
Given 200 distinct inputs were checked
|
||||
Then the cache holds no more than its existing limit and a repeated recent input is still a hit
|
||||
|
||||
Rule: Chunked IN lookups on a real database (item 8)
|
||||
|
||||
Background:
|
||||
Given the article repository looks ids up in batches of 500 (articles by id, article_authors by article id, authors by id)
|
||||
|
||||
Scenario: A page-sized lookup keeps every row once and in order
|
||||
Given 1,200 articles, so the lookup spans 3 batches
|
||||
When the repository loads all 1,200 ids
|
||||
Then 1,200 articles come back, each once, in the requested order
|
||||
|
||||
Scenario Outline: Batch boundaries
|
||||
Given <n> ids are requested and all exist
|
||||
Then exactly <n> rows come back
|
||||
|
||||
Examples:
|
||||
| n |
|
||||
| 0 |
|
||||
| 1 |
|
||||
| 499 |
|
||||
| 500 |
|
||||
| 501 |
|
||||
| 1000 |
|
||||
| 1001 |
|
||||
|
||||
Scenario: Missing ids are absent
|
||||
Given 600 ids of which 100 do not exist
|
||||
Then 500 rows come back and none is invented
|
||||
|
||||
Scenario: Credits keep their order across batches
|
||||
Given 700 articles each crediting 3 authors in a chosen order
|
||||
When the credits are loaded
|
||||
Then every article lists its authors in its stored order (position, then id)
|
||||
|
||||
Scenario: Soft-deleted rows follow the existing rule
|
||||
Given 3 of the 1,200 articles are soft-deleted
|
||||
Then the lookup returns the same rows as the single-query path would
|
||||
|
||||
Scenario: It works on every supported database
|
||||
When the tests run against SQLite, MySQL/MariaDB and PostgreSQL
|
||||
Then the results are identical
|
||||
|
||||
Rule: The article slug backfill (item 9)
|
||||
|
||||
Scenario: A large database is filled in batches
|
||||
Given a database from before slugs holding 5,000 articles with empty slugs
|
||||
When the backfill runs
|
||||
Then every article has a non-empty slug unique within its site
|
||||
And the writes were made in several transactions of bounded size and not in one
|
||||
And the batch size is a named constant chosen by the implementation
|
||||
|
||||
Scenario: The second boot changes nothing
|
||||
Given the backfill already ran
|
||||
When it runs again
|
||||
Then no row is written, no slug changes and updated_at is untouched
|
||||
|
||||
Scenario: A crashed backfill resumes
|
||||
Given the backfill stopped after its first batch
|
||||
When it runs again
|
||||
Then the remaining articles are filled and the already filled ones keep their slugs
|
||||
|
||||
Scenario: Duplicates get the first free suffix
|
||||
Given 3 articles with the same title in one site and 1 in another site
|
||||
When the backfill runs
|
||||
Then the first keeps the plain slug, the others get -2 and -3, and the other site's article keeps the plain slug
|
||||
|
||||
Scenario: Soft-deleted rows get their tombstone once
|
||||
Given 2 soft-deleted articles
|
||||
When the backfill runs twice
|
||||
Then each has the tombstone slug after the first run and is not rewritten by the second
|
||||
|
||||
Scenario: Existing slugs are kept
|
||||
Given an article whose slug was set by the owner
|
||||
When the backfill runs
|
||||
Then its slug is unchanged
|
||||
|
||||
Scenario: The unique index exists afterwards and a repeat does not fail
|
||||
When the backfill and index creation run on a second boot
|
||||
Then idx_articles_site_slug exists and no error is returned
|
||||
@@ -0,0 +1,63 @@
|
||||
# QA procedure: Hardening after the Sites, Authors and SEO slices
|
||||
|
||||
Spec: `features/tech_debt_hardening.feature`. The Go part is in `test/` (package `test`, `task test`); the web part is Vitest (`task test:web`). Constraints: no commits; no comments in code; scoped `task mutate` only for the Go files of items 3, 8 and 9 (and of items 5 and 6 per their own QA files); `task visual:check` only on `articles`, `sites` and `integration` to confirm no regression, with no new `approvedBy` exception and the waived screens untouched; `docs/design` read-only.
|
||||
|
||||
## Item 3: GET /v1/media/{id} (Go, `test/`, `task test`)
|
||||
|
||||
Build `media.NewHandler(...)` over the real media use cases and the existing fake image repository and storage fakes used by the media tests (unique fake type names across files). Mount on a fresh `chi.NewRouter()` that mirrors `NewMediaRouter` in `api/router.go`, behind `jwtauth.Verifier`, the API key authentication path, `middleware.RequireSignedIn`, `jwtauth.Authenticator` and `RequireVerifiedUser`, with a test `jwtauth.New("HS256", ...)`. Requests carry signed `jwt` cookies with the `user_id` claim for an owner, an admin, a verified account with another role and an unverified account. Call `validator.InitValidator()`.
|
||||
|
||||
1. Credentials table on an existing image 5: owner, admin, other verified role and a valid API key give 200; an unverified account gives 403; no credentials give 401.
|
||||
2. Shape: decode `GET /v1/media/5` and the matching element of `items` from `GET /v1/media/` into maps; assert the key sets and values are equal.
|
||||
3. `GET /v1/media/999` gives 404 with the exact plain-text body "Image not found" (`apperr.ImageNotFoundErrorMessage`).
|
||||
4. Malformed ids `abc`, `-1`, `4294967296`, `1.5` give 400 with `apperr.IdUint32ErrorMessage`.
|
||||
5. Regression: `GET /{id}/usage`, `GET /{id}/variants`, `PATCH /{id}` and `DELETE /{id}` still answer as before (the existing tests stay green without edits), and the variant routes keep their `RejectAPIKey` and owner/admin rule.
|
||||
6. No activity event is recorded by the read (the fake audit repository stays empty).
|
||||
7. Docs, read by hand: `docs/api.md` documents `GET /v1/media/{id}` (auth, 200 shape, 404, 400); `web/src/data/apiCatalogue.ts` lists it in the Media group, and its spec and `IntegrationView.spec.ts` counts are updated; `docs/release-notes.md` line.
|
||||
8. Scoped mutation through `task mutate` only, for the media files touched by the change: `MUTATE_FILES=<the GetMedia use case file and any media file touched> MUTATE_RUN='TestMedia' MUTATE_WORKERS=2 task mutate -- ./internal/infrastructure/useCases`. Report LIVED and NOT COVERED; re-check filtered suspects without `MUTATE_RUN`. Then `task lint:go` and `task fmt`.
|
||||
|
||||
## Item 3: media names (Vitest, `web/`)
|
||||
|
||||
Mock `useApi`'s `request` for `GET /v1/media/{id}` per case.
|
||||
|
||||
9. Open the SEO panel with cover id 150 and social id 151 (neither in any first page of the library). Mock `GET /v1/media/150` and `GET /v1/media/151` to return items. Assert the panel shows their names and thumbnails, and that no `GET /v1/media/?page=1&size=100&sort=newest` request is made.
|
||||
10. Re-render with the same selection: assert no new request (the mocked call count stays).
|
||||
11. `GET /v1/media/150` answers 404: the fallback is shown, no notice. A 500 and a network error: panel usable, fallback, no notice.
|
||||
12. A library of 12 items: the same names as before.
|
||||
13. The existing `useMediaNames` and SEO-panel specs pass; the assertions that depended on the list lookup are amended only where they mock the old request, and that diff is listed in the report.
|
||||
|
||||
## Item 4: SEO check cache (Vitest)
|
||||
|
||||
14. Same fields, different key order: one POST (assert the mocked call count).
|
||||
15. Table, one case per result-affecting field (title, body, description, excerpt, slug, cover image, social image, noindex, site, canonical URL): a second POST is sent.
|
||||
16. A field the check ignores changes: no second POST.
|
||||
17. A 500 is not cached: the retry POSTs.
|
||||
18. 200 distinct inputs: the cache size stays at its existing limit and the most recent input is a hit.
|
||||
19. The existing editor SEO specs pass unchanged.
|
||||
|
||||
## Item 8: chunked lookups (Go, `test/`)
|
||||
|
||||
20. Temp-dir SQLite (never the real database file): seed 1,200 articles; load all ids; assert 1,200 rows, no duplicates, requested order.
|
||||
21. Boundary table 0, 1, 499, 500, 501, 1000, 1001 ids.
|
||||
22. 600 ids with 100 missing gives 500 rows. 700 articles with 3 ordered credits each come back in stored order across batches (including the `position ASC, id ASC` rule). Soft-deleted rows match the single-query path.
|
||||
23. Name the MySQL and Postgres variants so the existing filters match them: `TestMySQLChunkedLookups` (run by `-run MySQL`) and `TestPostgresChunkedLookups` (run by `-run Postgres`). They skip when `RENEWCMS_TEST_MYSQL_DSN` or `RENEWCMS_TEST_POSTGRES_DSN` is unset, like the existing contract tests.
|
||||
|
||||
## Item 9: slug backfill (Go, `test/`, temp-dir SQLite)
|
||||
|
||||
24. Seed 5,000 articles with empty slugs across two sites; run `database.BackfillArticleSlugs`; assert unique non-empty slugs within each site; assert the writes ran in several bounded transactions (the batch size is a named constant chosen by the coder; assert through a counting hook or a GORM callback counter in the test, not a log).
|
||||
25. Second boot: record every row's `slug` and `updated_at` and the DB write count (callback), run again, and assert the values are identical and the write count is 0. Also call `database.Migrate` twice.
|
||||
26. Interrupted run: stop after the first batch (failure injection on the second batch), assert the first batch is committed, rerun, and assert the rest are filled and the first batch's slugs unchanged.
|
||||
27. Suffix table (3 same-title articles in one site plus 1 in another), tombstones for soft-deleted rows written once, owner-set slugs kept, index `idx_articles_site_slug` present and a repeated `ensureArticleSlugIndex` returns nil.
|
||||
28. Existing slug migration tests (`planSlugAssignments`) stay green.
|
||||
|
||||
## Quality gates
|
||||
|
||||
29. Layering guard: use cases import no persistence, mailer, models, gorm, net/http or chi; the domain package imports no GORM or HTTP; handlers import no persistence; no comment added.
|
||||
30. Scoped mutation for items 3, 8 and 9 only, through `task mutate`: `MUTATE_FILES=articleRepository.go,articleSlugBackfill.go,<item 3 media files> MUTATE_RUN='TestChunked|TestBackfill|TestMedia' MUTATE_WORKERS=2 task mutate -- ./internal/infrastructure/persistence/repositories ./internal/infrastructure/persistence/database ./internal/infrastructure/useCases`. Report LIVED and NOT COVERED; re-check filtered suspects without `MUTATE_RUN`. Then `task lint:go`, `task fmt`, `task test` and `task test:web`.
|
||||
31. `task visual:check -- articles`, `-- sites` and `-- integration`: all captures pass at 10 %; no exception added.
|
||||
|
||||
## Manual
|
||||
|
||||
- MySQL and PostgreSQL (item 8, and the repository cases of items 5 and 6): run `task test:mysql` (mysql:8.4 then mariadb:11) and `task test:postgres` (postgres:16 then 17). Docker is required; the tasks start and remove the containers. Extra arguments go after `--`, for example `task test:postgres -- -run Postgres.*Chunked -v`. To use your own server instead: `RENEWCMS_TEST_MYSQL_DSN='user:pass@tcp(host:3306)/db' go test -tags nofrontend ./test/... -run MySQL -count=1` and `RENEWCMS_TEST_POSTGRES_DSN='postgres://user:pass@host:5432/db?sslmode=disable' go test -tags nofrontend ./test/... -run Postgres -count=1`. Use a scratch database. No CI exists, so this is run by hand.
|
||||
- Item 3 on a live app: with more than 100 media items, open an article whose cover is an old image; the SEO panel shows its name. `curl -i -b <cookie> http://localhost:8080/v1/media/<id>` returns the item, 404 for a missing id and 400 for `abc`.
|
||||
- Slug backfill on a real file: copy an old database, boot with `task dev`, confirm slugs and that a second boot changes no row (compare `updated_at` before and after).
|
||||
- Item 7 (the `?seo=` measurement) is a measurement with its result recorded in `docs/`, so it has no Gherkin.
|
||||
@@ -76,6 +76,8 @@ type Counts struct {
|
||||
All int `json:"all"`
|
||||
Online int `json:"online"`
|
||||
Draft int `json:"draft"`
|
||||
|
||||
Suggestions int `json:"suggestions"`
|
||||
}
|
||||
|
||||
type PageResult struct {
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
package site
|
||||
|
||||
type ListQuery struct {
|
||||
Q string
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Items []Site
|
||||
Total int
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
List() ([]Site, error)
|
||||
Find(query ListQuery) (Page, error)
|
||||
Get(id uint32) (Site, error)
|
||||
GetByKey(key string) (Site, error)
|
||||
GetDefault() (Site, error)
|
||||
|
||||
@@ -3,13 +3,15 @@ package database
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"slices"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
articleSlugIndexName = "idx_articles_site_slug"
|
||||
articleSlugIndexStatement = "CREATE UNIQUE INDEX " + articleSlugIndexName + " ON articles (site_id, slug)"
|
||||
articleSlugIndexName = "idx_articles_site_slug"
|
||||
articleSlugBackfillBatchSize = 500
|
||||
articleSlugIndexStatement = "CREATE UNIQUE INDEX idx_articles_site_slug ON articles (site_id, slug)"
|
||||
)
|
||||
|
||||
type slugOwner struct {
|
||||
@@ -23,13 +25,22 @@ type slugAssignment struct {
|
||||
}
|
||||
|
||||
func BackfillArticleSlugs(db *gorm.DB) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var rows []entity.Article
|
||||
if err := tx.Unscoped().Select("id", "site_id", "title", "slug", "deleted_at").Order("id ASC").Find(&rows).Error; err != nil {
|
||||
var rows []entity.Article
|
||||
if err := db.Unscoped().Select("id", "site_id", "title", "slug", "deleted_at").Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
assignments := planSlugAssignments(rows)
|
||||
for batch := range slices.Chunk(assignments, articleSlugBackfillBatchSize) {
|
||||
if err := writeSlugAssignments(db, batch); err != nil {
|
||||
return err
|
||||
}
|
||||
assignments := planSlugAssignments(rows)
|
||||
for _, assignment := range assignments {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeSlugAssignments(db *gorm.DB, batch []slugAssignment) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, assignment := range batch {
|
||||
if err := tx.Unscoped().Model(&entity.Article{}).Where("id = ?", assignment.id).UpdateColumn("slug", assignment.slug).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
domain "RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -59,6 +60,40 @@ func (s *SiteRepository) List() ([]domain.Site, error) {
|
||||
return sites, nil
|
||||
}
|
||||
|
||||
func (s *SiteRepository) Find(query domain.ListQuery) (domain.Page, error) {
|
||||
all, err := s.List()
|
||||
if err != nil {
|
||||
return domain.Page{}, err
|
||||
}
|
||||
matching := all
|
||||
if needle := foldForSearch(strings.TrimSpace(query.Q)); needle != "" {
|
||||
matching = make([]domain.Site, 0, len(all))
|
||||
for _, candidate := range all {
|
||||
if siteMatches(candidate, needle) {
|
||||
matching = append(matching, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
page := domain.Page{Items: []domain.Site{}, Total: len(matching)}
|
||||
if query.Size <= 0 {
|
||||
page.Items = matching
|
||||
return page, nil
|
||||
}
|
||||
start := pageStart(max(query.Page, 1), query.Size, len(matching))
|
||||
end := min(start+query.Size, len(matching))
|
||||
page.Items = matching[start:end]
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func siteMatches(candidate domain.Site, needle string) bool {
|
||||
for _, field := range []string{candidate.Name, candidate.Key, candidate.BaseURL} {
|
||||
if strings.Contains(foldForSearch(field), needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *SiteRepository) load(db *gorm.DB, model entity.Site) (domain.Site, error) {
|
||||
total, err := s.articleCount(db, model.ID)
|
||||
if err != nil {
|
||||
|
||||
@@ -42,6 +42,11 @@ func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (Art
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
summaries, err := u.summarizeVisible(!command.Verified)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
counts.Suggestions = countSuggestions(summaries)
|
||||
page := ArticlePage{Items: []article.Article{}, Page: command.Page, Size: command.Size, Counts: counts, SEO: map[uint32]article.SEOSummary{}}
|
||||
if !command.Verified && command.Status == article.StatusDraft {
|
||||
return page, nil
|
||||
@@ -62,10 +67,6 @@ func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (Art
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
summaries, err := u.summarize(found.Items)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
items, total := found.Items, found.Total
|
||||
if command.SEO != "" {
|
||||
items, total = pageBySEOStatus(found.Items, summaries, command)
|
||||
@@ -80,6 +81,29 @@ func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (Art
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) summarizeVisible(onlineOnly bool) (map[uint32]article.SEOSummary, error) {
|
||||
filters := article.Filters{Unpaged: true}
|
||||
if onlineOnly {
|
||||
online := true
|
||||
filters.IsOnline = &online
|
||||
}
|
||||
found, err := u.articleRepository.FindPage(filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u.summarize(found.Items)
|
||||
}
|
||||
|
||||
func countSuggestions(summaries map[uint32]article.SEOSummary) int {
|
||||
suggestions := 0
|
||||
for _, summary := range summaries {
|
||||
if summary.Status == article.SEOStatusSuggestions {
|
||||
suggestions++
|
||||
}
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
func pageBySEOStatus(items []article.Article, summaries map[uint32]article.SEOSummary, command ListArticlesCommand) ([]article.Article, int) {
|
||||
matching := make([]article.Article, 0, len(items))
|
||||
for _, item := range items {
|
||||
|
||||
@@ -82,8 +82,7 @@ func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
|
||||
}
|
||||
}
|
||||
|
||||
offset := (query.Page - 1) * query.Size
|
||||
pageStart := min(offset, len(matching))
|
||||
pageStart := min(min(query.Page-1, len(matching))*query.Size, len(matching))
|
||||
pageEnd := min(pageStart+query.Size, len(matching))
|
||||
names := newUploaderNames(l.userRepository)
|
||||
items := []MediaItem{}
|
||||
@@ -106,6 +105,14 @@ func (l *ListMediaUseCase) List(query image.ListQuery) (MediaPage, error) {
|
||||
return MediaPage{Items: items, Total: len(matching), Page: query.Page, Size: query.Size, Counts: counts}, nil
|
||||
}
|
||||
|
||||
func (l *ListMediaUseCase) Get(id uint32) (MediaItem, error) {
|
||||
found, err := l.imageRepository.Get(id)
|
||||
if err != nil {
|
||||
return MediaItem{}, err
|
||||
}
|
||||
return l.Describe(found)
|
||||
}
|
||||
|
||||
func (l *ListMediaUseCase) Describe(img image.Image) (MediaItem, error) {
|
||||
articleCount, err := l.articleUsageCount(img)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,17 @@ type ListSitesUseCase struct {
|
||||
siteRepository site.Repository
|
||||
}
|
||||
|
||||
type ListSitesCommand struct {
|
||||
Q string
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
type SitePage struct {
|
||||
Items []site.Site
|
||||
Total int
|
||||
}
|
||||
|
||||
func NewListSitesUseCase(siteRepository site.Repository) *ListSitesUseCase {
|
||||
return &ListSitesUseCase{siteRepository}
|
||||
}
|
||||
@@ -13,3 +24,11 @@ func NewListSitesUseCase(siteRepository site.Repository) *ListSitesUseCase {
|
||||
func (u *ListSitesUseCase) List() ([]site.Site, error) {
|
||||
return u.siteRepository.List()
|
||||
}
|
||||
|
||||
func (u *ListSitesUseCase) ListPage(command ListSitesCommand) (SitePage, error) {
|
||||
found, err := u.siteRepository.Find(site.ListQuery{Q: command.Q, Page: command.Page, Size: command.Size})
|
||||
if err != nil {
|
||||
return SitePage{}, err
|
||||
}
|
||||
return SitePage{Items: found.Items, Total: found.Total}, nil
|
||||
}
|
||||
+1
-1
@@ -35,7 +35,7 @@ export GOCACHE="$(native "$work/gocache")"
|
||||
export TEMP="$(native "$work/tmp")"
|
||||
export TMP="$TEMP"
|
||||
export TMPDIR="$TEMP"
|
||||
export GOFLAGS="-buildvcs=false${MUTATE_RUN:+ -run=$MUTATE_RUN}"
|
||||
export GOFLAGS="-buildvcs=false -count=1${MUTATE_RUN:+ -run=$MUTATE_RUN}"
|
||||
|
||||
cd "$repo"
|
||||
code=0
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
chunkedArticleCount = 1200
|
||||
chunkedCreditedPool = 700
|
||||
chunkedTitlePrefix = "Chunk"
|
||||
)
|
||||
|
||||
func seedChunkedArticles(t *testing.T, db *gorm.DB, count int) {
|
||||
t.Helper()
|
||||
rows := make([]entity.Article, 0, count)
|
||||
for i := 1; i <= count; i++ {
|
||||
rows = append(rows, entity.Article{Title: fmt.Sprintf("%s %04d", chunkedTitlePrefix, i), Slug: fmt.Sprintf("chunk-%04d", i), SiteID: 1, IsOnline: true})
|
||||
}
|
||||
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
|
||||
t.Fatalf("seed articles: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedChunkedCredits(t *testing.T, db *gorm.DB, articles int) []uint32 {
|
||||
t.Helper()
|
||||
authors := make([]entity.Author, 0, 3)
|
||||
for i := 1; i <= 3; i++ {
|
||||
authors = append(authors, entity.Author{Name: fmt.Sprintf("Author %d", i), Slug: fmt.Sprintf("author-%d", i), SocialLinks: "[]"})
|
||||
}
|
||||
if err := db.Create(&authors).Error; err != nil {
|
||||
t.Fatalf("seed authors: %v", err)
|
||||
}
|
||||
order := []uint32{authors[2].ID, authors[0].ID, authors[1].ID}
|
||||
credits := make([]entity.ArticleAuthor, 0, articles*3)
|
||||
for article := 1; article <= articles; article++ {
|
||||
for _, position := range []int{2, 0, 1} {
|
||||
credits = append(credits, entity.ArticleAuthor{ArticleID: uint32(article), AuthorID: order[position], Position: position})
|
||||
}
|
||||
}
|
||||
if err := db.CreateInBatches(&credits, 100).Error; err != nil {
|
||||
t.Fatalf("seed credits: %v", err)
|
||||
}
|
||||
return []uint32{order[0], order[1], order[2]}
|
||||
}
|
||||
|
||||
func chunkedFilters(size int) domain.Filters {
|
||||
return domain.Filters{Q: chunkedTitlePrefix, Page: 1, Size: size, Sort: domain.Sort{Field: domain.SortByID, Direction: domain.SortAsc}}
|
||||
}
|
||||
|
||||
func runChunkedLookups(t *testing.T, db *gorm.DB) {
|
||||
seedChunkedArticles(t, db, chunkedArticleCount)
|
||||
repo := persistence.NewArticleRepository(db)
|
||||
|
||||
t.Run("A page-sized lookup keeps every row once and in order", func(t *testing.T) {
|
||||
filters := chunkedFilters(0)
|
||||
filters.Unpaged = true
|
||||
page, err := repo.FindPage(filters)
|
||||
if err != nil || page.Total != chunkedArticleCount || len(page.Items) != chunkedArticleCount {
|
||||
t.Fatalf("total %d items %d err %v", page.Total, len(page.Items), err)
|
||||
}
|
||||
ids := make([]uint32, 0, len(page.Items))
|
||||
for _, item := range page.Items {
|
||||
ids = append(ids, item.ID)
|
||||
}
|
||||
if !slices.IsSorted(ids) || len(slices.Compact(slices.Clone(ids))) != chunkedArticleCount {
|
||||
t.Fatalf("ids are not unique and ascending")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Batch boundaries", func(t *testing.T) {
|
||||
for _, n := range []int{0, 1, 499, 500, 501, 1000, 1001} {
|
||||
page, err := repo.FindPage(chunkedFilters(n))
|
||||
if err != nil || len(page.Items) != n || page.Total != chunkedArticleCount {
|
||||
t.Errorf("n=%d: items %d total %d err %v", n, len(page.Items), page.Total, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Credits keep their order across batches", func(t *testing.T) {
|
||||
expected := seedChunkedCredits(t, db, chunkedCreditedPool)
|
||||
page, err := repo.FindPage(chunkedFilters(chunkedCreditedPool))
|
||||
if err != nil || len(page.Items) != chunkedCreditedPool {
|
||||
t.Fatalf("items %d err %v", len(page.Items), err)
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
got := make([]uint32, 0, len(item.Authors))
|
||||
for _, byline := range item.Authors {
|
||||
got = append(got, byline.ID)
|
||||
}
|
||||
if !slices.Equal(got, expected) {
|
||||
t.Fatalf("article %d credits %v, expected %v", item.ID, got, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Soft-deleted rows follow the existing rule", func(t *testing.T) {
|
||||
if err := db.Where("id IN ?", []uint32{1, 600, 1200}).Delete(&entity.Article{}).Error; err != nil {
|
||||
t.Fatalf("soft delete: %v", err)
|
||||
}
|
||||
filters := chunkedFilters(0)
|
||||
filters.Unpaged = true
|
||||
page, err := repo.FindPage(filters)
|
||||
if err != nil || len(page.Items) != chunkedArticleCount-3 || page.Total != chunkedArticleCount-3 {
|
||||
t.Fatalf("items %d total %d err %v", len(page.Items), page.Total, err)
|
||||
}
|
||||
for _, item := range page.Items {
|
||||
if item.ID == 1 || item.ID == 600 || item.ID == 1200 {
|
||||
t.Fatalf("deleted article %d returned", item.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Ids that no longer exist are absent", func(t *testing.T) {
|
||||
if err := db.Where("id BETWEEN ? AND ?", 101, 200).Delete(&entity.Article{}).Error; err != nil {
|
||||
t.Fatalf("soft delete: %v", err)
|
||||
}
|
||||
page, err := repo.FindPage(chunkedFilters(600))
|
||||
if err != nil || len(page.Items) != 600 || page.Total != chunkedArticleCount-103 {
|
||||
t.Fatalf("items %d total %d err %v", len(page.Items), page.Total, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestChunkedLookups(t *testing.T) {
|
||||
db := openUnmigratedSQLite(t)
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
runChunkedLookups(t, db)
|
||||
}
|
||||
|
||||
func TestMySQLChunkedLookups(t *testing.T) {
|
||||
runChunkedLookups(t, openContractMySQL(t))
|
||||
}
|
||||
|
||||
func TestPostgresChunkedLookups(t *testing.T) {
|
||||
runChunkedLookups(t, openContractPostgres(t))
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
backfillArticleCount = 5000
|
||||
backfillIndexName = "idx_articles_site_slug"
|
||||
)
|
||||
|
||||
type backfillWrites struct {
|
||||
updates int
|
||||
rows int64
|
||||
transactions map[any]bool
|
||||
failFrom int
|
||||
}
|
||||
|
||||
func watchBackfillWrites(t *testing.T, db *gorm.DB) *backfillWrites {
|
||||
t.Helper()
|
||||
writes := &backfillWrites{transactions: map[any]bool{}}
|
||||
name := fmt.Sprintf("backfill-writes-%s", t.Name())
|
||||
err := db.Callback().Update().Before("gorm:update").Register(name, func(tx *gorm.DB) {
|
||||
if tx.Statement.Table != "articles" {
|
||||
return
|
||||
}
|
||||
if writes.failFrom > 0 && writes.updates+1 >= writes.failFrom {
|
||||
_ = tx.AddError(errors.New("injected failure"))
|
||||
return
|
||||
}
|
||||
writes.updates++
|
||||
writes.transactions[tx.Statement.ConnPool] = true
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("register callback: %v", err)
|
||||
}
|
||||
err = db.Callback().Update().After("gorm:update").Register(name+"-rows", func(tx *gorm.DB) {
|
||||
if tx.Statement.Table == "articles" {
|
||||
writes.rows += tx.RowsAffected
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("register callback: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = db.Callback().Update().Remove(name)
|
||||
_ = db.Callback().Update().Remove(name + "-rows")
|
||||
})
|
||||
return writes
|
||||
}
|
||||
|
||||
func openLegacyArticleDatabase(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db := openUnmigratedSQLite(t)
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if err := db.Exec("DROP INDEX " + backfillIndexName).Error; err != nil {
|
||||
t.Fatalf("drop index: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func seedLegacyArticles(t *testing.T, db *gorm.DB, rows []entity.Article) {
|
||||
t.Helper()
|
||||
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func legacyArticles(count int) []entity.Article {
|
||||
rows := make([]entity.Article, 0, count)
|
||||
for i := 1; i <= count; i++ {
|
||||
rows = append(rows, entity.Article{Title: fmt.Sprintf("Legacy %04d", i), SiteID: uint32(i%2 + 1)})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
type backfillRow struct {
|
||||
Slug string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func storedBackfillRows(t *testing.T, db *gorm.DB) map[uint32]backfillRow {
|
||||
t.Helper()
|
||||
var rows []entity.Article
|
||||
if err := db.Unscoped().Find(&rows).Error; err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
found := make(map[uint32]backfillRow, len(rows))
|
||||
for _, row := range rows {
|
||||
found[row.ID] = backfillRow{Slug: row.Slug, UpdatedAt: row.UpdatedAt}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
func countBackfillIndex(t *testing.T, db *gorm.DB) int64 {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?", backfillIndexName).Scan(&count).Error; err != nil {
|
||||
t.Fatalf("index lookup: %v", err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func TestBackfillArticleSlugs(t *testing.T) {
|
||||
t.Run("A large database is filled in batches", func(t *testing.T) {
|
||||
db := openLegacyArticleDatabase(t)
|
||||
seedLegacyArticles(t, db, legacyArticles(backfillArticleCount))
|
||||
writes := watchBackfillWrites(t, db)
|
||||
|
||||
if err := database.BackfillArticleSlugs(db); err != nil {
|
||||
t.Fatalf("backfill: %v", err)
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for id, row := range storedBackfillRows(t, db) {
|
||||
var siteID uint32
|
||||
if err := db.Unscoped().Model(&entity.Article{}).Where("id = ?", id).Select("site_id").Scan(&siteID).Error; err != nil {
|
||||
t.Fatalf("site: %v", err)
|
||||
}
|
||||
key := fmt.Sprintf("%d/%s", siteID, row.Slug)
|
||||
if row.Slug == "" || seen[key] {
|
||||
t.Fatalf("article %d has an empty or duplicate slug %q", id, row.Slug)
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
if writes.updates != backfillArticleCount || len(writes.transactions) < 2 || len(writes.transactions) > backfillArticleCount/100 {
|
||||
t.Errorf("updates %d in %d transactions", writes.updates, len(writes.transactions))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The second boot changes nothing", func(t *testing.T) {
|
||||
db := openLegacyArticleDatabase(t)
|
||||
seedLegacyArticles(t, db, legacyArticles(300))
|
||||
if err := database.BackfillArticleSlugs(db); err != nil {
|
||||
t.Fatalf("backfill: %v", err)
|
||||
}
|
||||
before := storedBackfillRows(t, db)
|
||||
writes := watchBackfillWrites(t, db)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
after := storedBackfillRows(t, db)
|
||||
for id, row := range before {
|
||||
if after[id] != row {
|
||||
t.Fatalf("article %d changed from %+v to %+v", id, row, after[id])
|
||||
}
|
||||
}
|
||||
if writes.rows != 0 {
|
||||
t.Errorf("second boot wrote %d rows", writes.rows)
|
||||
}
|
||||
if countBackfillIndex(t, db) != 1 {
|
||||
t.Errorf("%s must exist exactly once", backfillIndexName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A crashed backfill resumes", func(t *testing.T) {
|
||||
db := openLegacyArticleDatabase(t)
|
||||
seedLegacyArticles(t, db, legacyArticles(1200))
|
||||
writes := watchBackfillWrites(t, db)
|
||||
writes.failFrom = 501
|
||||
|
||||
if err := database.BackfillArticleSlugs(db); err == nil {
|
||||
t.Fatalf("expected the injected failure")
|
||||
}
|
||||
interrupted := storedBackfillRows(t, db)
|
||||
filled := map[uint32]string{}
|
||||
for id, row := range interrupted {
|
||||
if row.Slug != "" {
|
||||
filled[id] = row.Slug
|
||||
}
|
||||
}
|
||||
if len(filled) == 0 || len(filled) >= 1200 {
|
||||
t.Fatalf("expected a partial run, %d filled", len(filled))
|
||||
}
|
||||
|
||||
writes.failFrom = 0
|
||||
if err := database.BackfillArticleSlugs(db); err != nil {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
for id, row := range storedBackfillRows(t, db) {
|
||||
if row.Slug == "" {
|
||||
t.Fatalf("article %d still empty", id)
|
||||
}
|
||||
if slug, wasFilled := filled[id]; wasFilled && slug != row.Slug {
|
||||
t.Fatalf("article %d changed from %q to %q", id, slug, row.Slug)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Duplicates get the first free suffix", func(t *testing.T) {
|
||||
db := openLegacyArticleDatabase(t)
|
||||
seedLegacyArticles(t, db, []entity.Article{
|
||||
{Title: "Same Title", SiteID: 1}, {Title: "Same Title", SiteID: 1}, {Title: "Same Title", SiteID: 1}, {Title: "Same Title", SiteID: 2},
|
||||
})
|
||||
if err := database.BackfillArticleSlugs(db); err != nil {
|
||||
t.Fatalf("backfill: %v", err)
|
||||
}
|
||||
rows := storedBackfillRows(t, db)
|
||||
if rows[1].Slug != "same-title" || rows[2].Slug != "same-title-2" || rows[3].Slug != "same-title-3" || rows[4].Slug != "same-title" {
|
||||
t.Errorf("slugs = %+v", rows)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Soft-deleted rows get their tombstone once and existing slugs are kept", func(t *testing.T) {
|
||||
db := openLegacyArticleDatabase(t)
|
||||
seedLegacyArticles(t, db, []entity.Article{{Title: "Gone one", SiteID: 1}, {Title: "Gone two", SiteID: 1}, {Title: "Owner set", Slug: "chosen-by-owner", SiteID: 1}})
|
||||
if err := db.Where("id IN ?", []uint32{1, 2}).Delete(&entity.Article{}).Error; err != nil {
|
||||
t.Fatalf("soft delete: %v", err)
|
||||
}
|
||||
if err := database.BackfillArticleSlugs(db); err != nil {
|
||||
t.Fatalf("backfill: %v", err)
|
||||
}
|
||||
first := storedBackfillRows(t, db)
|
||||
if first[1].Slug != article.Tombstone(1) || first[2].Slug != article.Tombstone(2) || first[3].Slug != "chosen-by-owner" {
|
||||
t.Fatalf("slugs = %+v", first)
|
||||
}
|
||||
writes := watchBackfillWrites(t, db)
|
||||
if err := database.BackfillArticleSlugs(db); err != nil {
|
||||
t.Fatalf("second run: %v", err)
|
||||
}
|
||||
if writes.updates != 0 || writes.rows != 0 {
|
||||
t.Errorf("second run wrote %d rows", writes.rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
@@ -507,3 +508,102 @@ func TestAuthorsBackendOwnAuthor(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorsTotalHeader(t *testing.T) {
|
||||
seed := func(e *authorEnv, count int) {
|
||||
for id := uint32(1); id <= uint32(count); id++ {
|
||||
name := fmt.Sprintf("Writer %02d", id)
|
||||
if id <= 7 {
|
||||
name = fmt.Sprintf("Mara %02d", id)
|
||||
}
|
||||
e.authors.seed(id, name, fmt.Sprintf("writer-%02d", id))
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("The total is also sent as a header", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
query string
|
||||
header string
|
||||
total int
|
||||
}{{"?size=5", "30", 30}, {"?q=mar", "7", 7}, {"?page=3&size=100", "30", 30}, {"?q=nobody", "0", 0}}
|
||||
for _, c := range cases {
|
||||
e := newAuthorEnv(t)
|
||||
seed(e, 30)
|
||||
rec := e.owner(t, http.MethodGet, "/v1/author/"+c.query, "")
|
||||
list := decodeAuthorList(t, rec)
|
||||
if rec.Header().Get("X-Total-Count") != c.header || list.Total != c.total {
|
||||
t.Errorf("%s: header %q total %d", c.query, rec.Header().Get("X-Total-Count"), list.Total)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Every reader gets the header", func(t *testing.T) {
|
||||
readers := map[string]func(e *authorEnv) *httptest.ResponseRecorder{
|
||||
"owner": func(e *authorEnv) *httptest.ResponseRecorder { return e.owner(t, http.MethodGet, "/v1/author/", "") },
|
||||
"admin": func(e *authorEnv) *httptest.ResponseRecorder {
|
||||
return e.as(t, apiKeyDevId, http.MethodGet, "/v1/author/", "")
|
||||
},
|
||||
"editor": func(e *authorEnv) *httptest.ResponseRecorder {
|
||||
return e.as(t, authorEditorID, http.MethodGet, "/v1/author/", "")
|
||||
},
|
||||
"key": func(e *authorEnv) *httptest.ResponseRecorder { return e.viaKey(t, http.MethodGet, "/v1/author/", "") },
|
||||
}
|
||||
for name, send := range readers {
|
||||
e := newAuthorEnv(t)
|
||||
seed(e, 4)
|
||||
rec := send(e)
|
||||
if rec.Code != http.StatusOK || rec.Header().Get("X-Total-Count") != "4" {
|
||||
t.Errorf("%s: status %d header %q", name, rec.Code, rec.Header().Get("X-Total-Count"))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Refused requests send no header", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
query string
|
||||
send func(e *authorEnv, path string) *httptest.ResponseRecorder
|
||||
status int
|
||||
}{
|
||||
{"?size=30", func(e *authorEnv, path string) *httptest.ResponseRecorder {
|
||||
return e.owner(t, http.MethodGet, path, "")
|
||||
}, 400},
|
||||
{"?page=0", func(e *authorEnv, path string) *httptest.ResponseRecorder {
|
||||
return e.owner(t, http.MethodGet, path, "")
|
||||
}, 400},
|
||||
{"?page=abc", func(e *authorEnv, path string) *httptest.ResponseRecorder {
|
||||
return e.owner(t, http.MethodGet, path, "")
|
||||
}, 400},
|
||||
{"", func(e *authorEnv, path string) *httptest.ResponseRecorder {
|
||||
return e.as(t, apiKeyQuinnId, http.MethodGet, path, "")
|
||||
}, 403},
|
||||
{"", func(e *authorEnv, path string) *httptest.ResponseRecorder {
|
||||
return e.anonymous(t, http.MethodGet, path, "")
|
||||
}, 401},
|
||||
}
|
||||
for _, c := range cases {
|
||||
e := newAuthorEnv(t)
|
||||
seed(e, 4)
|
||||
rec := c.send(e, "/v1/author/"+c.query)
|
||||
if _, present := rec.Header()["X-Total-Count"]; rec.Code != c.status || present {
|
||||
t.Errorf("%q: status %d header present %v", c.query, rec.Code, present)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthorsResponseWriteFailureIsLogged(t *testing.T) {
|
||||
e := newAuthorEnv(t)
|
||||
e.authors.seed(1, "Writer 01", "writer-01")
|
||||
var logOutput strings.Builder
|
||||
defaultLogOutput := log.Writer()
|
||||
log.SetOutput(&logOutput)
|
||||
defer log.SetOutput(defaultLogOutput)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/author/", nil)
|
||||
req.AddCookie(e.cookie(t, apiKeyOwnerId, 0))
|
||||
e.router.ServeHTTP(&erroringResponseWriter{}, req)
|
||||
|
||||
if !strings.Contains(logOutput.String(), "Failed to write response") {
|
||||
t.Errorf("expected the write failure to be logged, got %q", logOutput.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const mediaGetEditorID = 9
|
||||
|
||||
func newMediaGetEnv(t *testing.T) *siteEnv {
|
||||
t.Helper()
|
||||
e := newSiteEnvWithMain(t)
|
||||
e.users.users[mediaGetEditorID] = user.User{ID: mediaGetEditorID, Username: "editor", Email: "editor@example.com", IsVerified: true, Role: user.Role("editor")}
|
||||
e.seedImageRecord(5)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *siteEnv) seedImageRecord(id uint32) {
|
||||
e.seedImage(id)
|
||||
img := e.images.images[id]
|
||||
img.Size, img.Width, img.Height, img.SHA256 = 2048, 800, 600, hashA
|
||||
e.images.images[id] = img
|
||||
e.seedSpring(1, `<img src="`+img.Path+`">`, true)
|
||||
}
|
||||
|
||||
func TestMediaGetOne(t *testing.T) {
|
||||
t.Run("Who can read one image", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
credentials string
|
||||
send func(e *siteEnv) *httptest.ResponseRecorder
|
||||
status int
|
||||
}{
|
||||
{"the owner's jwt cookie", func(e *siteEnv) *httptest.ResponseRecorder {
|
||||
return e.as(t, apiKeyOwnerId, http.MethodGet, "/v1/media/5", "")
|
||||
}, http.StatusOK},
|
||||
{"a verified admin's jwt cookie", func(e *siteEnv) *httptest.ResponseRecorder {
|
||||
return e.as(t, apiKeyDevId, http.MethodGet, "/v1/media/5", "")
|
||||
}, http.StatusOK},
|
||||
{"a verified account with another role", func(e *siteEnv) *httptest.ResponseRecorder {
|
||||
return e.as(t, mediaGetEditorID, http.MethodGet, "/v1/media/5", "")
|
||||
}, http.StatusOK},
|
||||
{"a valid API key", func(e *siteEnv) *httptest.ResponseRecorder {
|
||||
raw, _ := e.issueKey(apiKeyDevId, "Deploy bot")
|
||||
return e.asKey(t, raw, http.MethodGet, "/v1/media/5", "")
|
||||
}, http.StatusOK},
|
||||
{"an unverified account's jwt cookie", func(e *siteEnv) *httptest.ResponseRecorder {
|
||||
return e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/media/5", "")
|
||||
}, http.StatusForbidden},
|
||||
{"no credentials", func(e *siteEnv) *httptest.ResponseRecorder {
|
||||
return e.anonymous(t, http.MethodGet, "/v1/media/5", "")
|
||||
}, http.StatusUnauthorized},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.credentials, func(t *testing.T) {
|
||||
e := newMediaGetEnv(t)
|
||||
if rec := c.send(e); rec.Code != c.status {
|
||||
t.Errorf("status = %d, expected %d", rec.Code, c.status)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The item has the shape of the list", func(t *testing.T) {
|
||||
e := newMediaGetEnv(t)
|
||||
single := e.owner(t, http.MethodGet, "/v1/media/5", "")
|
||||
listed := e.owner(t, http.MethodGet, "/v1/media/", "")
|
||||
var one map[string]any
|
||||
var page struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(single.Body.Bytes(), &one); err != nil {
|
||||
t.Fatalf("decode item: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(listed.Body.Bytes(), &page); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
var expected map[string]any
|
||||
for _, item := range page.Items {
|
||||
if item["id"] == 5.0 {
|
||||
expected = item
|
||||
}
|
||||
}
|
||||
if expected == nil || !reflect.DeepEqual(one, expected) {
|
||||
t.Fatalf("item %v, list element %v", one, expected)
|
||||
}
|
||||
if one["used_in"] != 1.0 || one["size"] != 2048.0 {
|
||||
t.Errorf("item = %v", one)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A missing image is not found", func(t *testing.T) {
|
||||
e := newMediaGetEnv(t)
|
||||
rec := e.owner(t, http.MethodGet, "/v1/media/999", "")
|
||||
expectSiteStatusAndBody(t, rec, http.StatusNotFound, apperr.ImageNotFoundErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("A malformed id is refused", func(t *testing.T) {
|
||||
for _, id := range []string{"abc", "-1", "4294967296", "1.5"} {
|
||||
e := newMediaGetEnv(t)
|
||||
rec := e.owner(t, http.MethodGet, "/v1/media/"+id, "")
|
||||
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.IdUint32ErrorMessage)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Reading one image records nothing", func(t *testing.T) {
|
||||
e := newMediaGetEnv(t)
|
||||
e.owner(t, http.MethodGet, "/v1/media/5", "")
|
||||
e.noEvent(t)
|
||||
})
|
||||
|
||||
t.Run("The route does not shadow the usage route", func(t *testing.T) {
|
||||
e := newMediaGetEnv(t)
|
||||
rec := e.owner(t, http.MethodGet, "/v1/media/5/usage", "")
|
||||
if rec.Code != http.StatusOK || rec.Body.String() == "" {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -347,3 +347,14 @@ func TestListMediaUseCasePaging(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaPaginationHugePage(t *testing.T) {
|
||||
env := newMediaEnv(t)
|
||||
seedBulkImages(env, 3)
|
||||
|
||||
got, _ := env.list(t, "?page=138000000000000001&size=100")
|
||||
|
||||
if got.Total != 3 || len(got.Items) != 0 || got.Page != 138000000000000001 || got.Size != 100 {
|
||||
t.Errorf("unexpected envelope total=%d items=%d page=%d size=%d", got.Total, len(got.Items), got.Page, got.Size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
seoPerformanceArticles = 5000
|
||||
seoPerformanceBudget = 300 * time.Millisecond
|
||||
seoPerformanceCeiling = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
func TestSeoFilterPerformance(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("measures 5,000 articles on SQLite")
|
||||
}
|
||||
db := openUnmigratedSQLite(t)
|
||||
if err := database.Migrate(db, migrationPublicURL); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
body := strings.Repeat("<p>"+strings.Repeat("word ", 120)+"</p>", 8)
|
||||
rows := make([]entity.Article, 0, seoPerformanceArticles)
|
||||
for i := 1; i <= seoPerformanceArticles; i++ {
|
||||
row := entity.Article{Title: fmt.Sprintf("Performance article %04d", i), Slug: fmt.Sprintf("performance-%04d", i), Body: body, SiteID: 1, IsOnline: i%3 != 0, SEODismissed: "[]"}
|
||||
if i%2 == 0 {
|
||||
row.SEOTitle, row.SEODescription = strings.Repeat("t", 45), strings.Repeat("d", 100)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
list := useCases.NewListArticlesUseCase(persistence.NewArticleRepository(db), &fakeBodyRenderer{}, persistence.NewSiteRepository(db))
|
||||
|
||||
filtered := useCases.ListArticlesCommand{Verified: true, Page: 1, Size: 25, SEO: article.SEOStatusSuggestions, Sort: article.DefaultSort()}
|
||||
unfiltered := useCases.ListArticlesCommand{Verified: true, Page: 1, Size: 25, Sort: article.DefaultSort()}
|
||||
slowestFiltered, page := slowestOfThree(t, list, filtered)
|
||||
slowestUnfiltered, _ := slowestOfThree(t, list, unfiltered)
|
||||
t.Logf("GET /v1/article/?seo=suggestions over %d articles in one site: slowest of 3 runs %d ms (budget %d ms), total %d, counts %+v", seoPerformanceArticles, slowestFiltered.Milliseconds(), seoPerformanceBudget.Milliseconds(), page.Total, page.Counts)
|
||||
t.Logf("GET /v1/article/ (default page) over %d articles in one site: slowest of 3 runs %d ms (budget %d ms)", seoPerformanceArticles, slowestUnfiltered.Milliseconds(), seoPerformanceBudget.Milliseconds())
|
||||
if slowestFiltered > seoPerformanceCeiling {
|
||||
t.Errorf("the seo filter took %v, above %v", slowestFiltered, seoPerformanceCeiling)
|
||||
}
|
||||
if slowestUnfiltered > seoPerformanceCeiling {
|
||||
t.Errorf("the unfiltered list took %v, above %v", slowestUnfiltered, seoPerformanceCeiling)
|
||||
}
|
||||
}
|
||||
|
||||
func slowestOfThree(t *testing.T, list *useCases.ListArticlesUseCase, command useCases.ListArticlesCommand) (time.Duration, useCases.ArticlePage) {
|
||||
t.Helper()
|
||||
slowest := time.Duration(0)
|
||||
var page useCases.ArticlePage
|
||||
for run := 0; run < 3; run++ {
|
||||
started := time.Now()
|
||||
found, err := list.ListArticlesPage(command)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
page = found
|
||||
slowest = max(slowest, time.Since(started))
|
||||
}
|
||||
return slowest, page
|
||||
}
|
||||
@@ -164,7 +164,7 @@ func TestSeoOutputList(t *testing.T) {
|
||||
})
|
||||
}
|
||||
list := e.list(t, "owner", "?seo=suggestions")
|
||||
if !reflect.DeepEqual(list["counts"], map[string]any{"all": 6.0, "online": 4.0, "draft": 2.0}) || list["total"] != 3.0 {
|
||||
if !reflect.DeepEqual(list["counts"], map[string]any{"all": 6.0, "online": 4.0, "draft": 2.0, "suggestions": 3.0}) || list["total"] != 3.0 {
|
||||
t.Errorf("list = %v %v", list["counts"], list["total"])
|
||||
}
|
||||
})
|
||||
@@ -227,8 +227,68 @@ func TestSeoOutputList(t *testing.T) {
|
||||
t.Run("The existing list behaviour is unchanged without the filter", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
list := e.list(t, "owner", "")
|
||||
if list["total"] != 2.0 || list["page"] != 1.0 || list["size"] != 25.0 || !reflect.DeepEqual(list["counts"], map[string]any{"all": 2.0, "online": 1.0, "draft": 1.0}) {
|
||||
if list["total"] != 2.0 || list["page"] != 1.0 || list["size"] != 25.0 || !reflect.DeepEqual(list["counts"], map[string]any{"all": 2.0, "online": 1.0, "draft": 1.0, "suggestions": 2.0}) {
|
||||
t.Errorf("list = %v", list)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("counts.suggestions", func(t *testing.T) {
|
||||
t.Run("It counts visible suggestions and equals the filter total", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.seedFourListed()
|
||||
list := e.list(t, "owner", "")
|
||||
filtered := e.list(t, "owner", "?seo=suggestions")
|
||||
counts := list["counts"].(map[string]any)
|
||||
if counts["suggestions"] != 2.0 || filtered["total"] != 2.0 || filtered["counts"].(map[string]any)["suggestions"] != 2.0 {
|
||||
t.Errorf("counts = %v filtered = %v", counts, filtered["counts"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("It is zero when nothing needs attention", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.cleanArticles()
|
||||
for id := uint32(1); id <= 5; id++ {
|
||||
e.seedListed(id, "Clean"+strconv.Itoa(int(id)), true, nil)
|
||||
}
|
||||
if got := e.list(t, "owner", "")["counts"].(map[string]any)["suggestions"]; got != 0.0 {
|
||||
t.Errorf("suggestions = %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("It is scoped to the caller's visibility", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.cleanArticles()
|
||||
e.seedListed(1, "Offline", false, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = nil, "" })
|
||||
e.seedListed(2, "Online", true, func(a *article.Article) { a.CoverImageID, a.CoverImageAlt = nil, "" })
|
||||
if got := e.list(t, "", "")["counts"].(map[string]any)["suggestions"]; got != 1.0 {
|
||||
t.Errorf("anonymous suggestions = %v", got)
|
||||
}
|
||||
if got := e.list(t, "owner", "")["counts"].(map[string]any)["suggestions"]; got != 2.0 {
|
||||
t.Errorf("owner suggestions = %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("It ignores the other query parameters", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.seedFourListed()
|
||||
for _, query := range []string{"?q=nomatch", "?status=draft", "?seo=ready", "?size=5&page=9", "?sort=title:asc"} {
|
||||
if got := e.list(t, "owner", query)["counts"].(map[string]any)["suggestions"]; got != 2.0 {
|
||||
t.Errorf("%s: suggestions = %v", query, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Hidden and dismissed articles are not counted", func(t *testing.T) {
|
||||
e := newSeoOutEnv(t)
|
||||
e.cleanArticles()
|
||||
e.seedListed(1, "Dismissed", true, func(a *article.Article) {
|
||||
a.CoverImageID, a.CoverImageAlt = nil, ""
|
||||
a.SEODismissed = []string{"cover_none"}
|
||||
})
|
||||
e.seedListed(2, "Hidden", true, func(a *article.Article) { a.Noindex = true; a.CoverImageID, a.CoverImageAlt = nil, "" })
|
||||
if got := e.list(t, "owner", "")["counts"].(map[string]any)["suggestions"]; got != 0.0 {
|
||||
t.Errorf("suggestions = %v", got)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/site"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSiteRepositoryFind(t *testing.T) {
|
||||
db := openUnmigratedSQLite(t)
|
||||
if err := database.Migrate(db, "https://renew.example.com"); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
repo := persistence.NewSiteRepository(db)
|
||||
for i := 0; i < 12; i++ {
|
||||
_, err := repo.Create(site.Site{Name: fmt.Sprintf("Shop %02d", i), Key: fmt.Sprintf("shop%02d", i), BaseURL: "https://shop.example.net", DefaultRobots: site.RobotsIndex})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := repo.Create(site.Site{Name: "100% Café", Key: "cafe", BaseURL: "https://cafe.example.net", DefaultRobots: site.RobotsIndex}); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
t.Run("Unpaged returns everything with the default first", func(t *testing.T) {
|
||||
page, err := repo.Find(site.ListQuery{})
|
||||
if err != nil || page.Total != 14 || len(page.Items) != 14 || !page.Items[0].IsDefault {
|
||||
t.Fatalf("page %+v err %v", page.Total, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Paging slices after the search", func(t *testing.T) {
|
||||
page, err := repo.Find(site.ListQuery{Q: "SHOP", Page: 2, Size: 5})
|
||||
if err != nil || page.Total != 12 || len(page.Items) != 5 || page.Items[0].Name != "Shop 05" {
|
||||
t.Fatalf("page %+v err %v", page, err)
|
||||
}
|
||||
past, err := repo.Find(site.ListQuery{Q: "shop", Page: 9, Size: 5})
|
||||
if err != nil || past.Total != 12 || past.Items == nil || len(past.Items) != 0 {
|
||||
t.Fatalf("past %+v err %v", past, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Wildcards are literal and accents are ignored", func(t *testing.T) {
|
||||
for _, q := range []string{"%", "cafe", "CAFÉ"} {
|
||||
page, err := repo.Find(site.ListQuery{Q: q})
|
||||
if err != nil || page.Total != 1 || page.Items[0].Key != "cafe" {
|
||||
t.Fatalf("%q: %+v err %v", q, page, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func seedPagedSites(e *siteEnv, count int, shops int) {
|
||||
e.sites.seed(1, "Renew", "renew", true)
|
||||
for id := uint32(2); id <= uint32(count); id++ {
|
||||
name := fmt.Sprintf("Site %02d", id)
|
||||
key := fmt.Sprintf("site%02d", id)
|
||||
if int(id) <= shops+1 {
|
||||
name = fmt.Sprintf("Shop %02d", id)
|
||||
key = fmt.Sprintf("shop%02d", id)
|
||||
}
|
||||
e.sites.seed(id, name, key, false)
|
||||
}
|
||||
}
|
||||
|
||||
func siteNames(sites []site.Site) []string {
|
||||
names := []string{}
|
||||
for _, s := range sites {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func siteIDs(sites []site.Site) []uint32 {
|
||||
ids := []uint32{}
|
||||
for _, s := range sites {
|
||||
ids = append(ids, s.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func TestSitesPagination(t *testing.T) {
|
||||
list := func(t *testing.T, e *siteEnv, query string) (*httptest.ResponseRecorder, []site.Site) {
|
||||
t.Helper()
|
||||
rec := e.owner(t, http.MethodGet, "/v1/site/"+query, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
return rec, nil
|
||||
}
|
||||
return rec, decodeSiteList(t, rec)
|
||||
}
|
||||
|
||||
t.Run("With no paging parameter every site is returned", func(t *testing.T) {
|
||||
e := newSiteEnv(t)
|
||||
seedPagedSites(e, 61, 0)
|
||||
rec, sites := list(t, e, "")
|
||||
if len(sites) != 61 || sites[0].Name != "Renew" || rec.Header().Get("X-Total-Count") != "61" {
|
||||
t.Fatalf("got %d sites, first %q, total %q", len(sites), sites[0].Name, rec.Header().Get("X-Total-Count"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("q alone returns every match", func(t *testing.T) {
|
||||
e := newSiteEnv(t)
|
||||
seedPagedSites(e, 61, 40)
|
||||
rec, sites := list(t, e, "?q=shop")
|
||||
if len(sites) != 40 || rec.Header().Get("X-Total-Count") != "40" {
|
||||
t.Fatalf("got %d sites, total %q", len(sites), rec.Header().Get("X-Total-Count"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Paging and sizes", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
params string
|
||||
count int
|
||||
}{{"page=1", 25}, {"page=2", 25}, {"page=3", 11}, {"size=5", 5}, {"size=25", 25}, {"size=50&page=2", 11}, {"size=100", 61}}
|
||||
for _, c := range cases {
|
||||
t.Run(c.params, func(t *testing.T) {
|
||||
e := newSiteEnv(t)
|
||||
seedPagedSites(e, 61, 0)
|
||||
rec, sites := list(t, e, "?"+c.params)
|
||||
if len(sites) != c.count || rec.Header().Get("X-Total-Count") != "61" {
|
||||
t.Fatalf("got %d sites, total %q", len(sites), rec.Header().Get("X-Total-Count"))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Pages never overlap and keep the order", func(t *testing.T) {
|
||||
e := newSiteEnv(t)
|
||||
seedPagedSites(e, 61, 0)
|
||||
_, unpaged := list(t, e, "")
|
||||
joined := []site.Site{}
|
||||
for page := 1; page <= 3; page++ {
|
||||
_, part := list(t, e, "?page="+strconv.Itoa(page))
|
||||
joined = append(joined, part...)
|
||||
}
|
||||
if !reflect.DeepEqual(siteIDs(joined), siteIDs(unpaged)) || joined[0].Name != "Renew" {
|
||||
t.Fatalf("pages differ from the unpaged list")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A page past the end is an empty array with the total", func(t *testing.T) {
|
||||
e := newSiteEnv(t)
|
||||
seedPagedSites(e, 61, 0)
|
||||
rec, _ := list(t, e, "?page=99")
|
||||
if strings.TrimSpace(rec.Body.String()) != "[]" || rec.Header().Get("X-Total-Count") != "61" {
|
||||
t.Fatalf("body %q total %q", rec.Body.String(), rec.Header().Get("X-Total-Count"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No match is an empty array", func(t *testing.T) {
|
||||
e := newSiteEnv(t)
|
||||
seedPagedSites(e, 3, 0)
|
||||
rec, _ := list(t, e, "?q=nomatch")
|
||||
if strings.TrimSpace(rec.Body.String()) != "[]" || rec.Header().Get("X-Total-Count") != "0" {
|
||||
t.Fatalf("body %q total %q", rec.Body.String(), rec.Header().Get("X-Total-Count"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Search matches name, key and base URL", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
q string
|
||||
names []string
|
||||
}{
|
||||
{"renew", []string{"Renew"}},
|
||||
{"JOURNAL", []string{"Journal"}},
|
||||
{"blog", []string{"Journal"}},
|
||||
{"example.org", []string{"Journal"}},
|
||||
{"example", []string{"Renew", "Archive", "Journal"}},
|
||||
{"nobody", []string{}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.q, func(t *testing.T) {
|
||||
e := newSiteEnv(t)
|
||||
e.sites.seed(1, "Renew", "renew", true)
|
||||
journal := e.sites.seed(2, "Journal", "blog", false)
|
||||
journal.BaseURL = "https://journal.example.org"
|
||||
e.sites.sites[2] = journal
|
||||
archive := e.sites.seed(3, "Archive", "archive", false)
|
||||
archive.BaseURL = "https://old.example.net"
|
||||
e.sites.sites[3] = archive
|
||||
rec, sites := list(t, e, "?q="+url.QueryEscape(c.q))
|
||||
if !reflect.DeepEqual(siteNames(sites), c.names) || rec.Header().Get("X-Total-Count") != strconv.Itoa(len(c.names)) {
|
||||
t.Fatalf("names %v total %q", siteNames(sites), rec.Header().Get("X-Total-Count"))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Search ignores accents", func(t *testing.T) {
|
||||
e := newSiteEnv(t)
|
||||
e.sites.seed(1, "Café", "cafe", true)
|
||||
for _, q := range []string{"cafe", "CAFÉ"} {
|
||||
rec, sites := list(t, e, "?q="+url.QueryEscape(q))
|
||||
if !reflect.DeepEqual(siteNames(sites), []string{"Café"}) || rec.Header().Get("X-Total-Count") != "1" {
|
||||
t.Fatalf("%s: names %v", q, siteNames(sites))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Search and paging compose and the total follows the search", func(t *testing.T) {
|
||||
e := newSiteEnv(t)
|
||||
seedPagedSites(e, 61, 27)
|
||||
rec, sites := list(t, e, "?q=shop&page=2")
|
||||
if len(sites) != 2 || rec.Header().Get("X-Total-Count") != "27" {
|
||||
t.Fatalf("got %d sites, total %q", len(sites), rec.Header().Get("X-Total-Count"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The default site is not forced into the results", func(t *testing.T) {
|
||||
e := newSiteEnv(t)
|
||||
e.sites.seed(1, "Renew", "renew", true)
|
||||
e.sites.seed(2, "Journal", "blog", false)
|
||||
_, sites := list(t, e, "?q=journal")
|
||||
if !reflect.DeepEqual(siteNames(sites), []string{"Journal"}) {
|
||||
t.Fatalf("names %v", siteNames(sites))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A wildcard in q is literal", func(t *testing.T) {
|
||||
e := newSiteEnv(t)
|
||||
e.sites.seed(1, "Main", "main", true)
|
||||
e.sites.seed(2, "100% Shop", "shop", false)
|
||||
_, sites := list(t, e, "?q=%25")
|
||||
if !reflect.DeepEqual(siteNames(sites), []string{"100% Shop"}) {
|
||||
t.Fatalf("names %v", siteNames(sites))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid page, size or q", func(t *testing.T) {
|
||||
for _, params := range []string{"page=0", "page=-1", "page=abc", "page=", "size=0", "size=30", "size=101", "size=abc", "size=", "q=" + strings.Repeat("a", 201)} {
|
||||
t.Run(params[:min(len(params), 12)], func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
rec := e.owner(t, http.MethodGet, "/v1/site/?"+params, "")
|
||||
expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage)
|
||||
if _, present := rec.Header()["X-Total-Count"]; present {
|
||||
t.Errorf("X-Total-Count sent on a refusal")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("q at the limit is accepted", func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
rec, _ := list(t, e, "?q="+strings.Repeat("a", 200))
|
||||
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" || rec.Header().Get("X-Total-Count") != "0" {
|
||||
t.Fatalf("status %d body %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The other fields are unchanged", func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
e.seedArticle(1, siteMainID, true)
|
||||
rec, sites := list(t, e, "?size=5")
|
||||
if len(sites) != 1 || sites[0].ArticleCount != 1 || !strings.Contains(rec.Body.String(), `"article_count"`) {
|
||||
t.Fatalf("body %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Readers and refusals", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
credentials string
|
||||
send func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder
|
||||
status int
|
||||
}{
|
||||
{"the owner's jwt cookie", func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.as(t, apiKeyOwnerId, http.MethodGet, "/v1/site/?size=5", "")
|
||||
}, http.StatusOK},
|
||||
{"a verified admin's jwt cookie", func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.as(t, apiKeyDevId, http.MethodGet, "/v1/site/?size=5", "")
|
||||
}, http.StatusOK},
|
||||
{"a valid API key", func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
raw, _ := e.issueKey(apiKeyDevId, "Deploy bot")
|
||||
return e.asKey(t, raw, http.MethodGet, "/v1/site/?size=5", "")
|
||||
}, http.StatusOK},
|
||||
{"an unverified account's jwt cookie", func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/site/?size=5", "")
|
||||
}, http.StatusForbidden},
|
||||
{"no credentials", func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder {
|
||||
return e.anonymous(t, http.MethodGet, "/v1/site/?size=5", "")
|
||||
}, http.StatusUnauthorized},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.credentials, func(t *testing.T) {
|
||||
e := newSiteEnvWithMain(t)
|
||||
rec := c.send(e, t)
|
||||
_, present := rec.Header()["X-Total-Count"]
|
||||
if rec.Code != c.status || present != (c.status == http.StatusOK) {
|
||||
t.Errorf("status %d, header present %v", rec.Code, present)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -92,6 +92,29 @@ func (m *mockSiteRepo) List() ([]site.Site, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) Find(query site.ListQuery) (site.Page, error) {
|
||||
if err := m.fail.trigger("Find"); err != nil {
|
||||
return site.Page{}, err
|
||||
}
|
||||
all, err := m.List()
|
||||
if err != nil {
|
||||
return site.Page{}, err
|
||||
}
|
||||
needle := foldArticleSearch(strings.TrimSpace(query.Q))
|
||||
matching := []site.Site{}
|
||||
for _, s := range all {
|
||||
if needle == "" || strings.Contains(foldArticleSearch(s.Name+"\n"+s.Key+"\n"+s.BaseURL), needle) {
|
||||
matching = append(matching, s)
|
||||
}
|
||||
}
|
||||
if query.Size <= 0 {
|
||||
return site.Page{Items: matching, Total: len(matching)}, nil
|
||||
}
|
||||
start := min((max(query.Page, 1)-1)*query.Size, len(matching))
|
||||
end := min(start+query.Size, len(matching))
|
||||
return site.Page{Items: matching[start:end], Total: len(matching)}, nil
|
||||
}
|
||||
|
||||
func (m *mockSiteRepo) Get(id uint32) (site.Site, error) {
|
||||
if err := m.fail.trigger("Get"); err != nil {
|
||||
return site.Site{}, err
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: 'range' | 'error'
|
||||
failedPage: number
|
||||
shownPage: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
const isError = computed(() => props.kind === 'error')
|
||||
|
||||
const text = computed(() => {
|
||||
if (props.kind === 'range') {
|
||||
return t("That page doesn't exist. Showing the last page.")
|
||||
}
|
||||
return t("Couldn't load page {0}. Showing page {1}.", [props.failedPage, props.shownPage])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAlert
|
||||
:role="isError ? 'alert' : 'status'"
|
||||
:color="isError ? 'error' : 'info'"
|
||||
variant="soft"
|
||||
orientation="horizontal"
|
||||
:close="false"
|
||||
:icon="isError ? 'i-lucide-circle-x' : 'i-lucide-info'"
|
||||
:description="text"
|
||||
:ui="{
|
||||
root: 'items-center gap-2.5 px-3.5 py-2.5',
|
||||
icon: 'size-4',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
>
|
||||
<template v-if="isError" #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="$emit('retry')"
|
||||
>
|
||||
{{ t('Retry') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
</template>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import ArticleSiteFilter from '@/components/ArticleSiteFilter.vue'
|
||||
import NeedsAttentionToggle from '@/components/NeedsAttentionToggle.vue'
|
||||
import type { ArticleCounts, ArticleStatus } from '@/composables/useArticles'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
|
||||
|
||||
const props = defineProps<{
|
||||
status: '' | ArticleStatus
|
||||
counts: ArticleCounts
|
||||
needsAttentionCount: number | null
|
||||
needsAttention: boolean
|
||||
site: string
|
||||
sites: Site[]
|
||||
severalSites: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:status': [status: '' | ArticleStatus]
|
||||
'update:needsAttention': [on: boolean]
|
||||
'update:site': [site: string]
|
||||
}>()
|
||||
|
||||
const search = defineModel<string>('search', { required: true })
|
||||
|
||||
const activeTab = computed<'' | ArticleStatus>({
|
||||
get: () => props.status,
|
||||
set: (value) => emit('update:status', value),
|
||||
})
|
||||
|
||||
const tabItems = computed(() => [
|
||||
{
|
||||
get label() {
|
||||
return t('All {0}', [props.counts.all])
|
||||
},
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Online {0}', [props.counts.online])
|
||||
},
|
||||
value: 'online',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Drafts {0}', [props.counts.draft])
|
||||
},
|
||||
value: 'draft',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col-reverse gap-3 md:flex-row md:items-end md:gap-4 md:border-b md:border-default"
|
||||
>
|
||||
<UTabs
|
||||
v-model="activeTab"
|
||||
:items="tabItems"
|
||||
variant="link"
|
||||
color="neutral"
|
||||
:content="false"
|
||||
class="md:-mb-px md:flex-1"
|
||||
:ui="{
|
||||
list: 'gap-5 border-b-0 p-0 max-md:border-b',
|
||||
trigger: `${UNDERLINE_TAB_TRIGGER} max-md:text-base`,
|
||||
}"
|
||||
/>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end md:gap-4 md:pb-1.5">
|
||||
<UInput
|
||||
v-model="search"
|
||||
type="search"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="t('Search by title')"
|
||||
class="w-full md:w-65"
|
||||
:ui="{
|
||||
base: 'h-11 text-lg md:h-8 md:text-sm',
|
||||
leadingIcon: 'size-4 md:size-3.5',
|
||||
}"
|
||||
/>
|
||||
<NeedsAttentionToggle
|
||||
:model-value="needsAttention"
|
||||
:count="needsAttentionCount"
|
||||
class="md:-order-1"
|
||||
@update:model-value="emit('update:needsAttention', $event)"
|
||||
/>
|
||||
<ArticleSiteFilter
|
||||
v-if="severalSites"
|
||||
:model-value="site"
|
||||
:sites="sites"
|
||||
@update:model-value="emit('update:site', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import PublishControl from '@/components/PublishControl.vue'
|
||||
import SaveStatus from '@/components/SaveStatus.vue'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import type { ArticleDraftState } from '@/composables/useArticleDraft'
|
||||
|
||||
defineProps<{
|
||||
article: Article
|
||||
state: ArticleDraftState
|
||||
savedAt: Date | null
|
||||
countText: string
|
||||
saveEnabled: boolean
|
||||
publishPending: string | undefined
|
||||
publishDisabled: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
retry: []
|
||||
save: []
|
||||
publish: []
|
||||
unpublish: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const previewOn = defineModel<boolean>('preview', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-default bg-default px-4 py-3 lg:gap-3 lg:px-6 lg:py-3.5"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-arrow-left"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="hidden size-8 justify-center text-toned lg:inline-flex"
|
||||
:aria-label="t('Back to Articles')"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<div
|
||||
class="flex min-w-0 flex-1 basis-full flex-wrap items-center gap-2.5 lg:min-w-50 lg:basis-50"
|
||||
>
|
||||
<h1
|
||||
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
|
||||
:title="t('The title can\'t be changed after creation.')"
|
||||
>
|
||||
{{ article.title }}
|
||||
</h1>
|
||||
<ArticleStatusBadge :online="article.is_online" size="lg" />
|
||||
</div>
|
||||
<SaveStatus :state="state" :saved-at="savedAt" @retry="emit('retry')" />
|
||||
<span class="ml-auto font-mono text-xs text-muted lg:hidden">{{ countText }}</span>
|
||||
<div class="hidden items-center gap-3 lg:flex">
|
||||
<USwitch
|
||||
v-model="previewOn"
|
||||
:label="t('Preview')"
|
||||
:ui="{
|
||||
root: 'items-center gap-2',
|
||||
container: 'h-auto',
|
||||
base: 'h-4.5 w-8 p-0.5',
|
||||
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-2.5',
|
||||
wrapper: 'ms-0',
|
||||
label: 'text-sm font-normal text-toned',
|
||||
}"
|
||||
/>
|
||||
<UButton
|
||||
data-testid="save-button"
|
||||
icon="i-lucide-save"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-8 gap-1.5 px-3"
|
||||
:disabled="!saveEnabled"
|
||||
@click="emit('save')"
|
||||
>
|
||||
{{ t('Save') }}
|
||||
</UButton>
|
||||
<PublishControl
|
||||
class="h-8 gap-1.5 px-3"
|
||||
:online="article.is_online"
|
||||
:dirty="state === 'dirty'"
|
||||
:pending="publishPending"
|
||||
:disabled="publishDisabled"
|
||||
@publish="emit('publish')"
|
||||
@unpublish="emit('unpublish')"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
class="h-8 gap-1.5 px-3"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
{{ t('Delete') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import PublishControl from '@/components/PublishControl.vue'
|
||||
import type { ArticleDraftState } from '@/composables/useArticleDraft'
|
||||
|
||||
defineProps<{
|
||||
online: boolean
|
||||
state: ArticleDraftState
|
||||
saveEnabled: boolean
|
||||
publishPending: string | undefined
|
||||
publishDisabled: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
details: []
|
||||
save: []
|
||||
publish: []
|
||||
unpublish: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden">
|
||||
<UButton
|
||||
icon="i-lucide-panel-right"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="size-11 justify-center"
|
||||
:aria-label="t('Details')"
|
||||
@click="emit('details')"
|
||||
/>
|
||||
<UButton
|
||||
data-testid="save-button-mobile"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 flex-1 justify-center"
|
||||
:disabled="!saveEnabled"
|
||||
@click="emit('save')"
|
||||
>
|
||||
{{ t('Save') }}
|
||||
</UButton>
|
||||
<PublishControl
|
||||
class="h-11 flex-1 justify-center"
|
||||
hide-icon
|
||||
:online="online"
|
||||
:dirty="state === 'dirty'"
|
||||
:pending="publishPending"
|
||||
:disabled="publishDisabled"
|
||||
@publish="emit('publish')"
|
||||
@unpublish="emit('unpublish')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div aria-busy="true" class="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
class="flex flex-shrink-0 items-center gap-2.5 border-b border-default bg-default px-6 py-3.5"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-arrow-left"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="t('Back to Articles')"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<USkeleton class="h-5 w-80 rounded-none" data-testid="skeleton-title" />
|
||||
<USkeleton class="h-5.5 w-15 rounded-none" />
|
||||
</div>
|
||||
<div
|
||||
class="min-h-0 max-w-168 flex-1 space-y-3.5 px-12 py-10"
|
||||
data-testid="skeleton-content"
|
||||
>
|
||||
<USkeleton class="h-3.5 w-full rounded-none" />
|
||||
<USkeleton class="h-3.5 w-11/12 rounded-none" />
|
||||
<USkeleton class="h-3.5 w-4/5 rounded-none" />
|
||||
<USkeleton class="mt-4.5 h-5.5 w-2/5 rounded-none" />
|
||||
<USkeleton class="h-3.5 w-full rounded-none" />
|
||||
<USkeleton class="h-3.5 w-3/4 rounded-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="w-70 flex-shrink-0 space-y-2.5 border-default p-5 lg:border-l"
|
||||
data-testid="skeleton-details"
|
||||
>
|
||||
<div class="flex flex-col gap-3 border-b border-default pb-4">
|
||||
<div class="flex min-h-6 items-center gap-2 text-highlighted">
|
||||
<UIcon name="i-lucide-chevron-down" class="size-3.5 -rotate-90" />
|
||||
<span class="text-sm font-semibold">{{ t('Site and authors') }}</span>
|
||||
</div>
|
||||
<USkeleton class="ml-5.5 h-3 w-2/5 rounded-none" data-testid="skeleton-meta" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 border-b border-default pb-4">
|
||||
<div class="flex min-h-6 items-center gap-2 text-highlighted">
|
||||
<UIcon name="i-lucide-chevron-down" class="size-3.5 -rotate-90" />
|
||||
<span class="text-sm font-semibold">{{ t('Search and sharing') }}</span>
|
||||
</div>
|
||||
<USkeleton class="ml-5.5 h-3 w-2/5 rounded-none" data-testid="skeleton-seo" />
|
||||
</div>
|
||||
<USkeleton class="h-3 w-full rounded-none" />
|
||||
<USkeleton class="h-3 w-full rounded-none" />
|
||||
<USkeleton class="h-3 w-3/5 rounded-none" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import FallbackChip from '@/components/FallbackChip.vue'
|
||||
import SeoDisclosure from '@/components/SeoDisclosure.vue'
|
||||
import { SEO_LIMITS } from '@/lib/articleSeo'
|
||||
import { chipLabel } from '@/lib/seoCopy'
|
||||
import { monoFieldUi } from '@/lib/seoFieldUi'
|
||||
|
||||
defineProps<{
|
||||
canonicalUrl: string
|
||||
placeholder: string
|
||||
summary: string
|
||||
error: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
type: [value: string]
|
||||
leave: []
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SeoDisclosure v-model:open="open" :title="t('Advanced')" :summary="summary">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField
|
||||
:label="t('Canonical URL')"
|
||||
optional
|
||||
:error="error"
|
||||
:help="t('Only change this if the same content lives at another address.')"
|
||||
>
|
||||
<UInput
|
||||
:model-value="canonicalUrl"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="SEO_LIMITS.canonical_url"
|
||||
:color="error ? 'error' : undefined"
|
||||
:aria-invalid="error ? 'true' : undefined"
|
||||
inputmode="url"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="monoFieldUi"
|
||||
@update:model-value="emit('type', String($event))"
|
||||
@focusout="emit('leave')"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip v-if="!canonicalUrl" :label="chipLabel('canonical')" />
|
||||
</div>
|
||||
</SeoDisclosure>
|
||||
</template>
|
||||
+75
-267
@@ -1,41 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { createReusableTemplate, useMediaQuery } from '@vueuse/core'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import DirectoryPanel from '@/components/DirectoryPanel.vue'
|
||||
import FallbackChip from '@/components/FallbackChip.vue'
|
||||
import MediaField from '@/components/MediaField.vue'
|
||||
import SearchPreview from '@/components/SearchPreview.vue'
|
||||
import SeoDisclosure from '@/components/SeoDisclosure.vue'
|
||||
import SeoAdvancedSection from '@/components/SeoAdvancedSection.vue'
|
||||
import SeoSocialSection from '@/components/SeoSocialSection.vue'
|
||||
import SlugField from '@/components/SlugField.vue'
|
||||
import SocialCardPreview from '@/components/SocialCardPreview.vue'
|
||||
import SoftCounter from '@/components/SoftCounter.vue'
|
||||
import SuggestionList from '@/components/SuggestionList.vue'
|
||||
import type { SeoCheckPhase } from '@/composables/useSeoCheck'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { useMediaNames } from '@/composables/useMediaNames'
|
||||
import { useSeoPanelCanonical } from '@/composables/useSeoPanelCanonical'
|
||||
import { useSeoPanelImages } from '@/composables/useSeoPanelImages'
|
||||
import { useSeoPanelSlug } from '@/composables/useSeoPanelSlug'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import {
|
||||
SEO_LIMITS,
|
||||
canonicalIsValid,
|
||||
charCount,
|
||||
normalizeSlug,
|
||||
typedSlug,
|
||||
type ArticleSeo,
|
||||
} from '@/lib/articleSeo'
|
||||
import { slugify } from '@/lib/directory'
|
||||
import { SEO_LIMITS, charCount, type ArticleSeo } from '@/lib/articleSeo'
|
||||
import { chipLabel, descriptionChip } from '@/lib/seoCopy'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
import { areaUi, fieldUi } from '@/lib/seoFieldUi'
|
||||
import {
|
||||
DESCRIPTION_RANGE,
|
||||
TITLE_RANGE,
|
||||
hasSocialOverrides,
|
||||
siteDisplayName,
|
||||
previewDescription,
|
||||
previewDescriptionSource,
|
||||
socialImageSource,
|
||||
type PreviewDevice,
|
||||
type SeoPreviewInput,
|
||||
} from '@/lib/seoPreview'
|
||||
@@ -86,18 +79,9 @@ const [DefinePreviews, Previews] = createReusableTemplate()
|
||||
const [DefinePreviewButton, PreviewButton] = createReusableTemplate()
|
||||
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const names = useMediaNames()
|
||||
const layout = ref<HTMLElement | null>(null)
|
||||
const focusTarget = ref<SeoFocus | null>(props.focus)
|
||||
const device = ref<PreviewDevice>('desktop')
|
||||
const socialOpen = ref(false)
|
||||
const advancedOpen = ref(props.canonicalRejected)
|
||||
const canonicalMessage = ref('')
|
||||
const generatedSlug = computed(() => slugify(props.articleTitle, SEO_LIMITS.slug))
|
||||
const slugManual = ref(
|
||||
props.modelValue.slug !== '' && props.modelValue.slug !== generatedSlug.value,
|
||||
)
|
||||
const slugText = ref(props.modelValue.slug || generatedSlug.value)
|
||||
|
||||
const seo = computed(() => props.modelValue)
|
||||
const siteName = computed(() => siteDisplayName(props.site))
|
||||
@@ -107,66 +91,53 @@ const previewInput = computed<SeoPreviewInput>(() => ({
|
||||
body: props.body,
|
||||
site: props.site,
|
||||
}))
|
||||
const displaySlug = computed(() => (slugManual.value ? slugText.value : generatedSlug.value))
|
||||
const slugMode = computed(() => {
|
||||
if (slugManual.value) {
|
||||
return 'manual' as const
|
||||
}
|
||||
return generatedSlug.value ? ('auto' as const) : ('empty' as const)
|
||||
})
|
||||
const siteImageId = computed(() => props.site?.default_social_image_id ?? null)
|
||||
const siteImageName = computed(() => {
|
||||
const id = siteImageId.value
|
||||
if (id === null) {
|
||||
return ''
|
||||
}
|
||||
return names.itemOf(id)?.filename ?? t('Image #{0}', [id])
|
||||
})
|
||||
const coverItem = computed(() => names.itemOf(seo.value.cover_image_id))
|
||||
const socialItem = computed(() => names.itemOf(seo.value.social_image_id))
|
||||
const cardSource = computed(() => socialImageSource(seo.value, siteImageId.value !== null))
|
||||
const cardImageItem = computed(() => {
|
||||
const items = {
|
||||
social: socialItem.value,
|
||||
cover: coverItem.value,
|
||||
site: names.itemOf(siteImageId.value),
|
||||
none: null,
|
||||
}
|
||||
return items[cardSource.value]
|
||||
})
|
||||
const cardImageName = computed(() => {
|
||||
const labels = {
|
||||
social: socialItem.value?.filename ?? t('Image #{0}', [seo.value.social_image_id ?? 0]),
|
||||
cover: coverItem.value?.filename ?? t('Image #{0}', [seo.value.cover_image_id ?? 0]),
|
||||
site: siteImageName.value,
|
||||
none: '',
|
||||
}
|
||||
return labels[cardSource.value]
|
||||
|
||||
function update(patch: Partial<ArticleSeo>, immediate = false) {
|
||||
emit('update:modelValue', { ...props.modelValue, ...patch })
|
||||
emit('check', immediate)
|
||||
}
|
||||
|
||||
const {
|
||||
siteImageId,
|
||||
siteImageName,
|
||||
coverItem,
|
||||
socialItem,
|
||||
cardSource,
|
||||
cardImageItem,
|
||||
cardImageName,
|
||||
remember,
|
||||
} = useSeoPanelImages(seo, () => props.site)
|
||||
|
||||
const {
|
||||
displaySlug,
|
||||
slugMode,
|
||||
slugSuggestion,
|
||||
slugError,
|
||||
slugUrl,
|
||||
typeSlug,
|
||||
unlockSlug,
|
||||
resetSlug,
|
||||
useSuggestedSlug,
|
||||
checkSlug,
|
||||
} = useSeoPanelSlug({
|
||||
seo,
|
||||
articleTitle: () => props.articleTitle,
|
||||
savedSlug: () => props.savedSlug,
|
||||
site: () => props.site,
|
||||
siteName,
|
||||
slugTakenOnSave: () => props.slugTakenOnSave,
|
||||
slugVerdict: () => props.slugVerdict,
|
||||
checkedSlug: () => props.checkedSlug,
|
||||
update,
|
||||
requestCheck: () => emit('check', true),
|
||||
})
|
||||
|
||||
const slugTaken = computed(() => {
|
||||
if (props.slugTakenOnSave !== null && props.slugTakenOnSave === seo.value.slug) {
|
||||
return true
|
||||
}
|
||||
const verdict = props.slugVerdict
|
||||
return (
|
||||
verdict !== null &&
|
||||
!verdict.available &&
|
||||
props.checkedSlug !== undefined &&
|
||||
props.checkedSlug === seo.value.slug
|
||||
)
|
||||
})
|
||||
const slugSuggestion = computed(() =>
|
||||
slugTaken.value && props.checkedSlug === seo.value.slug
|
||||
? (props.slugVerdict?.suggestion ?? '')
|
||||
: '',
|
||||
)
|
||||
const slugError = computed(() =>
|
||||
slugTaken.value
|
||||
? t('The slug "{0}" is already used on {1}.', [displaySlug.value, siteName.value])
|
||||
: '',
|
||||
)
|
||||
const slugUrl = computed(() => `${props.site?.base_url ?? ''}/${displaySlug.value}`)
|
||||
const { advancedOpen, advancedSummary, canonicalError, typeCanonical, canonicalBlocked } =
|
||||
useSeoPanelCanonical({
|
||||
seo,
|
||||
rejected: () => props.canonicalRejected,
|
||||
update,
|
||||
})
|
||||
|
||||
const descriptionPlaceholder = computed(() => previewDescription(previewInput.value))
|
||||
const descriptionChipLabel = computed(() =>
|
||||
@@ -184,40 +155,11 @@ const coverHelp = computed(() => {
|
||||
? t('Using the site social image ({0}).', [siteImageName.value])
|
||||
: t('Using the site social image.')
|
||||
})
|
||||
const coverPicker = computed(() =>
|
||||
pickerCopy(t('Choose a cover image'), t('The cover for this article.')),
|
||||
)
|
||||
const socialPicker = computed(() =>
|
||||
pickerCopy(t('Choose a social image'), t('The image shown when this article is shared.')),
|
||||
)
|
||||
const customised = computed(() => hasSocialOverrides(seo.value))
|
||||
const socialSummary = computed(() => {
|
||||
if (customised.value) {
|
||||
return t('Customised')
|
||||
}
|
||||
return socialOpen.value ? '' : t('Uses the meta values')
|
||||
})
|
||||
const advancedSummary = computed(() => {
|
||||
if (advancedOpen.value) {
|
||||
return ''
|
||||
}
|
||||
return seo.value.canonical_url ? t('Canonical URL set') : t('Canonical URL: automatic')
|
||||
})
|
||||
const canonicalError = computed(() =>
|
||||
canonicalMessage.value || props.canonicalRejected
|
||||
? t('Enter a full address starting with https://')
|
||||
: '',
|
||||
)
|
||||
const canonicalPlaceholder = computed(() => slugUrl.value)
|
||||
|
||||
function pickerCopy(title: string, description: string) {
|
||||
return { title, description, confirmLabel: t('Use image') }
|
||||
}
|
||||
|
||||
function update(patch: Partial<ArticleSeo>, immediate = false) {
|
||||
emit('update:modelValue', { ...props.modelValue, ...patch })
|
||||
emit('check', immediate)
|
||||
}
|
||||
const coverPicker = computed(() => ({
|
||||
title: t('Choose a cover image'),
|
||||
description: t('The cover for this article.'),
|
||||
confirmLabel: t('Use image'),
|
||||
}))
|
||||
|
||||
function typeText(
|
||||
key:
|
||||
@@ -232,56 +174,6 @@ function typeText(
|
||||
update({ [key]: value })
|
||||
}
|
||||
|
||||
function typeCanonical(value: string) {
|
||||
canonicalMessage.value = ''
|
||||
update({ canonical_url: value })
|
||||
}
|
||||
|
||||
function canonicalBlocked() {
|
||||
if (canonicalIsValid(seo.value.canonical_url)) {
|
||||
return false
|
||||
}
|
||||
canonicalMessage.value = 'invalid'
|
||||
advancedOpen.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
function checkCanonical() {
|
||||
canonicalBlocked()
|
||||
}
|
||||
|
||||
function autoSlug() {
|
||||
return generatedSlug.value === props.savedSlug ? props.savedSlug : ''
|
||||
}
|
||||
|
||||
function unlockSlug() {
|
||||
slugManual.value = true
|
||||
slugText.value = generatedSlug.value
|
||||
}
|
||||
|
||||
function typeSlug(value: string) {
|
||||
slugText.value = typedSlug(value)
|
||||
update({ slug: normalizeSlug(slugText.value) })
|
||||
}
|
||||
|
||||
function resetSlug() {
|
||||
slugManual.value = false
|
||||
slugText.value = generatedSlug.value
|
||||
update({ slug: autoSlug() }, true)
|
||||
}
|
||||
|
||||
function useSuggestedSlug(suggestion: string) {
|
||||
slugManual.value = true
|
||||
slugText.value = suggestion
|
||||
update({ slug: suggestion }, true)
|
||||
}
|
||||
|
||||
function checkSlug() {
|
||||
if (slugManual.value) {
|
||||
emit('check', true)
|
||||
}
|
||||
}
|
||||
|
||||
function setCover(id: number | null) {
|
||||
update({ cover_image_id: id }, true)
|
||||
}
|
||||
@@ -294,12 +186,6 @@ function setNoindex(value: boolean) {
|
||||
update({ noindex: value }, true)
|
||||
}
|
||||
|
||||
function remember(item: MediaItem | null) {
|
||||
if (item) {
|
||||
names.remember(item)
|
||||
}
|
||||
}
|
||||
|
||||
function goTo(field: SuggestionField) {
|
||||
if (!PANEL_FIELDS.includes(field)) {
|
||||
emit('go', field)
|
||||
@@ -317,26 +203,6 @@ function submit() {
|
||||
emit('submit')
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.canonicalRejected,
|
||||
(rejected) => {
|
||||
if (rejected) {
|
||||
advancedOpen.value = true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
names.resolve([siteImageId.value, seo.value.cover_image_id, seo.value.social_image_id])
|
||||
})
|
||||
|
||||
const DISABLED_FIELD = 'disabled:bg-elevated disabled:opacity-100'
|
||||
const fieldUi = { base: `h-11 text-lg text-ellipsis md:h-10 md:text-sm ${DISABLED_FIELD}` }
|
||||
const monoFieldUi = {
|
||||
base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${DISABLED_FIELD}`,
|
||||
}
|
||||
const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD}` }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -552,84 +418,26 @@ const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD
|
||||
</DirectoryField>
|
||||
</div>
|
||||
|
||||
<SeoDisclosure
|
||||
v-model:open="socialOpen"
|
||||
:title="t('Social sharing')"
|
||||
:tag="t('Optional')"
|
||||
:summary="socialSummary"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField :label="t('Social title')" optional>
|
||||
<UInput
|
||||
:model-value="seo.social_title"
|
||||
:placeholder="seo.seo_title || articleTitle"
|
||||
:maxlength="SEO_LIMITS.social_title"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="fieldUi"
|
||||
@update:model-value="typeText('social_title', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip v-if="!seo.social_title" :label="chipLabel('meta_title')" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField :label="t('Social description')" optional>
|
||||
<UTextarea
|
||||
:model-value="seo.social_description"
|
||||
:placeholder="descriptionPlaceholder"
|
||||
:maxlength="SEO_LIMITS.social_description"
|
||||
:rows="3"
|
||||
class="w-full"
|
||||
:ui="areaUi"
|
||||
@update:model-value="typeText('social_description', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip
|
||||
v-if="!seo.social_description"
|
||||
:label="chipLabel('meta_description')"
|
||||
/>
|
||||
</div>
|
||||
<MediaField
|
||||
:model-value="seo.social_image_id"
|
||||
:item="socialItem"
|
||||
:label="t('Social image')"
|
||||
:help="seo.social_image_id === null ? t('Using the cover image.') : ''"
|
||||
:picker="socialPicker"
|
||||
:disabled="saving"
|
||||
@update:item="remember"
|
||||
@update:model-value="setSocialImage"
|
||||
/>
|
||||
</SeoDisclosure>
|
||||
<SeoSocialSection
|
||||
:seo="seo"
|
||||
:article-title="articleTitle"
|
||||
:description-placeholder="descriptionPlaceholder"
|
||||
:social-item="socialItem"
|
||||
:saving="saving"
|
||||
@type="typeText"
|
||||
@remember="remember"
|
||||
@image="setSocialImage"
|
||||
/>
|
||||
|
||||
<SeoDisclosure
|
||||
<SeoAdvancedSection
|
||||
v-model:open="advancedOpen"
|
||||
:title="t('Advanced')"
|
||||
:canonical-url="seo.canonical_url"
|
||||
:placeholder="slugUrl"
|
||||
:summary="advancedSummary"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField
|
||||
:label="t('Canonical URL')"
|
||||
optional
|
||||
:error="canonicalError"
|
||||
:help="t('Only change this if the same content lives at another address.')"
|
||||
>
|
||||
<UInput
|
||||
:model-value="seo.canonical_url"
|
||||
:placeholder="canonicalPlaceholder"
|
||||
:maxlength="SEO_LIMITS.canonical_url"
|
||||
:color="canonicalError ? 'error' : undefined"
|
||||
:aria-invalid="canonicalError ? 'true' : undefined"
|
||||
inputmode="url"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="monoFieldUi"
|
||||
@update:model-value="typeCanonical(String($event))"
|
||||
@focusout="checkCanonical"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip v-if="!seo.canonical_url" :label="chipLabel('canonical')" />
|
||||
</div>
|
||||
</SeoDisclosure>
|
||||
:error="canonicalError"
|
||||
@type="typeCanonical"
|
||||
@leave="canonicalBlocked"
|
||||
/>
|
||||
|
||||
<USwitch
|
||||
:model-value="seo.noindex"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import DirectoryField from '@/components/DirectoryField.vue'
|
||||
import FallbackChip from '@/components/FallbackChip.vue'
|
||||
import MediaField from '@/components/MediaField.vue'
|
||||
import SeoDisclosure from '@/components/SeoDisclosure.vue'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { SEO_LIMITS, type ArticleSeo } from '@/lib/articleSeo'
|
||||
import { chipLabel } from '@/lib/seoCopy'
|
||||
import { hasSocialOverrides } from '@/lib/seoPreview'
|
||||
import { areaUi, fieldUi } from '@/lib/seoFieldUi'
|
||||
|
||||
const props = defineProps<{
|
||||
seo: ArticleSeo
|
||||
articleTitle: string
|
||||
descriptionPlaceholder: string
|
||||
socialItem: MediaItem | null
|
||||
saving: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
type: [key: 'social_title' | 'social_description', value: string]
|
||||
remember: [item: MediaItem | null]
|
||||
image: [id: number | null]
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
|
||||
const picker = computed(() => ({
|
||||
title: t('Choose a social image'),
|
||||
description: t('The image shown when this article is shared.'),
|
||||
confirmLabel: t('Use image'),
|
||||
}))
|
||||
const summary = computed(() => {
|
||||
if (hasSocialOverrides(props.seo)) {
|
||||
return t('Customised')
|
||||
}
|
||||
return open.value ? '' : t('Uses the meta values')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SeoDisclosure
|
||||
v-model:open="open"
|
||||
:title="t('Social sharing')"
|
||||
:tag="t('Optional')"
|
||||
:summary="summary"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField :label="t('Social title')" optional>
|
||||
<UInput
|
||||
:model-value="seo.social_title"
|
||||
:placeholder="seo.seo_title || articleTitle"
|
||||
:maxlength="SEO_LIMITS.social_title"
|
||||
autocomplete="off"
|
||||
class="w-full"
|
||||
:ui="fieldUi"
|
||||
@update:model-value="emit('type', 'social_title', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip v-if="!seo.social_title" :label="chipLabel('meta_title')" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<DirectoryField :label="t('Social description')" optional>
|
||||
<UTextarea
|
||||
:model-value="seo.social_description"
|
||||
:placeholder="descriptionPlaceholder"
|
||||
:maxlength="SEO_LIMITS.social_description"
|
||||
:rows="3"
|
||||
class="w-full"
|
||||
:ui="areaUi"
|
||||
@update:model-value="emit('type', 'social_description', String($event))"
|
||||
/>
|
||||
</DirectoryField>
|
||||
<FallbackChip v-if="!seo.social_description" :label="chipLabel('meta_description')" />
|
||||
</div>
|
||||
<MediaField
|
||||
:model-value="seo.social_image_id"
|
||||
:item="socialItem"
|
||||
:label="t('Social image')"
|
||||
:help="seo.social_image_id === null ? t('Using the cover image.') : ''"
|
||||
:picker="picker"
|
||||
:disabled="saving"
|
||||
@update:item="emit('remember', $event)"
|
||||
@update:model-value="emit('image', $event)"
|
||||
/>
|
||||
</SeoDisclosure>
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const request = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: () => ({ request }) }
|
||||
})
|
||||
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import {
|
||||
MEDIA_FAILURE_CACHE_LIMIT,
|
||||
MEDIA_LOOKUP_CONCURRENCY,
|
||||
useMediaNames,
|
||||
} from '@/composables/useMediaNames'
|
||||
|
||||
function mediaOf(id: number, filename = `image-${id}.jpg`) {
|
||||
return { id, filename } as MediaItem
|
||||
}
|
||||
|
||||
describe('useMediaNames', () => {
|
||||
beforeEach(() => {
|
||||
request.mockReset()
|
||||
request.mockImplementation(async (path: string) => {
|
||||
const id = Number(path.split('/').pop())
|
||||
return mediaOf(id)
|
||||
})
|
||||
})
|
||||
|
||||
it('looks each wanted id up by id, whatever the size of the library', async () => {
|
||||
const names = useMediaNames()
|
||||
|
||||
await names.resolve([4, 250])
|
||||
|
||||
expect(request.mock.calls.map(([path]) => path).sort()).toEqual([
|
||||
'/v1/media/250',
|
||||
'/v1/media/4',
|
||||
])
|
||||
expect(names.itemOf(250)?.filename).toBe('image-250.jpg')
|
||||
expect(names.itemOf(4)?.filename).toBe('image-4.jpg')
|
||||
})
|
||||
|
||||
it('skips empty ids and asks once for a repeated id', async () => {
|
||||
const names = useMediaNames()
|
||||
|
||||
await names.resolve([null, undefined, 3, 3])
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/3')
|
||||
})
|
||||
|
||||
it('does not ask again for an id it already knows', async () => {
|
||||
const names = useMediaNames()
|
||||
await names.resolve([3])
|
||||
request.mockClear()
|
||||
|
||||
await names.resolve([3])
|
||||
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not ask for an id handed over with remember', async () => {
|
||||
const names = useMediaNames()
|
||||
names.remember(mediaOf(8, 'picked.jpg'))
|
||||
|
||||
await names.resolve([8])
|
||||
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
expect(names.itemOf(8)?.filename).toBe('picked.jpg')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failure: '404', error: new ApiError(404) },
|
||||
{ failure: '500', error: new ApiError(500) },
|
||||
{ failure: 'a network error', error: new TypeError('Failed to fetch') },
|
||||
])('stays silent and keeps the other names on $failure', async ({ error }) => {
|
||||
request.mockImplementation(async (path: string) => {
|
||||
if (path === '/v1/media/9') {
|
||||
throw error
|
||||
}
|
||||
return mediaOf(Number(path.split('/').pop()))
|
||||
})
|
||||
const names = useMediaNames()
|
||||
|
||||
await expect(names.resolve([9, 2])).resolves.toBeUndefined()
|
||||
|
||||
expect(names.itemOf(9)).toBeNull()
|
||||
expect(names.itemOf(2)?.filename).toBe('image-2.jpg')
|
||||
})
|
||||
|
||||
it('never has more than the cap of lookups in flight', async () => {
|
||||
let inFlight = 0
|
||||
let peak = 0
|
||||
request.mockImplementation(async (path: string) => {
|
||||
inFlight += 1
|
||||
peak = Math.max(peak, inFlight)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
inFlight -= 1
|
||||
return mediaOf(Number(path.split('/').pop()))
|
||||
})
|
||||
const names = useMediaNames()
|
||||
|
||||
await names.resolve(Array.from({ length: 20 }, (_, index) => index + 1))
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(20)
|
||||
expect(peak).toBe(MEDIA_LOOKUP_CONCURRENCY)
|
||||
expect(names.itemOf(20)?.filename).toBe('image-20.jpg')
|
||||
})
|
||||
|
||||
it('does not ask again for an id that failed earlier in the session', async () => {
|
||||
request.mockRejectedValue(new ApiError(404))
|
||||
const names = useMediaNames()
|
||||
await names.resolve([9])
|
||||
request.mockClear()
|
||||
|
||||
await names.resolve([9, 9])
|
||||
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
expect(names.itemOf(9)).toBeNull()
|
||||
})
|
||||
|
||||
it('forgets the oldest failures once the failure cache is full', async () => {
|
||||
request.mockRejectedValue(new ApiError(500))
|
||||
const names = useMediaNames()
|
||||
const ids = Array.from({ length: MEDIA_FAILURE_CACHE_LIMIT + 1 }, (_, index) => index + 1)
|
||||
await names.resolve(ids)
|
||||
request.mockClear()
|
||||
|
||||
await names.resolve(ids)
|
||||
|
||||
expect(request.mock.calls.map(([path]) => path)).toEqual(['/v1/media/1'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
|
||||
const request = vi.fn()
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: () => ({ request }) }
|
||||
})
|
||||
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { SEO_CHECK_CACHE_LIMIT, useSeoCheck } from '@/composables/useSeoCheck'
|
||||
import type { SeoCheckBody } from '@/lib/seoTypes'
|
||||
|
||||
function answerFor(init?: RequestInit) {
|
||||
const sent = JSON.parse(String(init?.body))
|
||||
return {
|
||||
status: 'ready',
|
||||
suggestions: [],
|
||||
slug: { available: true, suggestion: sent.slug ?? null },
|
||||
}
|
||||
}
|
||||
|
||||
function mountCheck(initial: Record<string, unknown>) {
|
||||
const state = { body: initial as SeoCheckBody }
|
||||
let check!: ReturnType<typeof useSeoCheck>
|
||||
mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
check = useSeoCheck({
|
||||
articleId: 12,
|
||||
body: () => state.body,
|
||||
onGone: () => {},
|
||||
})
|
||||
return () => null
|
||||
},
|
||||
}),
|
||||
)
|
||||
return { check, state }
|
||||
}
|
||||
|
||||
const BASE = {
|
||||
seo_title: 'Title',
|
||||
seo_description: 'Description',
|
||||
excerpt: 'Excerpt',
|
||||
slug: 'a-slug',
|
||||
cover_image_id: 1,
|
||||
social_image_id: 2,
|
||||
noindex: false,
|
||||
canonical_url: 'https://example.com/a',
|
||||
body: '<p>Body</p>',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
request.mockReset()
|
||||
request.mockImplementation(async (_path: string, init?: RequestInit) => answerFor(init))
|
||||
})
|
||||
|
||||
describe('SEO check answer cache', () => {
|
||||
it('checks once when only the key order of the fields differs', async () => {
|
||||
const { check, state } = mountCheck({ slug: 'a-slug', seo_title: 'Title' })
|
||||
await check.runNow()
|
||||
|
||||
state.body = { seo_title: 'Title', slug: 'a-slug' }
|
||||
await check.runNow()
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reuses the cached answer on restore when only the key order differs', async () => {
|
||||
const { check, state } = mountCheck({ slug: 'a-slug', seo_title: 'Title' })
|
||||
await check.runNow()
|
||||
state.body = { slug: 'other' }
|
||||
await check.runFresh()
|
||||
|
||||
state.body = { seo_title: 'Title', slug: 'a-slug' }
|
||||
check.restore()
|
||||
|
||||
expect(check.checkedSlug.value).toBe('a-slug')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['seo_title', 'Another title'],
|
||||
['body', '<p>Another body</p>'],
|
||||
['seo_description', 'Another description'],
|
||||
['excerpt', 'Another excerpt'],
|
||||
['slug', 'another-slug'],
|
||||
['cover_image_id', 7],
|
||||
['social_image_id', 8],
|
||||
['noindex', true],
|
||||
['canonical_url', 'https://example.com/b'],
|
||||
])('checks again when %s changes', async (field, value) => {
|
||||
const { check, state } = mountCheck(BASE)
|
||||
await check.runNow()
|
||||
|
||||
state.body = { ...BASE, [field]: value } as SeoCheckBody
|
||||
await check.runNow()
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not check again when only a field the check ignores changes', async () => {
|
||||
const { check, state } = mountCheck(BASE)
|
||||
await check.runNow()
|
||||
|
||||
state.body = { ...BASE, unrelated: 'changed' } as SeoCheckBody
|
||||
await check.runNow()
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not cache a failed check', async () => {
|
||||
request.mockRejectedValueOnce(new ApiError(500))
|
||||
const { check } = mountCheck(BASE)
|
||||
await check.runNow()
|
||||
expect(check.phase.value).toBe('failed')
|
||||
|
||||
check.restore()
|
||||
expect(check.result.value).toBeNull()
|
||||
|
||||
await check.runNow()
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
expect(check.phase.value).toBe('ready')
|
||||
})
|
||||
|
||||
it('stays at its limit after 200 distinct inputs and still hits the latest one', async () => {
|
||||
const { check, state } = mountCheck({ slug: 'input-0' })
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
state.body = { slug: `input-${index}` }
|
||||
await check.runFresh()
|
||||
}
|
||||
request.mockClear()
|
||||
|
||||
state.body = { slug: 'input-198' }
|
||||
check.restore()
|
||||
expect(check.checkedSlug.value).toBe('input-198')
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
|
||||
state.body = { slug: 'input-0' }
|
||||
check.restore()
|
||||
expect(check.checkedSlug.value).toBe('input-198')
|
||||
expect(SEO_CHECK_CACHE_LIMIT).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('keeps a recently restored input when the cache overflows', async () => {
|
||||
const { check, state } = mountCheck({ slug: 'input-0' })
|
||||
for (let index = 0; index < SEO_CHECK_CACHE_LIMIT; index += 1) {
|
||||
state.body = { slug: `input-${index}` }
|
||||
await check.runFresh()
|
||||
}
|
||||
state.body = { slug: 'input-0' }
|
||||
check.restore()
|
||||
state.body = { slug: `input-${SEO_CHECK_CACHE_LIMIT}` }
|
||||
await check.runFresh()
|
||||
|
||||
state.body = { slug: 'input-0' }
|
||||
check.restore()
|
||||
expect(check.checkedSlug.value).toBe('input-0')
|
||||
|
||||
state.body = { slug: 'input-1' }
|
||||
check.restore()
|
||||
expect(check.checkedSlug.value).toBe('input-0')
|
||||
expect(check.result.value?.slug?.suggestion).toBe('input-0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useArticleActions } from '@/composables/useArticleActions'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
|
||||
export interface OnlineChange {
|
||||
prepare?: () => Promise<boolean>
|
||||
pending: (busy: boolean) => void
|
||||
onUpdated: (updated: Article) => void
|
||||
onGone: () => void
|
||||
}
|
||||
|
||||
export interface RemovalHandlers {
|
||||
onDeleted: () => void
|
||||
onGone: () => void
|
||||
}
|
||||
|
||||
type ArticleRef = Pick<Article, 'id' | 'title'>
|
||||
|
||||
export function useArticleLifecycle() {
|
||||
const actions = useArticleActions()
|
||||
let deleteDialogOpen = false
|
||||
|
||||
async function changeOnline(article: ArticleRef, makeOnline: boolean, change: OnlineChange) {
|
||||
change.pending(true)
|
||||
try {
|
||||
if (change.prepare && !(await change.prepare())) {
|
||||
return
|
||||
}
|
||||
await actions.publish(article, makeOnline, {
|
||||
onUpdated: change.onUpdated,
|
||||
onGone: change.onGone,
|
||||
retry: () => changeOnline(article, makeOnline, change),
|
||||
})
|
||||
} finally {
|
||||
change.pending(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAndDelete(article: Article, handlers: RemovalHandlers) {
|
||||
if (deleteDialogOpen) {
|
||||
return false
|
||||
}
|
||||
deleteDialogOpen = true
|
||||
const confirmed = await actions
|
||||
.confirmDelete(article, () => actions.deleteArticle(article, handlers))
|
||||
.finally(() => {
|
||||
deleteDialogOpen = false
|
||||
})
|
||||
return Boolean(confirmed)
|
||||
}
|
||||
|
||||
return { changeOnline, confirmAndDelete }
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
useArticles,
|
||||
type Article,
|
||||
type ArticleCounts,
|
||||
type ArticleStatus,
|
||||
} from '@/composables/useArticles'
|
||||
import {
|
||||
ARTICLE_PAGE_SIZES,
|
||||
SEO_FILTER,
|
||||
nextSort,
|
||||
parseArticleQuery,
|
||||
sameArticleQuery,
|
||||
sameRouteQuery,
|
||||
toRouteQuery,
|
||||
type ArticleListQuery,
|
||||
type ArticleSortField,
|
||||
} from '@/lib/articleListQuery'
|
||||
import { pageCountFor } from '@/lib/pagination'
|
||||
import { ARTICLES_PAGE_SIZE_KEY, readStoredPageSize, storePageSize } from '@/lib/pageSizeMemory'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const SEARCH_DEBOUNCE_MS = 120
|
||||
const ARTICLES_PATH = '/article'
|
||||
|
||||
export type UrlMode = 'push' | 'replace'
|
||||
export type ArticleListNotice =
|
||||
| { kind: 'range' }
|
||||
| { kind: 'error'; query: ArticleListQuery; mode: UrlMode }
|
||||
|
||||
interface ArticleListOptions {
|
||||
sitesSettled: () => boolean
|
||||
usableSite: (query: ArticleListQuery) => ArticleListQuery
|
||||
}
|
||||
|
||||
export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { listArticles } = useArticles()
|
||||
|
||||
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
|
||||
const parsed = parseArticleQuery(query)
|
||||
if (query.size !== undefined) {
|
||||
return parsed
|
||||
}
|
||||
const stored = readStoredPageSize(ARTICLES_PAGE_SIZE_KEY, ARTICLE_PAGE_SIZES)
|
||||
return stored === null ? parsed : { ...parsed, size: stored }
|
||||
}
|
||||
|
||||
const items = ref<Article[]>([])
|
||||
const total = ref(0)
|
||||
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
|
||||
const shown = ref<ArticleListQuery>(parseWithStoredSize(route.query))
|
||||
const desired = ref<ArticleListQuery>(shown.value)
|
||||
const hasLoaded = ref(false)
|
||||
const loading = ref(true)
|
||||
const loadFailed = ref(false)
|
||||
const pendingPage = ref<number | null>(null)
|
||||
const notice = ref<ArticleListNotice | null>(null)
|
||||
const showSkeleton = ref(false)
|
||||
const searchInput = ref(shown.value.q)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let requestToken = 0
|
||||
let syncedTarget: Record<string, string> | null = null
|
||||
|
||||
const needsAttentionCount = computed(() => counts.value.suggestions ?? null)
|
||||
const settled = computed(() => hasLoaded.value && !loadFailed.value)
|
||||
const skeletonActive = computed(() => loading.value && showSkeleton.value)
|
||||
const showPaginator = computed(() => settled.value && total.value > 0)
|
||||
const pageCount = computed(() => pageCountFor(total.value, shown.value.size))
|
||||
|
||||
function clearSearchTimer() {
|
||||
if (searchDebounceTimer !== null) {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
clearTimeout(skeletonTimer)
|
||||
skeletonTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(searchInput, (value) => {
|
||||
clearSearchTimer()
|
||||
searchDebounceTimer = setTimeout(() => changeSearch(value), SEARCH_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
async function syncUrl(query: ArticleListQuery, mode: UrlMode) {
|
||||
if (route.path !== ARTICLES_PATH) {
|
||||
return
|
||||
}
|
||||
const target = toRouteQuery(query)
|
||||
if (sameRouteQuery(route.query, target)) {
|
||||
return
|
||||
}
|
||||
syncedTarget = target
|
||||
await router[mode]({ query: target })
|
||||
}
|
||||
|
||||
async function load(
|
||||
query: ArticleListQuery,
|
||||
mode: UrlMode,
|
||||
successNotice: ArticleListNotice | null = null,
|
||||
) {
|
||||
const token = ++requestToken
|
||||
desired.value = query
|
||||
loading.value = true
|
||||
showSkeleton.value = false
|
||||
pendingPage.value = hasLoaded.value ? query.page : null
|
||||
clearSkeletonTimer()
|
||||
skeletonTimer = setTimeout(() => {
|
||||
showSkeleton.value = true
|
||||
}, SKELETON_DELAY_MS)
|
||||
|
||||
try {
|
||||
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 {
|
||||
if (token !== requestToken) {
|
||||
return
|
||||
}
|
||||
if (hasLoaded.value) {
|
||||
notice.value = { kind: 'error', query, mode }
|
||||
} else {
|
||||
items.value = []
|
||||
loadFailed.value = true
|
||||
}
|
||||
} finally {
|
||||
if (token === requestToken) {
|
||||
loading.value = false
|
||||
pendingPage.value = null
|
||||
clearSkeletonTimer()
|
||||
showSkeleton.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(page: number) {
|
||||
load({ ...desired.value, page }, 'push')
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
storePageSize(ARTICLES_PAGE_SIZE_KEY, size)
|
||||
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 changeSite(site: string) {
|
||||
if (site === desired.value.site) {
|
||||
return
|
||||
}
|
||||
load({ ...desired.value, site, page: 1 }, 'push')
|
||||
}
|
||||
|
||||
function changeSeo(on: boolean) {
|
||||
load({ ...desired.value, seo: on ? SEO_FILTER : '', 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 clearSearch() {
|
||||
clearSearchTimer()
|
||||
searchInput.value = ''
|
||||
changeSearch('')
|
||||
}
|
||||
|
||||
function retryNotice() {
|
||||
if (notice.value?.kind === 'error') {
|
||||
load(notice.value.query, notice.value.mode)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
if (route.path !== ARTICLES_PATH) {
|
||||
return
|
||||
}
|
||||
const ownReplace = syncedTarget !== null && sameRouteQuery(route.query, syncedTarget)
|
||||
syncedTarget = null
|
||||
const parsed = ownReplace
|
||||
? parseArticleQuery(route.query)
|
||||
: parseWithStoredSize(route.query)
|
||||
const next = sitesSettled() ? usableSite(parsed) : parsed
|
||||
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')
|
||||
},
|
||||
)
|
||||
|
||||
async function start(sitesRequest: Promise<unknown>) {
|
||||
if (shown.value.site) {
|
||||
await sitesRequest
|
||||
const usable = usableSite(shown.value)
|
||||
shown.value = usable
|
||||
desired.value = usable
|
||||
}
|
||||
load(shown.value, 'replace')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
requestToken++
|
||||
clearSearchTimer()
|
||||
clearSkeletonTimer()
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
counts,
|
||||
shown,
|
||||
desired,
|
||||
hasLoaded,
|
||||
loading,
|
||||
loadFailed,
|
||||
pendingPage,
|
||||
notice,
|
||||
showSkeleton,
|
||||
searchInput,
|
||||
needsAttentionCount,
|
||||
settled,
|
||||
skeletonActive,
|
||||
showPaginator,
|
||||
pageCount,
|
||||
load,
|
||||
start,
|
||||
changePage,
|
||||
changeSize,
|
||||
changeStatus,
|
||||
changeSite,
|
||||
changeSeo,
|
||||
changeSort,
|
||||
changeSearch,
|
||||
clearSearch,
|
||||
retryNotice,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { nextTick, ref, type Ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { useArticleActions } from '@/composables/useArticleActions'
|
||||
import { useArticleLifecycle } from '@/composables/useArticleLifecycle'
|
||||
import type { Article, ArticleCounts } from '@/composables/useArticles'
|
||||
import type { ArticleListQuery } from '@/lib/articleListQuery'
|
||||
import { pageCountFor } from '@/lib/pagination'
|
||||
@@ -21,7 +21,7 @@ export function useArticleRowActions({
|
||||
shown,
|
||||
reload,
|
||||
}: ArticleRowActionsState) {
|
||||
const actions = useArticleActions()
|
||||
const lifecycle = useArticleLifecycle()
|
||||
const toast = useLocalizedToast()
|
||||
const busyIds = ref<Set<number>>(new Set())
|
||||
|
||||
@@ -93,17 +93,12 @@ export function useArticleRowActions({
|
||||
}
|
||||
}
|
||||
|
||||
async function setOnline(article: Article, makeOnline: boolean) {
|
||||
setBusy(article.id, true)
|
||||
try {
|
||||
await actions.publish(article, makeOnline, {
|
||||
onUpdated: (updated) => replaceArticle(updated, article),
|
||||
onGone: () => goneFromList(article),
|
||||
retry: () => setOnline(article, makeOnline),
|
||||
})
|
||||
} finally {
|
||||
setBusy(article.id, false)
|
||||
}
|
||||
function setOnline(article: Article, makeOnline: boolean) {
|
||||
return lifecycle.changeOnline(article, makeOnline, {
|
||||
pending: (busy) => setBusy(article.id, busy),
|
||||
onUpdated: (updated) => replaceArticle(updated, article),
|
||||
onGone: () => goneFromList(article),
|
||||
})
|
||||
}
|
||||
|
||||
function findItem(id: number) {
|
||||
@@ -162,13 +157,6 @@ export function useArticleRowActions({
|
||||
toast.add(() => notFoundToast(article))
|
||||
}
|
||||
|
||||
function performDelete(article: Article) {
|
||||
return actions.deleteArticle(article, {
|
||||
onDeleted: () => removeArticle(article),
|
||||
onGone: () => goneFromList(article),
|
||||
})
|
||||
}
|
||||
|
||||
async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) {
|
||||
await nextTick()
|
||||
const index = idsAtOpen.indexOf(deletedId)
|
||||
@@ -180,19 +168,12 @@ export function useArticleRowActions({
|
||||
document.querySelector<HTMLElement>('[data-testid="articles-heading"]')?.focus()
|
||||
}
|
||||
|
||||
let deleteDialogOpen = false
|
||||
|
||||
async function handleDelete(article: Article) {
|
||||
if (deleteDialogOpen) {
|
||||
return
|
||||
}
|
||||
deleteDialogOpen = true
|
||||
const idsAtOpen = items.value.map((existing) => existing.id)
|
||||
const confirmed = await actions
|
||||
.confirmDelete(article, () => performDelete(article))
|
||||
.finally(() => {
|
||||
deleteDialogOpen = false
|
||||
})
|
||||
const confirmed = await lifecycle.confirmAndDelete(article, {
|
||||
onDeleted: () => removeArticle(article),
|
||||
onGone: () => goneFromList(article),
|
||||
})
|
||||
if (confirmed) {
|
||||
focusRowOrHeading(idsAtOpen, article.id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useSites, type Site } from '@/composables/useSites'
|
||||
import type { ArticleListQuery } from '@/lib/articleListQuery'
|
||||
|
||||
export function useArticleSites(shownSite: () => string) {
|
||||
const { listSites } = useSites()
|
||||
const sites = ref<Site[]>([])
|
||||
const sitesSettled = ref(false)
|
||||
|
||||
const severalSites = computed(() => sites.value.length > 1)
|
||||
const siteName = computed(
|
||||
() => sites.value.find((site) => site.key === shownSite())?.name ?? shownSite(),
|
||||
)
|
||||
|
||||
async function fetchSites() {
|
||||
try {
|
||||
sites.value = await listSites()
|
||||
} catch {
|
||||
sites.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function loadSites() {
|
||||
return fetchSites().finally(() => {
|
||||
sitesSettled.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function usableSite(query: ArticleListQuery): ArticleListQuery {
|
||||
const known = severalSites.value && sites.value.some((site) => site.key === query.site)
|
||||
return query.site && !known ? { ...query, site: '' } : query
|
||||
}
|
||||
|
||||
return { sites, sitesSettled, severalSites, siteName, loadSites, usableSite }
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export interface ArticleCounts {
|
||||
all: number
|
||||
online: number
|
||||
draft: number
|
||||
suggestions?: number
|
||||
}
|
||||
|
||||
export interface ArticlePage {
|
||||
@@ -46,12 +47,6 @@ export interface ListArticlesParams {
|
||||
seo?: '' | typeof SEO_FILTER
|
||||
}
|
||||
|
||||
export interface CountNeedsAttentionParams {
|
||||
q?: string
|
||||
status?: ArticleStatus | ''
|
||||
site?: string
|
||||
}
|
||||
|
||||
export function useArticles() {
|
||||
const { request } = useApi()
|
||||
|
||||
@@ -79,20 +74,5 @@ export function useArticles() {
|
||||
return request<ArticlePage>(`/v1/article/?${query.toString()}`)
|
||||
}
|
||||
|
||||
async function countNeedsAttention(params: CountNeedsAttentionParams): Promise<number> {
|
||||
const query = new URLSearchParams({ seo: 'suggestions', page: '1', size: '5' })
|
||||
if (params.q) {
|
||||
query.set('q', params.q)
|
||||
}
|
||||
if (params.status) {
|
||||
query.set('status', params.status)
|
||||
}
|
||||
if (params.site) {
|
||||
query.set('site', params.site)
|
||||
}
|
||||
const page = await request<Pick<ArticlePage, 'total'>>(`/v1/article/?${query.toString()}`)
|
||||
return page.total
|
||||
}
|
||||
|
||||
return { listArticles, countNeedsAttention }
|
||||
return { listArticles }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { nextTick, type Ref } from 'vue'
|
||||
import type ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
|
||||
const FOCUS_RETRIES = 10
|
||||
const FOCUS_RETRY_MS = 30
|
||||
|
||||
interface EditorFocusOptions {
|
||||
editorRef: Ref<InstanceType<typeof ArticleEditor> | null>
|
||||
detailsOpen: Ref<boolean>
|
||||
previewOn: Ref<boolean>
|
||||
metaRemembered: Ref<boolean | null>
|
||||
}
|
||||
|
||||
export function useEditorFocus({
|
||||
editorRef,
|
||||
detailsOpen,
|
||||
previewOn,
|
||||
metaRemembered,
|
||||
}: EditorFocusOptions) {
|
||||
function focusBody() {
|
||||
detailsOpen.value = false
|
||||
previewOn.value = false
|
||||
nextTick(() => editorRef.value?.focus())
|
||||
}
|
||||
|
||||
function authorsFocusTarget() {
|
||||
const scope = detailsOpen.value
|
||||
? '[role="dialog"] [data-testid="meta-section"]'
|
||||
: '[data-testid="article-details"] [data-testid="meta-section"]'
|
||||
return document.querySelector<HTMLElement>(
|
||||
`${scope} [data-testid="author-row"] button, ${scope} button[aria-haspopup="listbox"]`,
|
||||
)
|
||||
}
|
||||
|
||||
function focusWhenRendered(find: () => HTMLElement | null, retries = FOCUS_RETRIES) {
|
||||
const target = find()
|
||||
if (target) {
|
||||
target.focus()
|
||||
return
|
||||
}
|
||||
if (retries > 0) {
|
||||
setTimeout(() => focusWhenRendered(find, retries - 1), FOCUS_RETRY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
function focusAuthors() {
|
||||
metaRemembered.value = true
|
||||
focusWhenRendered(authorsFocusTarget)
|
||||
}
|
||||
|
||||
return { focusBody, focusAuthors }
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import type { SaveOutcome, useArticleDraft } from '@/composables/useArticleDraft'
|
||||
import { useSeoCheck } from '@/composables/useSeoCheck'
|
||||
import { SEO_LIMITS, charCount, seoChanges, type ArticleSeo } from '@/lib/articleSeo'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
import type { SeoCheckBody, SeoFocus, SuggestionField } from '@/lib/seoTypes'
|
||||
|
||||
const CLOSING_OUTCOMES: SaveOutcome[] = ['not-found', 'site-gone', 'author-gone', 'unauthorized']
|
||||
|
||||
interface EditorSeoPanelOptions {
|
||||
articleId: number
|
||||
draft: ReturnType<typeof useArticleDraft>
|
||||
save: () => Promise<SaveOutcome>
|
||||
onGone: () => void
|
||||
focusBody: () => void
|
||||
focusAuthors: () => void
|
||||
}
|
||||
|
||||
export function useEditorSeoPanel({
|
||||
articleId,
|
||||
draft,
|
||||
save,
|
||||
onGone,
|
||||
focusBody,
|
||||
focusAuthors,
|
||||
}: EditorSeoPanelOptions) {
|
||||
const toast = useLocalizedToast()
|
||||
const panelOpen = ref(false)
|
||||
const panelValues = ref<ArticleSeo | null>(null)
|
||||
const panelFocus = ref<SeoFocus | null>(null)
|
||||
const panelNotice = ref<DirectoryNotice | null>(null)
|
||||
const panelSaving = ref(false)
|
||||
const slugTakenOnSave = ref<string | null>(null)
|
||||
const canonicalRejected = ref(false)
|
||||
let panelSaveFailed = false
|
||||
|
||||
const seoInEffect = computed(() => panelValues.value ?? draft.seo.value)
|
||||
|
||||
function checkBody(): SeoCheckBody {
|
||||
const changes = seoChanges(seoInEffect.value, draft.savedSeo.value)
|
||||
if (charCount(changes.excerpt ?? '') > SEO_LIMITS.excerpt) {
|
||||
delete changes.excerpt
|
||||
}
|
||||
return {
|
||||
...changes,
|
||||
...(draft.bodyEdited.value ? { body: draft.body.value } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const check = useSeoCheck({
|
||||
articleId,
|
||||
body: checkBody,
|
||||
onGone,
|
||||
onUnauthorized: draft.mirrorToStorage,
|
||||
})
|
||||
|
||||
function openPanel(focus: SeoFocus | null = null) {
|
||||
panelFocus.value = focus
|
||||
panelValues.value = { ...draft.seo.value }
|
||||
panelNotice.value = null
|
||||
slugTakenOnSave.value = null
|
||||
canonicalRejected.value = false
|
||||
panelOpen.value = true
|
||||
check.runFresh()
|
||||
}
|
||||
|
||||
function closePanel(discardEdits = true) {
|
||||
panelOpen.value = false
|
||||
panelValues.value = null
|
||||
if (panelSaveFailed) {
|
||||
panelSaveFailed = false
|
||||
draft.markDirty()
|
||||
}
|
||||
if (discardEdits) {
|
||||
check.restore()
|
||||
}
|
||||
}
|
||||
|
||||
function updatePanelValues(values: ArticleSeo) {
|
||||
panelValues.value = values
|
||||
}
|
||||
|
||||
function onPanelCheck(immediate: boolean) {
|
||||
if (immediate) {
|
||||
check.runNow()
|
||||
return
|
||||
}
|
||||
check.schedule()
|
||||
}
|
||||
|
||||
function goToField(field: SuggestionField) {
|
||||
if (field === 'body') {
|
||||
closePanel()
|
||||
focusBody()
|
||||
return
|
||||
}
|
||||
if (field === 'author_ids') {
|
||||
closePanel()
|
||||
focusAuthors()
|
||||
return
|
||||
}
|
||||
openPanel(field)
|
||||
}
|
||||
|
||||
function showPanelFailure(outcome: SaveOutcome) {
|
||||
if (outcome === 'slug-taken') {
|
||||
slugTakenOnSave.value = panelValues.value?.slug ?? draft.seo.value.slug
|
||||
check.runFresh()
|
||||
return
|
||||
}
|
||||
if (outcome === 'invalid-url') {
|
||||
canonicalRejected.value = true
|
||||
return
|
||||
}
|
||||
panelSaveFailed = true
|
||||
panelNotice.value = {
|
||||
title: t(
|
||||
"Couldn't save search and sharing. Your changes are still here. Try again in a moment.",
|
||||
),
|
||||
description: '',
|
||||
}
|
||||
}
|
||||
|
||||
function announceSeoSaved() {
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Search and sharing saved')
|
||||
},
|
||||
get description() {
|
||||
return t('Your search and sharing settings are up to date.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
function revealSaveFailure(outcome: SaveOutcome) {
|
||||
if (!panelOpen.value && (outcome === 'slug-taken' || outcome === 'invalid-url')) {
|
||||
openPanel('slug')
|
||||
showPanelFailure(outcome)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSeoPanel() {
|
||||
const values = panelValues.value
|
||||
if (!values || panelSaving.value) {
|
||||
return
|
||||
}
|
||||
draft.editSeo(values)
|
||||
if (draft.state.value !== 'dirty') {
|
||||
closePanel()
|
||||
return
|
||||
}
|
||||
panelNotice.value = null
|
||||
panelSaving.value = true
|
||||
const outcome = await save()
|
||||
panelSaving.value = false
|
||||
if (outcome === 'saved') {
|
||||
closePanel(false)
|
||||
announceSeoSaved()
|
||||
return
|
||||
}
|
||||
if (CLOSING_OUTCOMES.includes(outcome)) {
|
||||
closePanel()
|
||||
return
|
||||
}
|
||||
showPanelFailure(outcome)
|
||||
}
|
||||
|
||||
return {
|
||||
check,
|
||||
panelOpen,
|
||||
panelValues,
|
||||
panelFocus,
|
||||
panelNotice,
|
||||
panelSaving,
|
||||
slugTakenOnSave,
|
||||
canonicalRejected,
|
||||
openPanel,
|
||||
closePanel,
|
||||
updatePanelValues,
|
||||
onPanelCheck,
|
||||
goToField,
|
||||
revealSaveFailure,
|
||||
saveSeoPanel,
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,49 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { MediaItem, MediaListResponse } from '@/composables/useMedia'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
|
||||
const LOOKUP_QUERY = 'page=1&size=100&sort=newest'
|
||||
export const MEDIA_LOOKUP_CONCURRENCY = 4
|
||||
export const MEDIA_FAILURE_CACHE_LIMIT = 50
|
||||
|
||||
export function useMediaNames() {
|
||||
const { request } = useApi()
|
||||
const items = ref<Record<number, MediaItem>>({})
|
||||
const failed = new Set<number>()
|
||||
|
||||
function remember(item: MediaItem) {
|
||||
items.value = { ...items.value, [item.id]: item }
|
||||
}
|
||||
|
||||
function rememberFailure(id: number) {
|
||||
failed.delete(id)
|
||||
failed.add(id)
|
||||
if (failed.size > MEDIA_FAILURE_CACHE_LIMIT) {
|
||||
failed.delete(failed.values().next().value as number)
|
||||
}
|
||||
}
|
||||
|
||||
async function lookUp(id: number) {
|
||||
try {
|
||||
remember(await request<MediaItem>(`/v1/media/${id}`))
|
||||
} catch {
|
||||
rememberFailure(id)
|
||||
}
|
||||
}
|
||||
|
||||
async function lookUpAll(queue: number[]) {
|
||||
const next = queue.values()
|
||||
const worker = async () => {
|
||||
for (const id of next) {
|
||||
await lookUp(id)
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: MEDIA_LOOKUP_CONCURRENCY }, worker))
|
||||
}
|
||||
|
||||
async function resolve(ids: (number | null | undefined)[]) {
|
||||
const wanted = ids.filter((id): id is number => typeof id === 'number')
|
||||
if (wanted.every((id) => id in items.value)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const page = await request<MediaListResponse>(`/v1/media/?${LOOKUP_QUERY}`)
|
||||
const found = (page?.items ?? []).filter((item) => wanted.includes(item.id))
|
||||
found.forEach(remember)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const missing = [...new Set(wanted)].filter((id) => !(id in items.value) && !failed.has(id))
|
||||
await lookUpAll(missing)
|
||||
}
|
||||
|
||||
function itemOf(id: number | null | undefined) {
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import { SEO_KEYS } from '@/lib/articleSeo'
|
||||
import type { SeoCheckBody, SeoCheckResult } from '@/lib/seoTypes'
|
||||
|
||||
export const SEO_CHECK_DEBOUNCE_MS = 500
|
||||
export const SEO_CHECK_CACHE_LIMIT = 50
|
||||
|
||||
const CHECKED_FIELDS = [...SEO_KEYS, 'body'].sort() as (keyof SeoCheckBody)[]
|
||||
|
||||
function checkKey(body: SeoCheckBody) {
|
||||
return JSON.stringify(
|
||||
CHECKED_FIELDS.filter((field) => body[field] !== undefined).map((field) => [
|
||||
field,
|
||||
body[field],
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
export type SeoCheckPhase = 'idle' | 'pending' | 'ready' | 'failed'
|
||||
|
||||
@@ -25,7 +38,7 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let controller: AbortController | null = null
|
||||
let ticket = 0
|
||||
let lastPayload: string | null = null
|
||||
let lastKey: string | null = null
|
||||
const answers = new Map<string, { result: SeoCheckResult; slug: string | undefined }>()
|
||||
|
||||
function clearTimer() {
|
||||
@@ -43,7 +56,15 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
}, SEO_CHECK_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
async function send(body: SeoCheckBody, payload: string, silent: boolean) {
|
||||
function remember(key: string, answer: { result: SeoCheckResult; slug: string | undefined }) {
|
||||
answers.delete(key)
|
||||
answers.set(key, answer)
|
||||
if (answers.size > SEO_CHECK_CACHE_LIMIT) {
|
||||
answers.delete(answers.keys().next().value as string)
|
||||
}
|
||||
}
|
||||
|
||||
async function send(body: SeoCheckBody, key: string, silent: boolean) {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
const current = ++ticket
|
||||
@@ -56,7 +77,7 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
@@ -66,7 +87,7 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
result.value = answer
|
||||
checkedSlug.value = body.slug
|
||||
phase.value = 'ready'
|
||||
answers.set(payload, { result: answer, slug: body.slug })
|
||||
remember(key, { result: answer, slug: body.slug })
|
||||
} catch (err) {
|
||||
if (current !== ticket) {
|
||||
return
|
||||
@@ -86,12 +107,12 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
function run(force = false, silent = false) {
|
||||
clearTimer()
|
||||
const body = options.body()
|
||||
const payload = JSON.stringify(body)
|
||||
if (!force && payload === lastPayload && phase.value !== 'failed') {
|
||||
const key = checkKey(body)
|
||||
if (!force && key === lastKey && phase.value !== 'failed') {
|
||||
return Promise.resolve()
|
||||
}
|
||||
lastPayload = payload
|
||||
return send(body, payload, silent)
|
||||
lastKey = key
|
||||
return send(body, key, silent)
|
||||
}
|
||||
|
||||
function runNow() {
|
||||
@@ -123,17 +144,18 @@ export function useSeoCheck(options: SeoCheckOptions) {
|
||||
}
|
||||
|
||||
function restore() {
|
||||
const payload = JSON.stringify(options.body())
|
||||
const answer = answers.get(payload)
|
||||
const key = checkKey(options.body())
|
||||
const answer = answers.get(key)
|
||||
clearTimer()
|
||||
controller?.abort()
|
||||
ticket += 1
|
||||
if (!answer) {
|
||||
lastPayload = null
|
||||
lastKey = null
|
||||
phase.value = result.value ? 'ready' : 'idle'
|
||||
return
|
||||
}
|
||||
lastPayload = payload
|
||||
remember(key, answer)
|
||||
lastKey = key
|
||||
result.value = answer.result
|
||||
checkedSlug.value = answer.slug
|
||||
phase.value = 'ready'
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import type { useSeoCheck } from '@/composables/useSeoCheck'
|
||||
import type { SuggestionCode } from '@/lib/seoTypes'
|
||||
|
||||
interface SeoDismissalsOptions {
|
||||
articleId: number
|
||||
check: ReturnType<typeof useSeoCheck>
|
||||
onGone: () => void
|
||||
onUnauthorized: () => void
|
||||
}
|
||||
|
||||
export function useSeoDismissals({
|
||||
articleId,
|
||||
check,
|
||||
onGone,
|
||||
onUnauthorized,
|
||||
}: SeoDismissalsOptions) {
|
||||
const { request } = useApi()
|
||||
const toast = useLocalizedToast()
|
||||
const dismissedCodes = ref<SuggestionCode[]>([])
|
||||
const dismissingCode = ref<SuggestionCode | null>(null)
|
||||
|
||||
function reportDismissFailure() {
|
||||
toast.add(() => ({
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
get title() {
|
||||
return t("Couldn't dismiss the suggestion")
|
||||
},
|
||||
get description() {
|
||||
return t('Try again in a moment.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async function saveDismissed(next: SuggestionCode[]) {
|
||||
try {
|
||||
const updated = await request<Pick<Article, 'seo_dismissed'>>(
|
||||
`/v1/article/${articleId}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seo_dismissed: next }),
|
||||
},
|
||||
)
|
||||
dismissedCodes.value = updated?.seo_dismissed ?? next
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
onGone()
|
||||
} else if (err instanceof ApiError && err.status === 401) {
|
||||
onUnauthorized()
|
||||
} else {
|
||||
reportDismissFailure()
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissSuggestion(code: SuggestionCode) {
|
||||
if (dismissingCode.value !== null) {
|
||||
return
|
||||
}
|
||||
dismissingCode.value = code
|
||||
const saved = await saveDismissed([...new Set([...dismissedCodes.value, code])])
|
||||
if (saved) {
|
||||
check.dropSuggestion(code)
|
||||
check.invalidate()
|
||||
check.runQuietly()
|
||||
}
|
||||
dismissingCode.value = null
|
||||
}
|
||||
|
||||
async function restoreDismissed() {
|
||||
if (await saveDismissed([])) {
|
||||
check.invalidate()
|
||||
check.runFresh()
|
||||
}
|
||||
}
|
||||
|
||||
return { dismissedCodes, dismissingCode, dismissSuggestion, restoreDismissed }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { computed, ref, watch, type ComputedRef } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { canonicalIsValid, type ArticleSeo } from '@/lib/articleSeo'
|
||||
|
||||
interface SeoPanelCanonicalOptions {
|
||||
seo: ComputedRef<ArticleSeo>
|
||||
rejected: () => boolean
|
||||
update: (patch: Partial<ArticleSeo>) => void
|
||||
}
|
||||
|
||||
export function useSeoPanelCanonical({ seo, rejected, update }: SeoPanelCanonicalOptions) {
|
||||
const advancedOpen = ref(rejected())
|
||||
const canonicalMessage = ref('')
|
||||
|
||||
const advancedSummary = computed(() => {
|
||||
if (advancedOpen.value) {
|
||||
return ''
|
||||
}
|
||||
return seo.value.canonical_url ? t('Canonical URL set') : t('Canonical URL: automatic')
|
||||
})
|
||||
const canonicalError = computed(() =>
|
||||
canonicalMessage.value || rejected()
|
||||
? t('Enter a full address starting with https://')
|
||||
: '',
|
||||
)
|
||||
|
||||
function typeCanonical(value: string) {
|
||||
canonicalMessage.value = ''
|
||||
update({ canonical_url: value })
|
||||
}
|
||||
|
||||
function canonicalBlocked() {
|
||||
if (canonicalIsValid(seo.value.canonical_url)) {
|
||||
return false
|
||||
}
|
||||
canonicalMessage.value = 'invalid'
|
||||
advancedOpen.value = true
|
||||
return true
|
||||
}
|
||||
|
||||
watch(rejected, (isRejected) => {
|
||||
if (isRejected) {
|
||||
advancedOpen.value = true
|
||||
}
|
||||
})
|
||||
|
||||
return { advancedOpen, advancedSummary, canonicalError, typeCanonical, canonicalBlocked }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { computed, onMounted, type ComputedRef } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { useMediaNames } from '@/composables/useMediaNames'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import type { ArticleSeo } from '@/lib/articleSeo'
|
||||
import { socialImageSource } from '@/lib/seoPreview'
|
||||
|
||||
export function useSeoPanelImages(seo: ComputedRef<ArticleSeo>, site: () => Site | undefined) {
|
||||
const names = useMediaNames()
|
||||
|
||||
const siteImageId = computed(() => site()?.default_social_image_id ?? null)
|
||||
const siteImageName = computed(() => {
|
||||
const id = siteImageId.value
|
||||
if (id === null) {
|
||||
return ''
|
||||
}
|
||||
return names.itemOf(id)?.filename ?? t('Image #{0}', [id])
|
||||
})
|
||||
const coverItem = computed(() => names.itemOf(seo.value.cover_image_id))
|
||||
const socialItem = computed(() => names.itemOf(seo.value.social_image_id))
|
||||
const cardSource = computed(() => socialImageSource(seo.value, siteImageId.value !== null))
|
||||
const cardImageItem = computed(() => {
|
||||
const items = {
|
||||
social: socialItem.value,
|
||||
cover: coverItem.value,
|
||||
site: names.itemOf(siteImageId.value),
|
||||
none: null,
|
||||
}
|
||||
return items[cardSource.value]
|
||||
})
|
||||
const cardImageName = computed(() => {
|
||||
const labels = {
|
||||
social: socialItem.value?.filename ?? t('Image #{0}', [seo.value.social_image_id ?? 0]),
|
||||
cover: coverItem.value?.filename ?? t('Image #{0}', [seo.value.cover_image_id ?? 0]),
|
||||
site: siteImageName.value,
|
||||
none: '',
|
||||
}
|
||||
return labels[cardSource.value]
|
||||
})
|
||||
|
||||
function remember(item: MediaItem | null) {
|
||||
if (item) {
|
||||
names.remember(item)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
names.resolve([siteImageId.value, seo.value.cover_image_id, seo.value.social_image_id])
|
||||
})
|
||||
|
||||
return {
|
||||
siteImageId,
|
||||
siteImageName,
|
||||
coverItem,
|
||||
socialItem,
|
||||
cardSource,
|
||||
cardImageItem,
|
||||
cardImageName,
|
||||
remember,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { computed, ref, type ComputedRef } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { SEO_LIMITS, normalizeSlug, typedSlug, type ArticleSeo } from '@/lib/articleSeo'
|
||||
import { slugify } from '@/lib/directory'
|
||||
|
||||
interface SeoPanelSlugOptions {
|
||||
seo: ComputedRef<ArticleSeo>
|
||||
articleTitle: () => string
|
||||
savedSlug: () => string
|
||||
site: () => Site | undefined
|
||||
siteName: ComputedRef<string>
|
||||
slugTakenOnSave: () => string | null
|
||||
slugVerdict: () => { available: boolean; suggestion: string | null } | null
|
||||
checkedSlug: () => string | undefined
|
||||
update: (patch: Partial<ArticleSeo>, immediate?: boolean) => void
|
||||
requestCheck: () => void
|
||||
}
|
||||
|
||||
export function useSeoPanelSlug({
|
||||
seo,
|
||||
articleTitle,
|
||||
savedSlug,
|
||||
site,
|
||||
siteName,
|
||||
slugTakenOnSave,
|
||||
slugVerdict,
|
||||
checkedSlug,
|
||||
update,
|
||||
requestCheck,
|
||||
}: SeoPanelSlugOptions) {
|
||||
const generatedSlug = computed(() => slugify(articleTitle(), SEO_LIMITS.slug))
|
||||
const slugManual = ref(seo.value.slug !== '' && seo.value.slug !== generatedSlug.value)
|
||||
const slugText = ref(seo.value.slug || generatedSlug.value)
|
||||
|
||||
const displaySlug = computed(() => (slugManual.value ? slugText.value : generatedSlug.value))
|
||||
const slugMode = computed(() => {
|
||||
if (slugManual.value) {
|
||||
return 'manual' as const
|
||||
}
|
||||
return generatedSlug.value ? ('auto' as const) : ('empty' as const)
|
||||
})
|
||||
const slugTaken = computed(() => {
|
||||
if (slugTakenOnSave() !== null && slugTakenOnSave() === seo.value.slug) {
|
||||
return true
|
||||
}
|
||||
const verdict = slugVerdict()
|
||||
return (
|
||||
verdict !== null &&
|
||||
!verdict.available &&
|
||||
checkedSlug() !== undefined &&
|
||||
checkedSlug() === seo.value.slug
|
||||
)
|
||||
})
|
||||
const slugSuggestion = computed(() =>
|
||||
slugTaken.value && checkedSlug() === seo.value.slug
|
||||
? (slugVerdict()?.suggestion ?? '')
|
||||
: '',
|
||||
)
|
||||
const slugError = computed(() =>
|
||||
slugTaken.value
|
||||
? t('The slug "{0}" is already used on {1}.', [displaySlug.value, siteName.value])
|
||||
: '',
|
||||
)
|
||||
const slugUrl = computed(() => `${site()?.base_url ?? ''}/${displaySlug.value}`)
|
||||
|
||||
function autoSlug() {
|
||||
return generatedSlug.value === savedSlug() ? savedSlug() : ''
|
||||
}
|
||||
|
||||
function unlockSlug() {
|
||||
slugManual.value = true
|
||||
slugText.value = generatedSlug.value
|
||||
}
|
||||
|
||||
function typeSlug(value: string) {
|
||||
slugText.value = typedSlug(value)
|
||||
update({ slug: normalizeSlug(slugText.value) })
|
||||
}
|
||||
|
||||
function resetSlug() {
|
||||
slugManual.value = false
|
||||
slugText.value = generatedSlug.value
|
||||
update({ slug: autoSlug() }, true)
|
||||
}
|
||||
|
||||
function useSuggestedSlug(suggestion: string) {
|
||||
slugManual.value = true
|
||||
slugText.value = suggestion
|
||||
update({ slug: suggestion }, true)
|
||||
}
|
||||
|
||||
function checkSlug() {
|
||||
if (slugManual.value) {
|
||||
requestCheck()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
displaySlug,
|
||||
slugMode,
|
||||
slugSuggestion,
|
||||
slugError,
|
||||
slugUrl,
|
||||
typeSlug,
|
||||
unlockSlug,
|
||||
resetSlug,
|
||||
useSuggestedSlug,
|
||||
checkSlug,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedOverlay } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import { authRedirectPending } from '@/composables/useApi'
|
||||
|
||||
interface UnsavedChangesOptions {
|
||||
isDirty: () => boolean
|
||||
title: () => string
|
||||
}
|
||||
|
||||
export function useUnsavedChangesGuard({ isDirty, title }: UnsavedChangesOptions) {
|
||||
const overlay = useLocalizedOverlay()
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (authRedirectPending.value || !isDirty()) {
|
||||
return true
|
||||
}
|
||||
const confirmed = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
get title() {
|
||||
return t('Leave without saving?')
|
||||
},
|
||||
get description() {
|
||||
return t("Your changes to \u0022{0}\u0022 haven't been saved.", [title()])
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Keep editing')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Discard changes')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Keep editing')
|
||||
},
|
||||
tone: 'neutral',
|
||||
action: async () => {},
|
||||
dismissResolvesTo: 'confirm',
|
||||
},
|
||||
})
|
||||
.open()
|
||||
return !confirmed
|
||||
})
|
||||
}
|
||||
@@ -31,6 +31,7 @@ const EXPECTED_GROUPS: Record<string, string[]> = {
|
||||
'Search and sharing': ['GET /v1/article/{id}/seo', 'GET /v1/site/{key}/sitemap'],
|
||||
Media: [
|
||||
'GET /v1/media/',
|
||||
'GET /v1/media/{id}',
|
||||
'POST /v1/media/',
|
||||
'PATCH /v1/media/{id}',
|
||||
'DELETE /v1/media/{id}',
|
||||
@@ -207,7 +208,7 @@ function expectOnlyKeys(value: unknown, allowed: string[]) {
|
||||
}
|
||||
|
||||
describe('Catalogue routes', () => {
|
||||
it('holds exactly the 29 key-callable routes in 7 groups, in order', () => {
|
||||
it('holds exactly the 30 key-callable routes in 7 groups, in order', () => {
|
||||
expect(API_GROUPS.map((group) => group.title)).toEqual([
|
||||
'Articles',
|
||||
'Search and sharing',
|
||||
@@ -222,7 +223,7 @@ describe('Catalogue routes', () => {
|
||||
API_GROUPS.map((group) => [group.title, group.endpoints.map(label)]),
|
||||
),
|
||||
).toEqual(EXPECTED_GROUPS)
|
||||
expect(allEndpoints()).toHaveLength(29)
|
||||
expect(allEndpoints()).toHaveLength(30)
|
||||
})
|
||||
|
||||
it('holds the 25 session-only rows in 7 sub-groups', () => {
|
||||
@@ -251,7 +252,7 @@ describe('Catalogue routes', () => {
|
||||
),
|
||||
...PUBLIC_AUTH_ROWS.map((item) => [label(item), item.description] as const),
|
||||
]
|
||||
expect(described).toHaveLength(7 + 29 + 25 + 6)
|
||||
expect(described).toHaveLength(7 + 30 + 25 + 6)
|
||||
const blank = described.filter(([, description]) => description.trim() === '')
|
||||
expect(blank.map(([name]) => name)).toEqual([])
|
||||
})
|
||||
@@ -633,7 +634,7 @@ describe('Request bodies and query parameters', () => {
|
||||
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(Object.keys(body.counts)).toEqual(['all', 'online', 'draft', 'suggestions'])
|
||||
expect(list.errors.map((error) => error.status)).toContain(400)
|
||||
expect(Array.isArray(JSON.parse(list.response))).toBe(false)
|
||||
})
|
||||
|
||||
@@ -502,7 +502,7 @@ const articlePage = () => ({
|
||||
total: 30,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 30, online: 28, draft: 2 },
|
||||
counts: { all: 30, online: 28, draft: 2, suggestions: 3 },
|
||||
})
|
||||
|
||||
const articleIdErrors = [e(400, ID_FORMAT), e(404, 'Article not found')]
|
||||
@@ -581,7 +581,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'art-list',
|
||||
'GET',
|
||||
'/v1/article/',
|
||||
'List one page of articles, newest update first, with the total and the tab counts. Anonymous callers get online articles only.',
|
||||
'List one page of articles, newest update first, with the total and the tab counts (all, online, draft and suggestions). Anonymous callers get online articles only.',
|
||||
'Public',
|
||||
articleErrors(e(400, BODY_MISMATCH)),
|
||||
json(articlePage()),
|
||||
@@ -861,6 +861,16 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
}),
|
||||
{ query: MEDIA_QUERY },
|
||||
),
|
||||
endpoint(
|
||||
'med-get',
|
||||
'GET',
|
||||
'/v1/media/{id}',
|
||||
'Get one image, with the same fields as an item of the list.',
|
||||
'Verified user',
|
||||
verified([e(400, ID_FORMAT), e(401, UNAUTHORIZED), e(404, 'Image not found')]),
|
||||
json(mediaItem()),
|
||||
{ pathId: 4 },
|
||||
),
|
||||
endpoint(
|
||||
'med-up',
|
||||
'POST',
|
||||
@@ -1224,9 +1234,9 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'site-list',
|
||||
'GET',
|
||||
'/v1/site/',
|
||||
'List the sites, the default first and then by name, each with its article count.',
|
||||
'List the sites, the default first and then by name, each with its article count. Without page or size every matching site is returned; the number of matching sites is in the X-Total-Count header.',
|
||||
'Verified user',
|
||||
verified([e(401, UNAUTHORIZED)]),
|
||||
verified([e(400, BODY_MISMATCH), e(401, UNAUTHORIZED)]),
|
||||
json([
|
||||
site(),
|
||||
site({
|
||||
@@ -1244,6 +1254,18 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
default_description: '',
|
||||
}),
|
||||
]),
|
||||
{
|
||||
query: [
|
||||
PAGE_QUERY,
|
||||
SIZE_QUERY,
|
||||
{
|
||||
name: 'q',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'Up to 200 characters, matched in the name, the key or the base URL, ignoring case and accents.',
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
@@ -1257,7 +1279,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
'author-list',
|
||||
'GET',
|
||||
'/v1/author/',
|
||||
'List the authors by name, each with its article count.',
|
||||
'List the authors by name, each with its article count. The total is also in the X-Total-Count header.',
|
||||
'Verified user',
|
||||
verified([e(400, 'The page, size or search is not valid.'), e(401, UNAUTHORIZED)]),
|
||||
json({
|
||||
|
||||
+18
-39
@@ -586,7 +586,6 @@ export const messages = {
|
||||
Accepted: 'Acceptée',
|
||||
Expired: 'Expirée',
|
||||
Owner: 'Propriétaire',
|
||||
Member: 'Membre',
|
||||
You: 'Vous',
|
||||
'Renew invite': 'Renouveler l’invitation',
|
||||
'Renewing…': 'Renouvellement…',
|
||||
@@ -608,17 +607,8 @@ export const messages = {
|
||||
'Your site reads published articles from RenewCMS. Share this guide with whoever builds it.':
|
||||
'Votre site lit les articles publiés dans RenewCMS. Partagez ce guide avec la personne qui le développe.',
|
||||
'Open the guide': 'Ouvrir le guide',
|
||||
'A guide for the developer who builds your public site.':
|
||||
'Un guide pour la personne qui développe votre site public.',
|
||||
"RenewCMS is headless: it stores and publishes your articles but doesn't show them to visitors. Your website fetches published articles from the two addresses below and displays them in its own design.":
|
||||
'RenewCMS stocke et publie vos articles sans les afficher aux visiteurs. Votre site récupère les articles publiés aux deux adresses ci-dessous et les affiche selon son propre design.',
|
||||
'Base URL': 'URL de base',
|
||||
'Public API URL': 'URL de l’API publique',
|
||||
'Lists every online article.': 'Liste tous les articles publiés.',
|
||||
'Returns one online article. Drafts and unknown ids both return 404.':
|
||||
'Renvoie un article publié. Les brouillons et les identifiants inconnus renvoient 404.',
|
||||
'Anonymous callers only ever get online articles. Drafts stay private until you publish them.':
|
||||
'Les requêtes anonymes n’obtiennent que les articles publiés. Les brouillons restent privés jusqu’à leur publication.',
|
||||
"Browsers can only call the API from origins listed in the server's CORS setting. Ask whoever runs the server to add your site's address.":
|
||||
'Les navigateurs ne peuvent appeler l’API que depuis les origines autorisées par le paramètre CORS du serveur. Demandez à la personne qui gère le serveur d’ajouter l’adresse de votre site.',
|
||||
Response: 'Réponse',
|
||||
@@ -773,7 +763,6 @@ export const messages = {
|
||||
"You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection.":
|
||||
'Vous êtes hors ligne. Vous pouvez consulter les images chargées, mais l’importation, le renommage et la suppression nécessitent une connexion.',
|
||||
"You're offline": 'Vous êtes hors ligne',
|
||||
"You're back online": 'Vous êtes de nouveau en ligne',
|
||||
'{0} — account menu': '{0} — menu du compte',
|
||||
"You're logged in as {0}.": 'Vous êtes connecté en tant que {0}.',
|
||||
'Counted from the text of your articles each time you open this page.':
|
||||
@@ -794,8 +783,6 @@ export const messages = {
|
||||
"It's": 'Elle est',
|
||||
'All {0} are': 'Les {0} sont',
|
||||
'{0} used in {1}, {2} {3}': '{0} utilisées dans {1}, {2} {3}',
|
||||
'{0} {1} attention': '{0} {1} votre attention',
|
||||
'{0} {1} rejected': '{0} {1} refusées',
|
||||
'{0} {1} the same content.': '{0} {1} le même contenu.',
|
||||
'{0} · RenewCMS': '{0} · RenewCMS',
|
||||
Enter: 'Entrée',
|
||||
@@ -804,7 +791,6 @@ export const messages = {
|
||||
'Delete {0}?': 'Supprimer {0} ?',
|
||||
'Delete {0}': 'Supprimer {0}',
|
||||
'Used in {0}': 'Utilisée dans {0}',
|
||||
'Copy {0} example': 'Copier l’exemple {0}',
|
||||
'Drop to upload': 'Déposez pour importer',
|
||||
'Drop an image here, or choose a file': 'Déposez une image ici ou choisissez un fichier',
|
||||
'Send invite': 'Envoyer l’invitation',
|
||||
@@ -823,8 +809,6 @@ export const messages = {
|
||||
'Choose a new password': 'Choisir un nouveau mot de passe',
|
||||
'Changing password…': 'Modification du mot de passe…',
|
||||
'Change password': 'Modifier le mot de passe',
|
||||
'Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the base URL before rendering.':
|
||||
'Les images du contenu utilisent des chemins comme /static/uploadedImages/<uuid>.png. Ajoutez l’URL de base avant de les afficher.',
|
||||
selected: 'sélectionnées',
|
||||
you: 'vous',
|
||||
now: 'maintenant',
|
||||
@@ -847,13 +831,6 @@ export const messages = {
|
||||
'{0} files were rejected': '{0} fichiers ont été refusés',
|
||||
'{0} file needs attention': '{0} fichier nécessite votre attention',
|
||||
'{0} files need attention': '{0} fichiers nécessitent votre attention',
|
||||
'{0} has the same content.': '{0} a le même contenu.',
|
||||
'{0} have the same content.': '{0} ont le même contenu.',
|
||||
'{0} images · {1} selected': '{0} images · {1} sélectionnées',
|
||||
'image, {0} selected': 'image, {0} sélectionnée',
|
||||
'images, {0} selected': 'images, {0} sélectionnées',
|
||||
'Used in 1 article': 'Utilisée dans 1 article',
|
||||
'Used in {0} articles': 'Utilisée dans {0} articles',
|
||||
'API keys': 'Clés d’API',
|
||||
'Create key': 'Créer une clé',
|
||||
Key: 'Clé',
|
||||
@@ -1651,12 +1628,11 @@ export const messages = {
|
||||
'List keys': 'Lister les clés',
|
||||
'Create a key': 'Créer une clé',
|
||||
'Revoke a key': 'Révoquer une clé',
|
||||
'List one page of articles, newest update first, with the total and the tab counts. Anonymous callers get online articles only.':
|
||||
'Liste une page d’articles, du plus récemment modifié au plus ancien, avec le total et les compteurs des onglets. Les appels anonymes ne reçoivent que les articles publiés.',
|
||||
'List one page of articles, newest update first, with the total and the tab counts (all, online, draft and suggestions). Anonymous callers get online articles only.':
|
||||
'Liste une page d’articles, du plus récemment modifié au plus ancien, avec le total et les compteurs des onglets (tous, publiés, brouillons et suggestions). Les appels anonymes ne reçoivent que les articles publiés.',
|
||||
'Get one article. Drafts answer 404 to anonymous callers.':
|
||||
'Récupère un article. Les brouillons répondent 404 aux appels anonymes.',
|
||||
'Create a draft article.': 'Crée un article en brouillon.',
|
||||
'Update the body of an article.': 'Modifie le corps d’un article.',
|
||||
'Publish an article.': 'Publie un article.',
|
||||
'Unpublish an article.': 'Dépublie un article.',
|
||||
'Delete an article.': 'Supprime un article.',
|
||||
@@ -1666,7 +1642,6 @@ export const messages = {
|
||||
'Delete an image. Articles that use it show a broken image.':
|
||||
'Supprime une image. Les articles qui l’utilisent affichent une image cassée.',
|
||||
'Merge identical copies into one image.': 'Fusionne les copies identiques en une seule image.',
|
||||
'List the articles that use an image.': 'Liste les articles qui utilisent une image.',
|
||||
'Get the automatic duplicate purge settings.':
|
||||
'Récupère les paramètres de la purge automatique des doublons.',
|
||||
'Turn the automatic purge on or off, or change its interval.':
|
||||
@@ -1971,16 +1946,28 @@ export const messages = {
|
||||
'La requête ne peut pas être traitée car le format du corps ne correspond pas.',
|
||||
site_not_found: 'Site introuvable',
|
||||
'25, 50, 100 or 500. Default 100.': '25, 50, 100 ou 500. Par défaut 100.',
|
||||
'List the sites, the default first and then by name, each with its article count.':
|
||||
'Liste les sites, le site par défaut d’abord puis par nom, chacun avec son nombre d’articles.',
|
||||
'List the sites, the default first and then by name, each with its article count. Without page or size every matching site is returned; the number of matching sites is in the X-Total-Count header.':
|
||||
'Liste les sites, le site par défaut d’abord puis par nom, chacun avec son nombre d’articles. Sans page ni taille, tous les sites correspondants sont renvoyés ; leur nombre est dans l’en-tête X-Total-Count.',
|
||||
'Up to 200 characters, matched in the name, the key or the base URL, ignoring case and accents.':
|
||||
'Jusqu’à 200 caractères, recherchés dans le nom, la clé ou l’URL de base, sans tenir compte de la casse ni des accents.',
|
||||
'Log in. Made for the browser. A key is ignored here, with no 403.':
|
||||
'Se connecter. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Log out. Made for the browser. A key is ignored here, with no 403.':
|
||||
'Se déconnecter. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Register. Made for the browser. A key is ignored here, with no 403.':
|
||||
'S’inscrire. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Reset password. Made for the browser. A key is ignored here, with no 403.':
|
||||
'Réinitialiser le mot de passe. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Get one image, with the same fields as an item of the list.':
|
||||
'Récupère une image, avec les mêmes champs qu’un élément de la liste.',
|
||||
'You must be logged in to access this resource.':
|
||||
'Vous devez être connecté pour accéder à cette ressource.',
|
||||
'You must verify your account to access this resource.':
|
||||
'Vous devez vérifier votre compte pour accéder à cette ressource.',
|
||||
'The people credited on articles. Any verified user can read them; changes are made in the browser by an owner or admin.':
|
||||
'Les personnes créditées sur les articles. Tout utilisateur vérifié peut les lire ; les modifications se font dans le navigateur par un propriétaire ou un administrateur.',
|
||||
'List the authors by name, each with its article count.':
|
||||
'Liste les auteurs par nom, chacun avec son nombre d’articles.',
|
||||
'List the authors by name, each with its article count. The total is also in the X-Total-Count header.':
|
||||
'Liste les auteurs par nom, chacun avec son nombre d’articles. Le total est aussi dans l’en-tête X-Total-Count.',
|
||||
'The page, size or search is not valid.':
|
||||
'La page, la taille ou la recherche n’est pas valide.',
|
||||
'Up to 200 characters, matched in the name or the slug.':
|
||||
@@ -2247,14 +2234,6 @@ export const messages = {
|
||||
'Certaines images à fusionner ont changé. Actualisez et réessayez.',
|
||||
'List the articles, authors and sites that use an image, with the places where each one uses it.':
|
||||
'Liste les articles, auteurs et sites qui utilisent une image, avec les endroits où chacun l’utilise.',
|
||||
'Log in. Made for the browser. A key is ignored here, with no 403.':
|
||||
'Se connecter. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Log out. Made for the browser. A key is ignored here, with no 403.':
|
||||
'Se déconnecter. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Register. Made for the browser. A key is ignored here, with no 403.':
|
||||
'S’inscrire. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
'Reset password. Made for the browser. A key is ignored here, with no 403.':
|
||||
'Réinitialiser le mot de passe. Conçu pour le navigateur. Une clé est ignorée ici, sans 403.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -6,6 +6,8 @@ import { messages } from './messages'
|
||||
|
||||
const SOURCES = [
|
||||
'components/ArticleCardList.vue',
|
||||
'components/ArticleListNotice.vue',
|
||||
'components/ArticleListToolbar.vue',
|
||||
'components/ArticleSiteFilter.vue',
|
||||
'components/ArticleTable.vue',
|
||||
'components/AuthorList.vue',
|
||||
@@ -13,13 +15,24 @@ const SOURCES = [
|
||||
'components/AuthorSelect.vue',
|
||||
'components/CreateAuthorDialog.vue',
|
||||
'components/CreateAuthorHint.vue',
|
||||
'components/EditorHeader.vue',
|
||||
'components/EditorMobileBar.vue',
|
||||
'components/EditorSkeleton.vue',
|
||||
'components/EmptyState.vue',
|
||||
'components/MetaSection.vue',
|
||||
'components/SeoAdvancedSection.vue',
|
||||
'components/SeoSocialSection.vue',
|
||||
'components/SiteSelect.vue',
|
||||
'composables/useArticleActions.ts',
|
||||
'composables/useArticleRowActions.ts',
|
||||
'composables/useEditorImages.ts',
|
||||
'composables/useEditorSeoPanel.ts',
|
||||
'composables/useMyAuthorProfile.ts',
|
||||
'composables/useSeoDismissals.ts',
|
||||
'composables/useSeoPanelCanonical.ts',
|
||||
'composables/useSeoPanelImages.ts',
|
||||
'composables/useSeoPanelSlug.ts',
|
||||
'composables/useUnsavedChangesGuard.ts',
|
||||
'lib/articleMeta.ts',
|
||||
'views/ArticleView.vue',
|
||||
'views/EditorView.vue',
|
||||
|
||||
@@ -9,21 +9,34 @@ import type { ResolvedSeo, SeoSuggestion, SuggestionCode } from '@/lib/seoTypes'
|
||||
|
||||
const SOURCES = [
|
||||
'components/ApiPreviewDialog.vue',
|
||||
'components/ArticleListNotice.vue',
|
||||
'components/ArticleListToolbar.vue',
|
||||
'components/EditorHeader.vue',
|
||||
'components/EditorMobileBar.vue',
|
||||
'components/EditorSkeleton.vue',
|
||||
'components/EmptyState.vue',
|
||||
'components/NeedsAttentionToggle.vue',
|
||||
'components/SeoStatusChip.vue',
|
||||
'views/ArticleView.vue',
|
||||
'components/FallbackChip.vue',
|
||||
'components/NeedsAttentionToggle.vue',
|
||||
'components/SearchPreview.vue',
|
||||
'components/SearchSharingSection.vue',
|
||||
'components/SeoAdvancedSection.vue',
|
||||
'components/SeoDisclosure.vue',
|
||||
'components/SeoPanel.vue',
|
||||
'components/SeoSocialSection.vue',
|
||||
'components/SeoStatusChip.vue',
|
||||
'components/SocialCardPreview.vue',
|
||||
'components/SoftCounter.vue',
|
||||
'components/SuggestionList.vue',
|
||||
'composables/useEditorSeoPanel.ts',
|
||||
'composables/useSeoDismissals.ts',
|
||||
'composables/useSeoPanelCanonical.ts',
|
||||
'composables/useSeoPanelImages.ts',
|
||||
'composables/useSeoPanelSlug.ts',
|
||||
'composables/useUnsavedChangesGuard.ts',
|
||||
'lib/seoApiPreview.ts',
|
||||
'lib/seoCopy.ts',
|
||||
'lib/seoPreview.ts',
|
||||
'views/ArticleView.vue',
|
||||
'views/EditorView.vue',
|
||||
]
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface StripCard {
|
||||
}
|
||||
|
||||
export const STRIP_KEYS: StripKey[] = ['total', 'active', 'failed', 'security']
|
||||
export const BAR_COUNT = 7
|
||||
export const MIN_BAR_HEIGHT = 2
|
||||
|
||||
function percent(part: number, whole: number) {
|
||||
|
||||
@@ -88,10 +88,6 @@ export function seoChanges(current: ArticleSeo, saved: ArticleSeo): SeoPatch {
|
||||
return changes as SeoPatch
|
||||
}
|
||||
|
||||
export function sameSeo(left: ArticleSeo, right: ArticleSeo) {
|
||||
return Object.keys(seoChanges(left, right)).length === 0
|
||||
}
|
||||
|
||||
export function charCount(text: string) {
|
||||
return [...text].length
|
||||
}
|
||||
|
||||
@@ -49,10 +49,6 @@ export function formatMegabytes(bytes: number) {
|
||||
: value.toFixed(1)
|
||||
}
|
||||
|
||||
export function isSizeRejectionBody(body: string) {
|
||||
return body.toLowerCase().includes('exceed')
|
||||
}
|
||||
|
||||
export function rejectionTitle(filename: string) {
|
||||
return t("\u0022{0}\u0022 can't be used", [filename])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
const DISABLED_FIELD = 'disabled:bg-elevated disabled:opacity-100'
|
||||
|
||||
export const fieldUi = {
|
||||
base: `h-11 text-lg text-ellipsis md:h-10 md:text-sm ${DISABLED_FIELD}`,
|
||||
}
|
||||
export const monoFieldUi = {
|
||||
base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${DISABLED_FIELD}`,
|
||||
}
|
||||
export const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD}` }
|
||||
@@ -7,10 +7,6 @@ export function cacheSize(bytes: number) {
|
||||
return bytes === 0 ? t('{0} MB', [0]) : formatVariantSize(bytes)
|
||||
}
|
||||
|
||||
export function filesCount(count: number) {
|
||||
return count === 1 ? t('1 file') : t('{0} files', [formatNumber(count)])
|
||||
}
|
||||
|
||||
export function filesAndSize(count: number, bytes: number) {
|
||||
return count === 1
|
||||
? t('1 file · {0}', [formatVariantSize(bytes)])
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
+61
-427
@@ -1,131 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ArticleTable from '@/components/ArticleTable.vue'
|
||||
import ArticleCardList from '@/components/ArticleCardList.vue'
|
||||
import ArticleListNotice from '@/components/ArticleListNotice.vue'
|
||||
import ArticleListToolbar from '@/components/ArticleListToolbar.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import NewArticleModal from '@/components/NewArticleModal.vue'
|
||||
import AppPaginator from '@/components/AppPaginator.vue'
|
||||
import ArticleSiteFilter from '@/components/ArticleSiteFilter.vue'
|
||||
import NeedsAttentionToggle from '@/components/NeedsAttentionToggle.vue'
|
||||
import { useArticleList } from '@/composables/useArticleList'
|
||||
import { useArticleRowActions } from '@/composables/useArticleRowActions'
|
||||
import {
|
||||
useArticles,
|
||||
type Article,
|
||||
type ArticleCounts,
|
||||
type ArticleStatus,
|
||||
} from '@/composables/useArticles'
|
||||
import { useSites, type Site } from '@/composables/useSites'
|
||||
import {
|
||||
ARTICLE_PAGE_SIZES,
|
||||
SEO_FILTER,
|
||||
effectiveSort,
|
||||
nextSort,
|
||||
parseArticleQuery,
|
||||
type ArticleSortField,
|
||||
sameArticleQuery,
|
||||
sameRouteQuery,
|
||||
toRouteQuery,
|
||||
type ArticleListQuery,
|
||||
} from '@/lib/articleListQuery'
|
||||
import { pageCountFor } from '@/lib/pagination'
|
||||
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
|
||||
import { ARTICLES_PAGE_SIZE_KEY, readStoredPageSize, storePageSize } from '@/lib/pageSizeMemory'
|
||||
import { useArticleSites } from '@/composables/useArticleSites'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { effectiveSort } from '@/lib/articleListQuery'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const SEARCH_DEBOUNCE_MS = 120
|
||||
const DEFAULT_SKELETON_ROWS = 6
|
||||
const ARTICLES_PATH = '/article'
|
||||
|
||||
type UrlMode = 'push' | 'replace'
|
||||
type Notice = { kind: 'range' } | { kind: 'error'; query: ArticleListQuery; mode: UrlMode }
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { listArticles, countNeedsAttention } = useArticles()
|
||||
const { listSites } = useSites()
|
||||
|
||||
const items = ref<Article[]>([])
|
||||
const sites = ref<Site[]>([])
|
||||
const total = ref(0)
|
||||
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
|
||||
const needsAttentionCount = ref<number | null>(null)
|
||||
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
|
||||
const parsed = parseArticleQuery(query)
|
||||
if (query.size !== undefined) {
|
||||
return parsed
|
||||
}
|
||||
const stored = readStoredPageSize(ARTICLES_PAGE_SIZE_KEY, ARTICLE_PAGE_SIZES)
|
||||
return stored === null ? parsed : { ...parsed, size: stored }
|
||||
}
|
||||
|
||||
const shown = ref<ArticleListQuery>(parseWithStoredSize(route.query))
|
||||
const desired = ref<ArticleListQuery>(shown.value)
|
||||
const sitesSettled = ref(false)
|
||||
const hasLoaded = ref(false)
|
||||
const loading = ref(true)
|
||||
const loadFailed = ref(false)
|
||||
const pendingPage = ref<number | null>(null)
|
||||
const notice = ref<Notice | null>(null)
|
||||
const showSkeleton = ref(false)
|
||||
const list = useArticleList({
|
||||
sitesSettled: () => siteList.sitesSettled.value,
|
||||
usableSite: (query) => siteList.usableSite(query),
|
||||
})
|
||||
const {
|
||||
items,
|
||||
total,
|
||||
counts,
|
||||
shown,
|
||||
desired,
|
||||
hasLoaded,
|
||||
loadFailed,
|
||||
notice,
|
||||
showSkeleton,
|
||||
searchInput,
|
||||
needsAttentionCount,
|
||||
settled,
|
||||
skeletonActive,
|
||||
showPaginator,
|
||||
pageCount,
|
||||
pendingPage,
|
||||
} = list
|
||||
const siteList = useArticleSites(() => shown.value.site)
|
||||
const { sites, severalSites, siteName } = siteList
|
||||
const newArticleModalOpen = ref(false)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let requestToken = 0
|
||||
let countToken = 0
|
||||
let countedFor: string | null = null
|
||||
let syncedTarget: Record<string, string> | null = null
|
||||
|
||||
const searchInput = ref(shown.value.q)
|
||||
|
||||
function clearSearchTimer() {
|
||||
if (searchDebounceTimer !== null) {
|
||||
clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(searchInput, (value) => {
|
||||
clearSearchTimer()
|
||||
searchDebounceTimer = setTimeout(() => changeSearch(value), SEARCH_DEBOUNCE_MS)
|
||||
})
|
||||
|
||||
const activeTab = computed<'' | ArticleStatus>({
|
||||
get: () => desired.value.status,
|
||||
set: (value) => changeStatus(value),
|
||||
})
|
||||
|
||||
const tabItems = computed(() => [
|
||||
{
|
||||
get label() {
|
||||
return t('All {0}', [counts.value.all])
|
||||
},
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Online {0}', [counts.value.online])
|
||||
},
|
||||
value: 'online',
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Drafts {0}', [counts.value.draft])
|
||||
},
|
||||
value: 'draft',
|
||||
},
|
||||
])
|
||||
|
||||
const severalSites = computed(() => sites.value.length > 1)
|
||||
const siteName = computed(
|
||||
() => sites.value.find((site) => site.key === shown.value.site)?.name ?? shown.value.site,
|
||||
)
|
||||
|
||||
const settled = computed(() => hasLoaded.value && !loadFailed.value)
|
||||
const skeletonActive = computed(() => loading.value && showSkeleton.value)
|
||||
const skeletonRows = computed(() => items.value.length || DEFAULT_SKELETON_ROWS)
|
||||
|
||||
const emptyVariant = computed(() => {
|
||||
@@ -150,234 +71,13 @@ const emptyVariant = computed(() => {
|
||||
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() {
|
||||
if (skeletonTimer !== null) {
|
||||
clearTimeout(skeletonTimer)
|
||||
skeletonTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function syncUrl(query: ArticleListQuery, mode: UrlMode) {
|
||||
if (route.path !== ARTICLES_PATH) {
|
||||
return
|
||||
}
|
||||
const target = toRouteQuery(query)
|
||||
if (sameRouteQuery(route.query, target)) {
|
||||
return
|
||||
}
|
||||
syncedTarget = target
|
||||
await router[mode]({ query: target })
|
||||
}
|
||||
|
||||
function countKey(query: ArticleListQuery) {
|
||||
return JSON.stringify([query.q, query.status, query.site])
|
||||
}
|
||||
|
||||
async function refreshNeedsAttentionCount(query: ArticleListQuery) {
|
||||
const key = countKey(query)
|
||||
if (query.seo || key === countedFor) {
|
||||
return
|
||||
}
|
||||
const token = ++countToken
|
||||
countedFor = key
|
||||
needsAttentionCount.value = null
|
||||
try {
|
||||
const result = await countNeedsAttention({
|
||||
q: query.q,
|
||||
status: query.status,
|
||||
site: query.site,
|
||||
})
|
||||
if (token === countToken) {
|
||||
needsAttentionCount.value = result
|
||||
}
|
||||
} catch {
|
||||
if (token === countToken) {
|
||||
countedFor = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notice | null = null) {
|
||||
const token = ++requestToken
|
||||
refreshNeedsAttentionCount(query)
|
||||
desired.value = query
|
||||
loading.value = true
|
||||
showSkeleton.value = false
|
||||
pendingPage.value = hasLoaded.value ? query.page : null
|
||||
clearSkeletonTimer()
|
||||
skeletonTimer = setTimeout(() => {
|
||||
showSkeleton.value = true
|
||||
}, SKELETON_DELAY_MS)
|
||||
|
||||
try {
|
||||
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
|
||||
if (effective.seo) {
|
||||
countToken++
|
||||
countedFor = countKey(effective)
|
||||
needsAttentionCount.value = response.total
|
||||
}
|
||||
shown.value = effective
|
||||
desired.value = effective
|
||||
hasLoaded.value = true
|
||||
loadFailed.value = false
|
||||
notice.value = successNotice
|
||||
await syncUrl(effective, mode)
|
||||
} catch {
|
||||
if (token !== requestToken) {
|
||||
return
|
||||
}
|
||||
if (hasLoaded.value) {
|
||||
notice.value = { kind: 'error', query, mode }
|
||||
} else {
|
||||
items.value = []
|
||||
loadFailed.value = true
|
||||
}
|
||||
} finally {
|
||||
if (token === requestToken) {
|
||||
loading.value = false
|
||||
pendingPage.value = null
|
||||
clearSkeletonTimer()
|
||||
showSkeleton.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(page: number) {
|
||||
load({ ...desired.value, page }, 'push')
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
storePageSize(ARTICLES_PAGE_SIZE_KEY, size)
|
||||
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 changeSite(site: string) {
|
||||
if (site === desired.value.site) {
|
||||
return
|
||||
}
|
||||
load({ ...desired.value, site, page: 1 }, 'push')
|
||||
}
|
||||
|
||||
function changeSeo(on: boolean) {
|
||||
load({ ...desired.value, seo: on ? SEO_FILTER : '', page: 1 }, 'push')
|
||||
}
|
||||
|
||||
function clearSeoFilter() {
|
||||
changeSeo(false)
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
async function fetchSites() {
|
||||
try {
|
||||
sites.value = await listSites()
|
||||
} catch {
|
||||
sites.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function usableSite(query: ArticleListQuery): ArticleListQuery {
|
||||
const known = severalSites.value && sites.value.some((site) => site.key === query.site)
|
||||
return query.site && !known ? { ...query, site: '' } : query
|
||||
}
|
||||
|
||||
function retryNotice() {
|
||||
if (notice.value?.kind === 'error') {
|
||||
load(notice.value.query, notice.value.mode)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
if (route.path !== ARTICLES_PATH) {
|
||||
return
|
||||
}
|
||||
const ownReplace = syncedTarget !== null && sameRouteQuery(route.query, syncedTarget)
|
||||
syncedTarget = null
|
||||
const parsed = ownReplace
|
||||
? parseArticleQuery(route.query)
|
||||
: parseWithStoredSize(route.query)
|
||||
const next = sitesSettled.value ? usableSite(parsed) : parsed
|
||||
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')
|
||||
},
|
||||
)
|
||||
|
||||
const { busyIds, handlePublish, handleUnpublish, handleCopyUrl, handleDelete } =
|
||||
useArticleRowActions({
|
||||
items,
|
||||
total,
|
||||
counts,
|
||||
shown,
|
||||
reload: load,
|
||||
reload: list.load,
|
||||
})
|
||||
|
||||
function openNewArticleModal() {
|
||||
@@ -389,9 +89,7 @@ function onArticleCreated(article: Article) {
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
clearSearchTimer()
|
||||
searchInput.value = ''
|
||||
changeSearch('')
|
||||
list.clearSearch()
|
||||
nextTick(() => {
|
||||
document.querySelector<HTMLInputElement>('input[type="search"]')?.focus()
|
||||
})
|
||||
@@ -399,11 +97,11 @@ function clearSearch() {
|
||||
|
||||
function onEmptyAction() {
|
||||
if (emptyVariant.value === 'needs-attention') {
|
||||
clearSeoFilter()
|
||||
list.changeSeo(false)
|
||||
return
|
||||
}
|
||||
if (emptyVariant.value === 'no-site') {
|
||||
changeSite('')
|
||||
list.changeSite('')
|
||||
return
|
||||
}
|
||||
if (emptyVariant.value === 'no-results') {
|
||||
@@ -411,29 +109,14 @@ function onEmptyAction() {
|
||||
return
|
||||
}
|
||||
if (emptyVariant.value === 'no-online') {
|
||||
activeTab.value = 'draft'
|
||||
list.changeStatus('draft')
|
||||
return
|
||||
}
|
||||
openNewArticleModal()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const siteRequest = fetchSites().finally(() => {
|
||||
sitesSettled.value = true
|
||||
})
|
||||
if (shown.value.site) {
|
||||
await siteRequest
|
||||
const usable = usableSite(shown.value)
|
||||
shown.value = usable
|
||||
desired.value = usable
|
||||
}
|
||||
load(shown.value, 'replace')
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
requestToken++
|
||||
clearSearchTimer()
|
||||
clearSkeletonTimer()
|
||||
onMounted(() => {
|
||||
list.start(siteList.loadSites())
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -462,76 +145,27 @@ onBeforeUnmount(() => {
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col-reverse gap-3 md:flex-row md:items-end md:gap-4 md:border-b md:border-default"
|
||||
>
|
||||
<UTabs
|
||||
v-model="activeTab"
|
||||
:items="tabItems"
|
||||
variant="link"
|
||||
color="neutral"
|
||||
:content="false"
|
||||
class="md:-mb-px md:flex-1"
|
||||
:ui="{
|
||||
list: 'gap-5 border-b-0 p-0 max-md:border-b',
|
||||
trigger: `${UNDERLINE_TAB_TRIGGER} max-md:text-base`,
|
||||
}"
|
||||
/>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end md:gap-4 md:pb-1.5">
|
||||
<UInput
|
||||
v-model="searchInput"
|
||||
type="search"
|
||||
icon="i-lucide-search"
|
||||
:placeholder="t('Search by title')"
|
||||
class="w-full md:w-65"
|
||||
:ui="{
|
||||
base: 'h-11 text-lg md:h-8 md:text-sm',
|
||||
leadingIcon: 'size-4 md:size-3.5',
|
||||
}"
|
||||
/>
|
||||
<NeedsAttentionToggle
|
||||
:model-value="desired.seo !== ''"
|
||||
:count="needsAttentionCount"
|
||||
class="md:-order-1"
|
||||
@update:model-value="changeSeo"
|
||||
/>
|
||||
<ArticleSiteFilter
|
||||
v-if="severalSites"
|
||||
:model-value="desired.site"
|
||||
:sites="sites"
|
||||
@update:model-value="changeSite"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ArticleListToolbar
|
||||
v-model:search="searchInput"
|
||||
:status="desired.status"
|
||||
:counts="counts"
|
||||
:needs-attention-count="needsAttentionCount"
|
||||
:needs-attention="desired.seo !== ''"
|
||||
:site="desired.site"
|
||||
:sites="sites"
|
||||
:several-sites="severalSites"
|
||||
@update:status="list.changeStatus"
|
||||
@update:needs-attention="list.changeSeo"
|
||||
@update:site="list.changeSite"
|
||||
/>
|
||||
|
||||
<UAlert
|
||||
<ArticleListNotice
|
||||
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>
|
||||
:kind="notice.kind"
|
||||
:failed-page="notice.kind === 'error' ? notice.query.page : shown.page"
|
||||
:shown-page="shown.page"
|
||||
@retry="list.retryNotice"
|
||||
/>
|
||||
|
||||
<template v-if="hasLoaded || loadFailed || showSkeleton">
|
||||
<EmptyState
|
||||
@@ -548,7 +182,7 @@ onBeforeUnmount(() => {
|
||||
:sort="effectiveSort(shown.sort)"
|
||||
:sites="severalSites ? sites : []"
|
||||
:busy-ids="busyIds"
|
||||
@sort="changeSort"
|
||||
@sort="list.changeSort"
|
||||
@publish="handlePublish"
|
||||
@unpublish="handleUnpublish"
|
||||
@delete="handleDelete"
|
||||
@@ -574,8 +208,8 @@ onBeforeUnmount(() => {
|
||||
:total="total"
|
||||
:size="shown.size"
|
||||
:loading-page="pendingPage"
|
||||
@update:page="changePage"
|
||||
@update:size="changeSize"
|
||||
@update:page="list.changePage"
|
||||
@update:size="list.changeSize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
+113
-461
@@ -1,60 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { createReusableTemplate } from '@vueuse/core'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import ErrorView from '@/components/ErrorView.vue'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import ArticleDetails from '@/components/ArticleDetails.vue'
|
||||
import MetaSection from '@/components/MetaSection.vue'
|
||||
import SearchSharingSection from '@/components/SearchSharingSection.vue'
|
||||
import SeoPanel from '@/components/SeoPanel.vue'
|
||||
import ApiPreviewDialog from '@/components/ApiPreviewDialog.vue'
|
||||
import SaveStatus from '@/components/SaveStatus.vue'
|
||||
import PublishControl from '@/components/PublishControl.vue'
|
||||
import EditorHeader from '@/components/EditorHeader.vue'
|
||||
import EditorMobileBar from '@/components/EditorMobileBar.vue'
|
||||
import EditorSkeleton from '@/components/EditorSkeleton.vue'
|
||||
import { neutralizeUnsafeLinks } from '@/lib/articleLinkUrl'
|
||||
import EditorStatusBar from '@/components/EditorStatusBar.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import ImageUploader from '@/components/ImageUploader.vue'
|
||||
import { usePageTitleOverride } from '@/composables/documentTitle'
|
||||
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { useArticleActions } from '@/composables/useArticleActions'
|
||||
import { useArticleDraft, type SaveOutcome } from '@/composables/useArticleDraft'
|
||||
import { useSeoCheck } from '@/composables/useSeoCheck'
|
||||
import { useArticleLifecycle } from '@/composables/useArticleLifecycle'
|
||||
import { useEditorFocus } from '@/composables/useEditorFocus'
|
||||
import { useEditorImages } from '@/composables/useEditorImages'
|
||||
import { useEditorSeoPanel } from '@/composables/useEditorSeoPanel'
|
||||
import { useSeoDismissals } from '@/composables/useSeoDismissals'
|
||||
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||
import { useSites, type Site } from '@/composables/useSites'
|
||||
import { toBylines } from '@/lib/articleMeta'
|
||||
import { readMetaSectionOpen, storeMetaSectionOpen } from '@/lib/metaSectionMemory'
|
||||
import { readSeoSectionOpen, storeSeoSectionOpen } from '@/lib/seoSectionMemory'
|
||||
import { SEO_LIMITS, charCount, seoChanges, seoOf, type ArticleSeo } from '@/lib/articleSeo'
|
||||
import { SEO_LIMITS, seoOf } from '@/lib/articleSeo'
|
||||
import { hostOf, slugify } from '@/lib/directory'
|
||||
import type { DirectoryNotice } from '@/lib/directoryTypes'
|
||||
import type { SeoCheckBody, SeoFocus, SuggestionCode, SuggestionField } from '@/lib/seoTypes'
|
||||
import { bodyCounts } from '@/lib/bodyCounts'
|
||||
import { useCanManageVariants } from '@/composables/useCanManageVariants'
|
||||
import { bodyHasVariantImage } from '@/lib/variants/blockVariant'
|
||||
import { useVariantCatalogStore } from '@/stores/variantCatalog'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const FOCUS_RETRIES = 10
|
||||
const FOCUS_RETRY_MS = 30
|
||||
|
||||
type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { request } = useApi()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
const directoryToasts = useDirectoryToasts()
|
||||
const { listSites } = useSites()
|
||||
const actions = useArticleActions()
|
||||
const lifecycle = useArticleLifecycle()
|
||||
const {
|
||||
editorRef,
|
||||
imageUploaderOpen,
|
||||
@@ -85,19 +82,52 @@ const publishPending = ref<string | undefined>(undefined)
|
||||
const sites = ref<Site[]>([])
|
||||
const remembered = ref<boolean | null>(readMetaSectionOpen())
|
||||
const seoRemembered = ref<boolean | null>(readSeoSectionOpen())
|
||||
const panelOpen = ref(false)
|
||||
const panelValues = ref<ArticleSeo | null>(null)
|
||||
const panelFocus = ref<SeoFocus | null>(null)
|
||||
const panelNotice = ref<DirectoryNotice | null>(null)
|
||||
const panelSaving = ref(false)
|
||||
const slugTakenOnSave = ref<string | null>(null)
|
||||
const canonicalRejected = ref(false)
|
||||
const apiPreviewOpen = ref(false)
|
||||
const dismissedCodes = ref<SuggestionCode[]>([])
|
||||
const dismissingCode = ref<SuggestionCode | null>(null)
|
||||
let panelSaveFailed = false
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function markNotFound() {
|
||||
loadState.value = 'notFound'
|
||||
}
|
||||
|
||||
const { focusBody, focusAuthors } = useEditorFocus({
|
||||
editorRef,
|
||||
detailsOpen,
|
||||
previewOn,
|
||||
metaRemembered: remembered,
|
||||
})
|
||||
|
||||
const {
|
||||
check,
|
||||
panelOpen,
|
||||
panelValues,
|
||||
panelFocus,
|
||||
panelNotice,
|
||||
panelSaving,
|
||||
slugTakenOnSave,
|
||||
canonicalRejected,
|
||||
openPanel,
|
||||
closePanel,
|
||||
updatePanelValues,
|
||||
onPanelCheck,
|
||||
goToField,
|
||||
revealSaveFailure,
|
||||
saveSeoPanel,
|
||||
} = useEditorSeoPanel({
|
||||
articleId,
|
||||
draft,
|
||||
save: () => runSave(),
|
||||
onGone: markNotFound,
|
||||
focusBody,
|
||||
focusAuthors,
|
||||
})
|
||||
|
||||
const { dismissedCodes, dismissingCode, dismissSuggestion, restoreDismissed } = useSeoDismissals({
|
||||
articleId,
|
||||
check,
|
||||
onGone: markNotFound,
|
||||
onUnauthorized: draft.mirrorToStorage,
|
||||
})
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
clearTimeout(skeletonTimer)
|
||||
@@ -124,7 +154,6 @@ function rememberSeoSection(open: boolean) {
|
||||
storeSeoSectionOpen(open)
|
||||
}
|
||||
|
||||
const seoInEffect = computed(() => panelValues.value ?? draft.seo.value)
|
||||
const siteOfArticle = computed(() =>
|
||||
sites.value.find((site) => site.id === draft.meta.value.siteId),
|
||||
)
|
||||
@@ -135,223 +164,11 @@ const address = computed(
|
||||
() => `${hostOf(siteOfArticle.value?.base_url ?? '')}/${addressSlug.value}`,
|
||||
)
|
||||
|
||||
function checkBody(): SeoCheckBody {
|
||||
const changes = seoChanges(seoInEffect.value, draft.savedSeo.value)
|
||||
if (charCount(changes.excerpt ?? '') > SEO_LIMITS.excerpt) {
|
||||
delete changes.excerpt
|
||||
}
|
||||
return {
|
||||
...changes,
|
||||
...(draft.bodyEdited.value ? { body: draft.body.value } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const check = useSeoCheck({
|
||||
articleId,
|
||||
body: checkBody,
|
||||
onGone: () => {
|
||||
loadState.value = 'notFound'
|
||||
},
|
||||
onUnauthorized: draft.mirrorToStorage,
|
||||
})
|
||||
|
||||
function reportDismissFailure() {
|
||||
toast.add(() => ({
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
get title() {
|
||||
return t("Couldn't dismiss the suggestion")
|
||||
},
|
||||
get description() {
|
||||
return t('Try again in a moment.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async function saveDismissed(next: SuggestionCode[]) {
|
||||
try {
|
||||
const updated = await request<Pick<Article, 'seo_dismissed'>>(`/v1/article/${articleId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seo_dismissed: next }),
|
||||
})
|
||||
dismissedCodes.value = updated?.seo_dismissed ?? next
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
loadState.value = 'notFound'
|
||||
} else if (err instanceof ApiError && err.status === 401) {
|
||||
draft.mirrorToStorage()
|
||||
} else {
|
||||
reportDismissFailure()
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function dismissSuggestion(code: SuggestionCode) {
|
||||
if (dismissingCode.value !== null) {
|
||||
return
|
||||
}
|
||||
dismissingCode.value = code
|
||||
const saved = await saveDismissed([...new Set([...dismissedCodes.value, code])])
|
||||
if (saved) {
|
||||
check.dropSuggestion(code)
|
||||
check.invalidate()
|
||||
check.runQuietly()
|
||||
}
|
||||
dismissingCode.value = null
|
||||
}
|
||||
|
||||
async function restoreDismissed() {
|
||||
if (await saveDismissed([])) {
|
||||
check.invalidate()
|
||||
check.runFresh()
|
||||
}
|
||||
}
|
||||
|
||||
function editBody(newBody: string) {
|
||||
draft.edit(newBody)
|
||||
check.schedule()
|
||||
}
|
||||
|
||||
function openPanel(focus: SeoFocus | null = null) {
|
||||
panelFocus.value = focus
|
||||
panelValues.value = { ...draft.seo.value }
|
||||
panelNotice.value = null
|
||||
slugTakenOnSave.value = null
|
||||
canonicalRejected.value = false
|
||||
panelOpen.value = true
|
||||
check.runFresh()
|
||||
}
|
||||
|
||||
function closePanel(discardEdits = true) {
|
||||
panelOpen.value = false
|
||||
panelValues.value = null
|
||||
if (panelSaveFailed) {
|
||||
panelSaveFailed = false
|
||||
draft.markDirty()
|
||||
}
|
||||
if (discardEdits) {
|
||||
check.restore()
|
||||
}
|
||||
}
|
||||
|
||||
function updatePanelValues(values: ArticleSeo) {
|
||||
panelValues.value = values
|
||||
}
|
||||
|
||||
function onPanelCheck(immediate: boolean) {
|
||||
if (immediate) {
|
||||
check.runNow()
|
||||
return
|
||||
}
|
||||
check.schedule()
|
||||
}
|
||||
|
||||
function focusBody() {
|
||||
detailsOpen.value = false
|
||||
previewOn.value = false
|
||||
nextTick(() => editorRef.value?.focus())
|
||||
}
|
||||
|
||||
function authorsFocusTarget() {
|
||||
const scope = detailsOpen.value
|
||||
? '[role="dialog"] [data-testid="meta-section"]'
|
||||
: '[data-testid="article-details"] [data-testid="meta-section"]'
|
||||
return document.querySelector<HTMLElement>(
|
||||
`${scope} [data-testid="author-row"] button, ${scope} button[aria-haspopup="listbox"]`,
|
||||
)
|
||||
}
|
||||
|
||||
function focusWhenRendered(find: () => HTMLElement | null, retries = FOCUS_RETRIES) {
|
||||
const target = find()
|
||||
if (target) {
|
||||
target.focus()
|
||||
return
|
||||
}
|
||||
if (retries > 0) {
|
||||
setTimeout(() => focusWhenRendered(find, retries - 1), FOCUS_RETRY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
function focusAuthors() {
|
||||
remembered.value = true
|
||||
focusWhenRendered(authorsFocusTarget)
|
||||
}
|
||||
|
||||
function goToField(field: SuggestionField) {
|
||||
if (field === 'body') {
|
||||
closePanel()
|
||||
focusBody()
|
||||
return
|
||||
}
|
||||
if (field === 'author_ids') {
|
||||
closePanel()
|
||||
focusAuthors()
|
||||
return
|
||||
}
|
||||
openPanel(field)
|
||||
}
|
||||
|
||||
function showPanelFailure(outcome: SaveOutcome) {
|
||||
if (outcome === 'slug-taken') {
|
||||
slugTakenOnSave.value = panelValues.value?.slug ?? draft.seo.value.slug
|
||||
check.runFresh()
|
||||
return
|
||||
}
|
||||
if (outcome === 'invalid-url') {
|
||||
canonicalRejected.value = true
|
||||
return
|
||||
}
|
||||
panelSaveFailed = true
|
||||
panelNotice.value = {
|
||||
title: t(
|
||||
"Couldn't save search and sharing. Your changes are still here. Try again in a moment.",
|
||||
),
|
||||
description: '',
|
||||
}
|
||||
}
|
||||
|
||||
function announceSeoSaved() {
|
||||
toast.add(() => ({
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
get title() {
|
||||
return t('Search and sharing saved')
|
||||
},
|
||||
get description() {
|
||||
return t('Your search and sharing settings are up to date.')
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async function saveSeoPanel() {
|
||||
const values = panelValues.value
|
||||
if (!values || panelSaving.value) {
|
||||
return
|
||||
}
|
||||
draft.editSeo(values)
|
||||
if (draft.state.value !== 'dirty') {
|
||||
closePanel()
|
||||
return
|
||||
}
|
||||
panelNotice.value = null
|
||||
panelSaving.value = true
|
||||
const outcome = await runSave()
|
||||
panelSaving.value = false
|
||||
if (outcome === 'saved') {
|
||||
closePanel(false)
|
||||
announceSeoSaved()
|
||||
return
|
||||
}
|
||||
if (['not-found', 'site-gone', 'author-gone', 'unauthorized'].includes(outcome)) {
|
||||
closePanel()
|
||||
return
|
||||
}
|
||||
showPanelFailure(outcome)
|
||||
}
|
||||
|
||||
async function loadArticle() {
|
||||
loadState.value = 'loading'
|
||||
showSkeleton.value = false
|
||||
@@ -384,7 +201,7 @@ async function loadArticle() {
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
loadState.value = 'notFound'
|
||||
markNotFound()
|
||||
return
|
||||
}
|
||||
loadState.value = 'error'
|
||||
@@ -425,10 +242,7 @@ async function runSave(): Promise<SaveOutcome> {
|
||||
check.invalidate()
|
||||
check.runFresh()
|
||||
}
|
||||
if (!panelOpen.value && (outcome === 'slug-taken' || outcome === 'invalid-url')) {
|
||||
openPanel('slug')
|
||||
showPanelFailure(outcome)
|
||||
}
|
||||
revealSaveFailure(outcome)
|
||||
if (outcome === 'site-gone') {
|
||||
directoryToasts.siteGone()
|
||||
sites.value = await fetchSites()
|
||||
@@ -466,27 +280,26 @@ function goBack() {
|
||||
router.push('/article')
|
||||
}
|
||||
|
||||
async function setOnline(makeOnline: boolean) {
|
||||
if (!article.value) {
|
||||
return
|
||||
}
|
||||
publishPending.value = makeOnline ? t('Publishing…') : t('Unpublishing…')
|
||||
try {
|
||||
if (makeOnline && draft.state.value === 'dirty' && (await runSave()) !== 'saved') {
|
||||
return
|
||||
}
|
||||
await actions.publish(article.value, makeOnline, {
|
||||
onUpdated: (updated) => {
|
||||
article.value = updated
|
||||
},
|
||||
onGone: () => {
|
||||
loadState.value = 'notFound'
|
||||
},
|
||||
retry: () => setOnline(makeOnline),
|
||||
})
|
||||
} finally {
|
||||
publishPending.value = undefined
|
||||
function setOnline(makeOnline: boolean) {
|
||||
const current = article.value
|
||||
if (!current) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return lifecycle.changeOnline(current, makeOnline, {
|
||||
prepare: async () =>
|
||||
!(makeOnline && draft.state.value === 'dirty') || (await runSave()) === 'saved',
|
||||
pending: (busy) => {
|
||||
publishPending.value = busy
|
||||
? makeOnline
|
||||
? t('Publishing…')
|
||||
: t('Unpublishing…')
|
||||
: undefined
|
||||
},
|
||||
onUpdated: (updated) => {
|
||||
article.value = updated
|
||||
},
|
||||
onGone: markNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
function handlePublish() {
|
||||
@@ -502,14 +315,10 @@ async function handleDelete() {
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
await actions.confirmDelete(current, () =>
|
||||
actions.deleteArticle(current, {
|
||||
onDeleted: () => router.push('/article'),
|
||||
onGone: () => {
|
||||
loadState.value = 'notFound'
|
||||
},
|
||||
}),
|
||||
)
|
||||
await lifecycle.confirmAndDelete(current, {
|
||||
onDeleted: () => router.push('/article'),
|
||||
onGone: markNotFound,
|
||||
})
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
@@ -529,37 +338,9 @@ onBeforeUnmount(() => {
|
||||
clearSkeletonTimer()
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (authRedirectPending.value || draft.state.value !== 'dirty') {
|
||||
return true
|
||||
}
|
||||
const confirmed = await overlay
|
||||
.create(ConfirmDialog, {
|
||||
props: {
|
||||
get title() {
|
||||
return t('Leave without saving?')
|
||||
},
|
||||
get description() {
|
||||
return t("Your changes to \u0022{0}\u0022 haven't been saved.", [
|
||||
article.value?.title ?? '',
|
||||
])
|
||||
},
|
||||
get confirmLabel() {
|
||||
return t('Keep editing')
|
||||
},
|
||||
get cancelLabel() {
|
||||
return t('Discard changes')
|
||||
},
|
||||
get pendingLabel() {
|
||||
return t('Keep editing')
|
||||
},
|
||||
tone: 'neutral',
|
||||
action: async () => {},
|
||||
dismissResolvesTo: 'confirm',
|
||||
},
|
||||
})
|
||||
.open()
|
||||
return !confirmed
|
||||
useUnsavedChangesGuard({
|
||||
isDirty: () => draft.state.value === 'dirty',
|
||||
title: () => article.value?.title ?? '',
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -633,135 +414,25 @@ onBeforeRouteLeave(async () => {
|
||||
<ErrorView v-if="loadState === 'notFound'" kind="article-not-found" />
|
||||
<ErrorView v-else-if="loadState === 'error'" kind="error" @retry="loadArticle" />
|
||||
|
||||
<div
|
||||
v-else-if="loadState === 'loading' && showSkeleton"
|
||||
aria-busy="true"
|
||||
class="flex min-h-0 flex-1 flex-col lg:flex-row"
|
||||
>
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
class="flex flex-shrink-0 items-center gap-2.5 border-b border-default bg-default px-6 py-3.5"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-arrow-left"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="size-8 justify-center text-toned"
|
||||
:aria-label="t('Back to Articles')"
|
||||
@click="goBack"
|
||||
/>
|
||||
<USkeleton class="h-5 w-80 rounded-none" data-testid="skeleton-title" />
|
||||
<USkeleton class="h-5.5 w-15 rounded-none" />
|
||||
</div>
|
||||
<div
|
||||
class="min-h-0 max-w-168 flex-1 space-y-3.5 px-12 py-10"
|
||||
data-testid="skeleton-content"
|
||||
>
|
||||
<USkeleton class="h-3.5 w-full rounded-none" />
|
||||
<USkeleton class="h-3.5 w-11/12 rounded-none" />
|
||||
<USkeleton class="h-3.5 w-4/5 rounded-none" />
|
||||
<USkeleton class="mt-4.5 h-5.5 w-2/5 rounded-none" />
|
||||
<USkeleton class="h-3.5 w-full rounded-none" />
|
||||
<USkeleton class="h-3.5 w-3/4 rounded-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="w-70 flex-shrink-0 space-y-2.5 border-default p-5 lg:border-l"
|
||||
data-testid="skeleton-details"
|
||||
>
|
||||
<div class="flex flex-col gap-3 border-b border-default pb-4">
|
||||
<div class="flex min-h-6 items-center gap-2 text-highlighted">
|
||||
<UIcon name="i-lucide-chevron-down" class="size-3.5 -rotate-90" />
|
||||
<span class="text-sm font-semibold">{{ t('Site and authors') }}</span>
|
||||
</div>
|
||||
<USkeleton class="ml-5.5 h-3 w-2/5 rounded-none" data-testid="skeleton-meta" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 border-b border-default pb-4">
|
||||
<div class="flex min-h-6 items-center gap-2 text-highlighted">
|
||||
<UIcon name="i-lucide-chevron-down" class="size-3.5 -rotate-90" />
|
||||
<span class="text-sm font-semibold">{{ t('Search and sharing') }}</span>
|
||||
</div>
|
||||
<USkeleton class="ml-5.5 h-3 w-2/5 rounded-none" data-testid="skeleton-seo" />
|
||||
</div>
|
||||
<USkeleton class="h-3 w-full rounded-none" />
|
||||
<USkeleton class="h-3 w-full rounded-none" />
|
||||
<USkeleton class="h-3 w-3/5 rounded-none" />
|
||||
</div>
|
||||
</div>
|
||||
<EditorSkeleton v-else-if="loadState === 'loading' && showSkeleton" @back="goBack" />
|
||||
|
||||
<div v-else-if="loadState === 'loaded' && article" class="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
class="flex flex-shrink-0 flex-wrap items-center gap-x-3 gap-y-2 border-b border-default bg-default px-4 py-3 lg:gap-3 lg:px-6 lg:py-3.5"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-arrow-left"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="hidden size-8 justify-center text-toned lg:inline-flex"
|
||||
:aria-label="t('Back to Articles')"
|
||||
@click="goBack"
|
||||
/>
|
||||
<div
|
||||
class="flex min-w-0 flex-1 basis-full flex-wrap items-center gap-2.5 lg:min-w-50 lg:basis-50"
|
||||
>
|
||||
<h1
|
||||
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
|
||||
:title="t('The title can\'t be changed after creation.')"
|
||||
>
|
||||
{{ article.title }}
|
||||
</h1>
|
||||
<ArticleStatusBadge :online="article.is_online" size="lg" />
|
||||
</div>
|
||||
<SaveStatus
|
||||
:state="draft.state.value"
|
||||
:saved-at="draft.savedAt.value"
|
||||
@retry="retrySave"
|
||||
/>
|
||||
<span class="ml-auto font-mono text-xs text-muted lg:hidden">{{ countText }}</span>
|
||||
<div class="hidden items-center gap-3 lg:flex">
|
||||
<USwitch
|
||||
v-model="previewOn"
|
||||
:label="t('Preview')"
|
||||
:ui="{
|
||||
root: 'items-center gap-2',
|
||||
container: 'h-auto',
|
||||
base: 'h-4.5 w-8 p-0.5',
|
||||
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-2.5',
|
||||
wrapper: 'ms-0',
|
||||
label: 'text-sm font-normal text-toned',
|
||||
}"
|
||||
/>
|
||||
<UButton
|
||||
data-testid="save-button"
|
||||
icon="i-lucide-save"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-8 gap-1.5 px-3"
|
||||
:disabled="!saveEnabled"
|
||||
@click="performSave"
|
||||
>
|
||||
{{ t('Save') }}
|
||||
</UButton>
|
||||
<PublishControl
|
||||
class="h-8 gap-1.5 px-3"
|
||||
:online="article.is_online"
|
||||
:dirty="draft.state.value === 'dirty'"
|
||||
:pending="publishPending"
|
||||
:disabled="publishDisabled"
|
||||
@publish="handlePublish"
|
||||
@unpublish="handleUnpublish"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
class="h-8 gap-1.5 px-3"
|
||||
@click="handleDelete"
|
||||
>
|
||||
{{ t('Delete') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
<EditorHeader
|
||||
v-model:preview="previewOn"
|
||||
:article="article"
|
||||
:state="draft.state.value"
|
||||
:saved-at="draft.savedAt.value"
|
||||
:count-text="countText"
|
||||
:save-enabled="saveEnabled"
|
||||
:publish-pending="publishPending"
|
||||
:publish-disabled="publishDisabled"
|
||||
@back="goBack"
|
||||
@retry="retrySave"
|
||||
@save="performSave"
|
||||
@publish="handlePublish"
|
||||
@unpublish="handleUnpublish"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
|
||||
<div v-if="previewOn" class="flex min-h-0 flex-1">
|
||||
<div
|
||||
@@ -849,36 +520,17 @@ onBeforeRouteLeave(async () => {
|
||||
@close="onImageUploaderClosed"
|
||||
/>
|
||||
|
||||
<div class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden">
|
||||
<UButton
|
||||
icon="i-lucide-panel-right"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="size-11 justify-center"
|
||||
:aria-label="t('Details')"
|
||||
@click="detailsOpen = true"
|
||||
/>
|
||||
<UButton
|
||||
data-testid="save-button-mobile"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 flex-1 justify-center"
|
||||
:disabled="!saveEnabled"
|
||||
@click="performSave"
|
||||
>
|
||||
{{ t('Save') }}
|
||||
</UButton>
|
||||
<PublishControl
|
||||
class="h-11 flex-1 justify-center"
|
||||
hide-icon
|
||||
:online="article.is_online"
|
||||
:dirty="draft.state.value === 'dirty'"
|
||||
:pending="publishPending"
|
||||
:disabled="publishDisabled"
|
||||
@publish="handlePublish"
|
||||
@unpublish="handleUnpublish"
|
||||
/>
|
||||
</div>
|
||||
<EditorMobileBar
|
||||
:online="article.is_online"
|
||||
:state="draft.state.value"
|
||||
:save-enabled="saveEnabled"
|
||||
:publish-pending="publishPending"
|
||||
:publish-disabled="publishDisabled"
|
||||
@details="detailsOpen = true"
|
||||
@save="performSave"
|
||||
@publish="handlePublish"
|
||||
@unpublish="handleUnpublish"
|
||||
/>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -7,12 +7,7 @@ import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import {
|
||||
NEEDS_ATTENTION_ROUTE,
|
||||
articleEnvelope,
|
||||
articleListRoute,
|
||||
needsAttentionCountPage,
|
||||
} from './articleEnvelope'
|
||||
import { articleEnvelope, articleListRoute } from './articleEnvelope'
|
||||
|
||||
const NOW = new Date('2026-08-26T10:00:00Z')
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
@@ -145,7 +140,6 @@ async function mountArticles(handlers: Record<string, () => Response | Promise<R
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = fetchRouter({
|
||||
[NEEDS_ATTENTION_ROUTE]: () => mockResponse(200, needsAttentionCountPage()),
|
||||
...handlers,
|
||||
}) as unknown as typeof fetch
|
||||
await router.push('/article')
|
||||
|
||||
@@ -8,13 +8,7 @@ import type { Article } from '@/composables/useArticles'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import {
|
||||
articleEnvelope,
|
||||
isNeedsAttentionCount,
|
||||
needsAttentionCountPage,
|
||||
parseListUrl,
|
||||
type EnvelopeParams,
|
||||
} from './articleEnvelope'
|
||||
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
|
||||
const KEY = 'renewcms-page-size-articles'
|
||||
const TOTAL = 312
|
||||
@@ -51,9 +45,6 @@ function installFetch() {
|
||||
json: async () => oneSite,
|
||||
} as unknown as Response)
|
||||
}
|
||||
if (method === 'GET' && isNeedsAttentionCount(url)) {
|
||||
return Promise.resolve(mockResponse(200, needsAttentionCountPage()))
|
||||
}
|
||||
if (method === 'GET' && url.startsWith('/v1/article/?')) {
|
||||
const params = parseListUrl(url)
|
||||
listCalls.push(params)
|
||||
|
||||
@@ -9,13 +9,7 @@ import type { Article, ArticlePage } from '@/composables/useArticles'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import {
|
||||
articleEnvelope,
|
||||
isNeedsAttentionCount,
|
||||
needsAttentionCountPage,
|
||||
parseListUrl,
|
||||
type EnvelopeParams,
|
||||
} from './articleEnvelope'
|
||||
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
|
||||
const TOTAL = 312
|
||||
const PLASTER_EVERY = 20
|
||||
@@ -91,9 +85,6 @@ function installFetch() {
|
||||
if (method === 'GET' && url === '/v1/site/') {
|
||||
return Promise.resolve(mockResponse(200, oneSite))
|
||||
}
|
||||
if (method === 'GET' && isNeedsAttentionCount(url)) {
|
||||
return Promise.resolve(mockResponse(200, needsAttentionCountPage()))
|
||||
}
|
||||
if (method === 'GET' && url.startsWith('/v1/article/?')) {
|
||||
const params = parseListUrl(url)
|
||||
listCalls.push(params)
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('Default, every card collapsed', () => {
|
||||
['articles', 'seo', 'media', 'users', 'settings'].map(
|
||||
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
|
||||
),
|
||||
).toEqual([8, 2, 10, 6, 1])
|
||||
).toEqual([8, 2, 11, 6, 1])
|
||||
expect(section(wrapper, 'articles').text()).toContain('8 routes')
|
||||
expect(section(wrapper, 'seo').text()).toContain('2 routes')
|
||||
expect(section(wrapper, 'sites').text()).toContain('1 route')
|
||||
|
||||
@@ -309,87 +309,91 @@ describe('Needs attention toggle', () => {
|
||||
})
|
||||
|
||||
describe('Needs attention count', () => {
|
||||
it('reads the total of the extra request with the current q, status and site', async () => {
|
||||
function envelope(counts: Record<string, number>) {
|
||||
return { items: [], total: 0, page: 1, size: 25, counts }
|
||||
}
|
||||
|
||||
it('reads counts.suggestions of the list response and sends no extra request', async () => {
|
||||
const backend = backendWith(boardArticles(), 'several')
|
||||
await mountMeta('articles', '/article?q=Article&status=online&site=renew', { backend })
|
||||
|
||||
const calls = countCalls(backend)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.raw).toBe(
|
||||
'/v1/article/?seo=suggestions&page=1&size=5&q=Article&status=online&site=renew',
|
||||
)
|
||||
expect(textOf(toggle())).toBe('Needs attention 3')
|
||||
expect(
|
||||
callsTo(backend, 'GET', '/v1/article/').filter(
|
||||
(call) => !call.query.has('format') || call.query.get('size') === '5',
|
||||
),
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ total: 3, label: 'Needs attention 3' },
|
||||
{ total: 0, label: 'Needs attention 0' },
|
||||
])('shows $label', async ({ total, label }) => {
|
||||
{ suggestions: 3, label: 'Needs attention 3' },
|
||||
{ suggestions: 0, label: 'Needs attention 0' },
|
||||
])('shows $label', async ({ suggestions, label }) => {
|
||||
const backend = backendWith(boardArticles())
|
||||
backend.responders['GET /v1/article/'] = (call) =>
|
||||
call.query.has('format')
|
||||
? {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 0, online: 0, draft: 0 },
|
||||
}
|
||||
: { items: [], total, page: 1, size: 5, counts: { all: 0, online: 0, draft: 0 } }
|
||||
backend.responders['GET /v1/article/'] = () =>
|
||||
envelope({ all: 5, online: 2, draft: 3, suggestions })
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(textOf(toggle())).toBe(label)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failure: 'status 500', responder: failWith(500) },
|
||||
{ failure: 'a network error', responder: networkFailure },
|
||||
])('shows no count and no notice on $failure', async ({ responder }) => {
|
||||
it('shows no count when counts.suggestions is absent', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
const defaults = backend.responders
|
||||
backend.responders['GET /v1/article/'] = (call) => {
|
||||
if (!call.query.has('format')) {
|
||||
return responder(call)
|
||||
}
|
||||
return defaults
|
||||
? {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 5, online: 2, draft: 3 },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
backend.responders['GET /v1/article/'] = () => envelope({ all: 5, online: 2, draft: 3 })
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(textOf(toggle())).toBe('Needs attention')
|
||||
})
|
||||
|
||||
it('shows no count before the first response arrives', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
backend.responders['GET /v1/article/'] = () => new Promise(() => {})
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(textOf(toggle())).toBe('Needs attention')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ failure: 'status 500', responder: failWith(500) },
|
||||
{ failure: 'a network error', responder: networkFailure },
|
||||
])('shows no count and no extra notice on $failure', async ({ responder }) => {
|
||||
const backend = backendWith(boardArticles())
|
||||
backend.responders['GET /v1/article/'] = responder
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(textOf(toggle())).toBe('Needs attention')
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
expect(currentQuery()).toEqual({})
|
||||
})
|
||||
|
||||
it('refreshes when q, tab or site change and never for page, size or sort', async () => {
|
||||
it('keeps the last count while a new request is in flight', async () => {
|
||||
const backend = backendWith(boardArticles(), 'several')
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
expect(textOf(toggle())).toBe('Needs attention 3')
|
||||
|
||||
backend.responders['GET /v1/article/'] = () => new Promise(() => {})
|
||||
await clickTab('Drafts')
|
||||
|
||||
expect(textOf(toggle())).toBe('Needs attention 3')
|
||||
})
|
||||
|
||||
it('does not change when q, tab, site, sort, page or size change', async () => {
|
||||
const backend = backendWith(
|
||||
Array.from({ length: 30 }, (_, index) => withSeo(index + 1, 'suggestions', 1)),
|
||||
'several',
|
||||
)
|
||||
await mountMeta('articles', '/article?size=5', { backend })
|
||||
expect(countCalls(backend)).toHaveLength(1)
|
||||
expect(textOf(toggle())).toBe('Needs attention 30')
|
||||
|
||||
document.body.querySelector<HTMLElement>('button[aria-label="Next page"]')!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('thead button'))
|
||||
.find((item) => textOf(item).startsWith('Title'))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
expect(countCalls(backend)).toHaveLength(1)
|
||||
|
||||
await clickTab('Drafts')
|
||||
expect(countCalls(backend)).toHaveLength(2)
|
||||
await search('Article 1')
|
||||
expect(countCalls(backend)).toHaveLength(3)
|
||||
|
||||
expect(textOf(toggle())).toBe('Needs attention 30')
|
||||
expect(countCalls(backend)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses the main response total while the filter is on, with no extra request', async () => {
|
||||
it('reads counts.suggestions while the filter is on, with no extra request', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', '/article?seo=suggestions', { backend })
|
||||
|
||||
|
||||
@@ -183,14 +183,6 @@ export function stripCards() {
|
||||
)
|
||||
}
|
||||
|
||||
export async function setSearch(value: string) {
|
||||
const input = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
return input
|
||||
}
|
||||
|
||||
export function lastListQuery() {
|
||||
const queries = callsTo(LIST_PATH)
|
||||
return queries[queries.length - 1]
|
||||
@@ -243,11 +235,3 @@ export function dialogButton(label: string) {
|
||||
(button) => textOf(button) === label || button.getAttribute('aria-label') === label,
|
||||
)
|
||||
}
|
||||
|
||||
export function dialogButtons() {
|
||||
return Array.from(
|
||||
dialogElement()?.querySelectorAll<HTMLButtonElement>(
|
||||
'footer button, [data-slot="footer"] button',
|
||||
) ?? [],
|
||||
).map(textOf)
|
||||
}
|
||||
@@ -10,9 +10,6 @@ export interface EnvelopeParams {
|
||||
seo?: '' | 'suggestions'
|
||||
}
|
||||
|
||||
export const NEEDS_ATTENTION_TOTAL = 3
|
||||
export const NEEDS_ATTENTION_ROUTE = 'GET /v1/article/?seo=suggestions&page=1&size=5'
|
||||
|
||||
function fold(value: string) {
|
||||
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||
}
|
||||
@@ -45,15 +42,6 @@ export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): Ar
|
||||
}
|
||||
}
|
||||
|
||||
export function isNeedsAttentionCount(url: string) {
|
||||
const query = new URL(url, 'http://localhost').searchParams
|
||||
return query.get('seo') === 'suggestions' && query.get('size') === '5' && !query.has('format')
|
||||
}
|
||||
|
||||
export function needsAttentionCountPage(total = NEEDS_ATTENTION_TOTAL): ArticlePage {
|
||||
return { items: [], total, page: 1, size: 5, counts: { all: 0, online: 0, draft: 0 } }
|
||||
}
|
||||
|
||||
export function articleListUrl(params: EnvelopeParams = {}) {
|
||||
const { page = 1, size = 25, q = '', status = '', sort = '', site = '', seo = '' } = params
|
||||
const query = new URLSearchParams({
|
||||
|
||||
@@ -58,11 +58,6 @@ export const camille: Byline = {
|
||||
slug: 'camille-roche',
|
||||
}
|
||||
export const paul: Byline = { id: 8, name: 'Paul Huber', slug: 'paul-huber' }
|
||||
export const leaFontaine: Byline = {
|
||||
id: 10,
|
||||
name: 'Léa Fontaine',
|
||||
slug: 'lea-fontaine',
|
||||
}
|
||||
|
||||
export const articleMeta = { site_id: defaultSite.id, authors: [ines] }
|
||||
|
||||
|
||||
@@ -189,6 +189,10 @@ function listArticles(backend: MetaBackend, query: URLSearchParams) {
|
||||
all: backend.articles.length,
|
||||
online,
|
||||
draft: backend.articles.length - online,
|
||||
suggestions: backend.articles.filter(
|
||||
(article) =>
|
||||
article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0,
|
||||
).length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,9 @@ export function seoBackend(
|
||||
size: 100,
|
||||
counts: { all: 2, unused: 0, duplicates: 0 },
|
||||
})
|
||||
for (const item of [NORTH_WALL, KITCHEN_AFTER]) {
|
||||
backend.responders[`GET /v1/media/${item.id}`] = () => item
|
||||
}
|
||||
return backend
|
||||
}
|
||||
|
||||
@@ -196,12 +199,6 @@ export async function openDisclosure(title: string) {
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function inputByPlaceholder(root: ParentNode, placeholder: string) {
|
||||
return root.querySelector<HTMLInputElement | HTMLTextAreaElement>(
|
||||
`[placeholder="${placeholder}"]`,
|
||||
)!
|
||||
}
|
||||
|
||||
export function fieldByLabel(root: ParentNode, label: string) {
|
||||
const element = Array.from(root.querySelectorAll('label')).find((candidate) =>
|
||||
textOf(candidate).startsWith(label),
|
||||
|
||||
+16
-10
@@ -919,15 +919,6 @@ function articleEnvelopeFor(
|
||||
url: URL,
|
||||
needsAttention = boardNeedsAttentionCount,
|
||||
) {
|
||||
if (url.searchParams.get('seo') === 'suggestions' && !url.searchParams.has('format')) {
|
||||
return {
|
||||
items: [],
|
||||
total: needsAttention,
|
||||
page: 1,
|
||||
size: 5,
|
||||
counts: { all: 0, online: 0, draft: 0 },
|
||||
}
|
||||
}
|
||||
const page = Number(url.searchParams.get('page') ?? 1)
|
||||
const size = Number(url.searchParams.get('size') ?? 25)
|
||||
const q = url.searchParams.get('q') ?? ''
|
||||
@@ -944,7 +935,12 @@ function articleEnvelopeFor(
|
||||
total: matching.length,
|
||||
page,
|
||||
size,
|
||||
counts: { all: all.length, online, draft: all.length - online },
|
||||
counts: {
|
||||
all: all.length,
|
||||
online,
|
||||
draft: all.length - online,
|
||||
suggestions: needsAttention,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5682,8 +5678,18 @@ interface SeoOptions {
|
||||
library?: boolean
|
||||
}
|
||||
|
||||
function seoMediaById(): Record<string, ApiMock> {
|
||||
return Object.fromEntries(
|
||||
avatarPickerList().items.map((item) => [
|
||||
`GET /v1/media/${(item as { id: number }).id}`,
|
||||
{ status: 200, body: item },
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function seoApi(options: SeoOptions = {}): Record<string, ApiMock> {
|
||||
return editorApi({
|
||||
...seoMediaById(),
|
||||
'GET /v1/article/12': {
|
||||
status: 200,
|
||||
body: seoArticle(options.article, options.body),
|
||||
|
||||
Reference in new issue
Block a user