feat: editorial article list
This commit is contained in:
1 parent
a0b3c58c52
commit
a671b81f80
103 files changed
+6464
-1776
No files matched your search
@@ -397,7 +397,7 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
|
||||
Token: authtoken.New([]byte(settings.JWTSecret), authtoken.WithRevocationStore(sessionRevocation)),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(repos.Transactor, renderer, recorder, repos.SiteRepo, repos.AuthorRepo),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(repos.ArticleRepo, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(repos.ArticleRepo, renderer, repos.SiteRepo, repos.LocalizationRepo),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(repos.ArticleRepo, renderer, recorder, repos.SiteRepo, repos.AuthorRepo, repos.ImageRepo, repos.LocalizationRepo, moveStory),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(repos.ArticleRepo, recorder),
|
||||
CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(repos.Transactor, repos.ArticleRepo, repos.LocalizationRepo, renderer, recorder),
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -559,6 +560,29 @@ type listQuery struct {
|
||||
Site *string `validate:"omitempty,min=1"`
|
||||
SEO string `validate:"omitempty,oneof=ready suggestions hidden"`
|
||||
Sort string `validate:"oneof=title:asc title:desc status:asc status:desc created_at:asc created_at:desc updated_at:asc updated_at:desc"`
|
||||
Group *string `validate:"required_if=Tab missing,omitempty,eq=story"`
|
||||
Tab *string `validate:"omitempty,oneof=all missing"`
|
||||
Lang *string `validate:"omitempty,min=1"`
|
||||
Author *uint32 `validate:"omitempty,gte=1"`
|
||||
}
|
||||
|
||||
const groupByStory = "story"
|
||||
|
||||
type storyItemResponse struct {
|
||||
listItemResponse
|
||||
StoryUpdatedAt time.Time `json:"story_updated_at"`
|
||||
StoryAuthors []author.Byline `json:"story_authors"`
|
||||
Coverage []domain.Translation `json:"coverage"`
|
||||
MissingLangs []string `json:"missing_langs"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
}
|
||||
|
||||
type storyListResponse struct {
|
||||
Items []storyItemResponse `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
Counts domain.StoryCounts `json:"counts"`
|
||||
}
|
||||
|
||||
const defaultListSort = "updated_at:desc"
|
||||
@@ -612,7 +636,38 @@ func readListQuery(values url.Values) (listQuery, error) {
|
||||
if values.Has("seo") && seoStatus == "" {
|
||||
return listQuery{}, errors.New("empty seo")
|
||||
}
|
||||
return listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue, Site: siteKey, SEO: seoStatus}, nil
|
||||
query := listQuery{Page: page, Size: size, Q: values.Get("q"), Status: status, Sort: sortValue, Site: siteKey, SEO: seoStatus}
|
||||
if err := readLocalizedQuery(values, &query); err != nil {
|
||||
return listQuery{}, err
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func readLocalizedQuery(values url.Values, query *listQuery) error {
|
||||
if values.Has("group") {
|
||||
group := values.Get("group")
|
||||
query.Group = &group
|
||||
}
|
||||
if values.Has("tab") {
|
||||
tab := values.Get("tab")
|
||||
query.Tab = &tab
|
||||
}
|
||||
if values.Has("lang") {
|
||||
tag, err := language.Normalize(values.Get("lang"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query.Lang = &tag
|
||||
}
|
||||
if values.Has("author") {
|
||||
id, err := strconv.ParseUint(values.Get("author"), 10, 32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authorID := uint32(id)
|
||||
query.Author = &authorID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -622,14 +677,21 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
query, err := readListQuery(r.URL.Query())
|
||||
if err != nil || validator.Validate.Struct(query) != nil {
|
||||
if err != nil || validator.Validate.Struct(query) != nil || query.Group != nil && access.CallerOf(r).UserID == 0 {
|
||||
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if stringOrEmpty(query.Group) == groupByStory {
|
||||
h.listStories(w, r, format, query)
|
||||
return
|
||||
}
|
||||
|
||||
page, err := h.listUseCase.ListArticlesPage(useCases.ListArticlesCommand{
|
||||
Format: format,
|
||||
Caller: access.CallerOf(r),
|
||||
Lang: query.Lang,
|
||||
Author: query.Author,
|
||||
Page: query.Page,
|
||||
Size: query.Size,
|
||||
Q: query.Q,
|
||||
@@ -653,17 +715,66 @@ func (h *Handler) ListArticles(w http.ResponseWriter, r *http.Request) {
|
||||
responses = append(responses, response)
|
||||
}
|
||||
|
||||
articlesJson, err := json.Marshal(listResponse{Items: responses, Total: page.Total, Page: page.Page, Size: page.Size, Counts: page.Counts})
|
||||
writeListJSON(w, listResponse{Items: responses, Total: page.Total, Page: page.Page, Size: page.Size, Counts: page.Counts})
|
||||
}
|
||||
|
||||
func writeListJSON(w http.ResponseWriter, payload any) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := w.Write(articlesJson); err != nil {
|
||||
if _, err := w.Write(body); err != nil {
|
||||
log.Printf("Failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) listStories(w http.ResponseWriter, r *http.Request, format domain.BodyFormat, query listQuery) {
|
||||
caller := access.CallerOf(r)
|
||||
page, err := h.listUseCase.ListStoriesPage(useCases.ListStoriesCommand{
|
||||
Format: format,
|
||||
Caller: caller,
|
||||
Page: query.Page,
|
||||
Size: query.Size,
|
||||
Q: query.Q,
|
||||
Status: query.Status,
|
||||
Sort: parseSort(query.Sort),
|
||||
SiteKey: query.Site,
|
||||
SEO: query.SEO,
|
||||
Lang: stringOrEmpty(query.Lang),
|
||||
Tab: stringOrEmpty(query.Tab),
|
||||
Author: query.Author,
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to retrieve articles", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
responses := make([]storyItemResponse, 0, len(page.Rows))
|
||||
for _, row := range page.Rows {
|
||||
item, err := toListItemResponse(row.Article, row.SEO, format, caller)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
responses = append(responses, storyItemResponse{
|
||||
listItemResponse: item,
|
||||
StoryUpdatedAt: row.UpdatedAt,
|
||||
StoryAuthors: row.Authors,
|
||||
Coverage: row.Coverage,
|
||||
MissingLangs: row.MissingLangs,
|
||||
Capabilities: row.Capabilities,
|
||||
})
|
||||
}
|
||||
writeListJSON(w, storyListResponse{Items: responses, Total: page.Total, Page: page.Page, Size: page.Size, Counts: page.Counts})
|
||||
}
|
||||
|
||||
func stringOrEmpty(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteArticle(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseArticleId(r)
|
||||
if err != nil {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
The full, searchable reference is the Integration page of the running app (`/integration`). It
|
||||
renders `web/src/data/apiCatalogue.ts`, which is the single source for this page. This file is for
|
||||
readers without the UI. Reading articles (pagination, `format`, body schemas) has its own page,
|
||||
[api/articles.md](api/articles.md), versions, translations and the story move have [api/article-localization.md](api/article-localization.md), site languages and the one-time language setup are in [api/site-languages.md](api/site-languages.md), and API keys are covered in [api/api-keys.md](api/api-keys.md).
|
||||
[api/articles.md](api/articles.md), versions, translations, the story move and the story-grouped editorial list (`GET /v1/article/?group=story`) have [api/article-localization.md](api/article-localization.md), site languages and the one-time language setup are in [api/site-languages.md](api/site-languages.md), and API keys are covered in [api/api-keys.md](api/api-keys.md).
|
||||
|
||||
## Base URL
|
||||
The origin that serves the app, for example `https://cms.example`. Every path starts with `/v1`,
|
||||
|
||||
@@ -160,6 +160,38 @@ Verified session or API key. The caller needs `content.update` on every version
|
||||
|
||||
Everything is checked inside the transaction, under the locks of both sites, and nothing moves when something fails: every language of every version (readable or not) must be enabled at the destination (`422 language_not_enabled`, `X-Missing-Languages` limited to languages of readable versions, a language disabled at the destination counts as missing, a language disabled at the source does not matter), and no address may exist at the destination in the same language (`409 slug_conflict`, `X-Conflicting-Articles` limited to readable versions). A conflict that appears between the check and the write answers `409 slug_conflict` without the header.
|
||||
|
||||
## Editorial list by story: `GET /v1/article/?group=story`
|
||||
|
||||
An opt-in shape of the list for a signed-in, verified caller whose role resolves. Without `group` the response keeps the flat envelope `{ items, total, page, size, counts }` of the paginated list, whose `counts` are `all`, `online`, `draft` and `suggestions`. A caller without a valid session, or with an unverified user, who sends `group` gets 400 with the body-mismatch message and nothing is read.
|
||||
|
||||
| Parameter | Meaning |
|
||||
| --- | --- |
|
||||
| `group` | Only `story`. `group=`, any other value, or `group` from a public caller is 400. |
|
||||
| `lang` | A supported language tag, normalized like the language of a version (`fr`, `pt-br` becomes `pt-BR`). Empty, malformed or unsupported is 400. Also narrows the flat list (`items`, `total`, `counts.all`, `counts.online`, `counts.draft`). Disabled languages can be selected. |
|
||||
| `tab` | `all` (default) or `missing`. `review`, an empty value, any other value, and `missing` without `group=story` are 400. |
|
||||
| `author` | A byline id, an integer of at least 1; anything else is 400. Also narrows the flat list. |
|
||||
| `page`, `size`, `q`, `status`, `site`, `seo`, `sort` | Same values, defaults and validation as the flat list. |
|
||||
|
||||
Only versions the caller can read count: they are titled, credited, listed in `coverage`, searched and counted. A hidden version never appears, and a story whose versions are all hidden has no row.
|
||||
|
||||
**Rows.** With no `lang` a row is a story: `total` counts stories. With `lang` a row is the version of that language (`tab=all`), and the whole story's `coverage`, `story_authors` and `missing_langs` still ride on the row. With `tab=missing` rows are stories again: `lang=fr` lists the stories on a site that enables `fr` and that have a readable version but none in `fr`; without `lang` it lists the stories that lack at least one enabled language, and a caller who cannot create (no `content.create`) gets `total` 0. Articles without language (`lang` `""`) are one story each and are never listed as missing a language. A version of the story must satisfy `status`, `author`, `q` and `seo` together to make the story match; `q` ignores case and accents.
|
||||
|
||||
**Item.** The article list item of the representative version (the one in `lang`, otherwise the site default language version, otherwise the first readable one by language tag), plus:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `story_updated_at` | RFC 3339 | The latest `updated_at` among the readable versions of the story |
|
||||
| `story_authors` | array of bylines | The union of the bylines of the readable versions, without duplicates |
|
||||
| `coverage` | array | One entry per readable version, in the order of `translations` (default language, other enabled tags, disabled tags): `{ id, lang, title, is_online, language_enabled, capabilities }` |
|
||||
| `missing_langs` | array of tags | Enabled languages without a readable version, default first then by tag; `[]` when the caller cannot create |
|
||||
| `capabilities` | array | The capabilities of the representative version |
|
||||
|
||||
**Sort.** The whitelist is the flat one. `updated_at` uses the latest readable version of the story, `created_at` the earliest, `title` and `status` the representative; with `lang` the version itself. Ties end with `story_id` descending.
|
||||
|
||||
**Counts.** `{ all, online, draft, suggestions, missing }`. They follow `lang`, `q`, `author`, `seo` and `site` but never `status` or `tab`, and count the unit of the mode (stories, or versions of the selected language). `online` and `draft` count the units with a readable online or draft version. `missing` is the `total` the not-in view would give under the same filters (0 without `lang` for a caller who cannot create). `suggestions` is the number of stories with a readable version whose `seo_status` is `suggestions`, ignoring every parameter.
|
||||
|
||||
A repository failure at any step, counts included, answers 500 `Failed to retrieve articles`.
|
||||
|
||||
## Mock-ready examples
|
||||
|
||||
Statuses of the new and changed calls. Bodies are plain text unless shown as JSON; headers listed are the only extra ones.
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
Feature: Editorial article list by story and language
|
||||
A signed-in editor lists the articles of the back-office either as one row per story (all languages), or per language, with coverage, a "not in this language" view, an author filter and counts. The grouped response is an explicit opt-in on the same endpoint (group=story). Without it, GET /v1/article/ keeps exactly the flat envelope of features/articles_pagination_api.feature. Only versions the caller can read contribute titles, authors, timestamps, coverage, results and counts.
|
||||
|
||||
Background:
|
||||
Given the storage, translation and lifecycle rules of features/article_localization_storage.feature, features/article_translation_create.feature and features/article_story_lifecycle.feature apply
|
||||
And error responses are plain text, and a malformed query answers 400 with apperr.BodyErrorMessage, validated with validate tags through validator.Validate.Struct
|
||||
And site "journal" has enabled languages en (default), fr, de, ar and disabled languages es, it
|
||||
And the "Journal" dataset holds 8 stories and 17 versions (article id, lang, state, updated_at, byline):
|
||||
| story | article ids and versions | byline | updated_at of the latest version |
|
||||
| 1 | 101 en online, 102 fr draft, 103 de online, 104 ar draft | Marta Alves | 2026-10-30 10:00 |
|
||||
| 2 | 201 en online, 202 fr online, 203 de online | Camille Roche | 2026-10-29 09:00 |
|
||||
| 3 | 301 en online, 302 fr online | Luis Ferrer | 2026-10-28 09:00 |
|
||||
| 4 | 401 en draft | Marta Alves | 2026-10-27 09:00 |
|
||||
| 5 | 501 en online, 502 de draft | Luis Ferrer | 2026-10-14 09:00 |
|
||||
| 6 | 601 en online, 602 fr draft | Camille Roche | 2026-10-13 09:00 |
|
||||
| 7 | 701 en online, 702 de online | Marta Alves | 2026-10-12 09:00 |
|
||||
| 8 | 801 es online | Luis Ferrer | 2026-10-10 09:00 |
|
||||
And an "owner" holds every content permission at scope all
|
||||
And a "reader" holds content.read at scope all only: drafts of other users are hidden and no create_translation capability is held
|
||||
And a "restricted writer" holds content.create and content.read at scope own
|
||||
And a caller is "editorial" when the jwt cookie carries a user_id claim of a verified user whose role is resolved; every other caller is "public"
|
||||
And the new query parameters are: group (only "story"), lang (a supported language tag, normalized as in features/article_localization_storage.feature), tab (all, missing; review is reserved for features/ of release 2 and answers 400 until then), author (a byline id, integer >= 1)
|
||||
And the existing parameters page, size, q, status, site, seo and sort keep their meaning, defaults and validation; filter changes are the client's job to page-reset
|
||||
And a visible version is one the caller may read under the scopes of features/roles_permissions_content_scope.feature; a hidden version is never counted, titled, named in coverage, in missing_langs or in an error
|
||||
And the repository query is portable across SQLite, MySQL 8.4, MariaDB 11 and PostgreSQL: no dialect-only construct, request text never reaches the SQL, and the access filter is applied inside the query, not after paging
|
||||
|
||||
Rule: Public lists are unchanged
|
||||
|
||||
Scenario: A bare request is still the flat envelope
|
||||
Given the "Journal" dataset and an owner session
|
||||
When the client sends GET /v1/article/
|
||||
Then the body keys are exactly items, total, page, size and counts, and each item is the article object of the version, as before this slice
|
||||
And total is 17 versions' worth of visible articles, not 8
|
||||
|
||||
Scenario: lang, tab and group are not read unless asked
|
||||
Given the "Journal" dataset
|
||||
When a public client sends GET /v1/article/?size=5
|
||||
Then the body is identical to what it was before this slice, with lang and story_id on each item as defined in features/article_localization_storage.feature
|
||||
|
||||
Scenario Outline: group=story needs an editorial caller
|
||||
Given the "Journal" dataset
|
||||
When <caller> sends GET /v1/article/?group=story
|
||||
Then the response status is 400 and the body is "The request cannot be processed due to a mismatch in the format of the body."
|
||||
And the repository was not called
|
||||
|
||||
Examples:
|
||||
| caller |
|
||||
| a client without a jwt cookie |
|
||||
| a client with a malformed or expired cookie |
|
||||
| a client whose user is not verified |
|
||||
|
||||
Scenario Outline: Invalid new parameters answer 400
|
||||
Given an owner session
|
||||
When the client sends GET /v1/article/?<query>
|
||||
Then the response status is 400 and the body is "The request cannot be processed due to a mismatch in the format of the body."
|
||||
And the repository was not called
|
||||
|
||||
Examples:
|
||||
| query |
|
||||
| group=stories |
|
||||
| group= |
|
||||
| group=story&tab=review |
|
||||
| group=story&tab=published |
|
||||
| group=story&tab= |
|
||||
| tab=missing |
|
||||
| group=story&lang=english |
|
||||
| group=story&lang=xx |
|
||||
| group=story&lang= |
|
||||
| group=story&author=0 |
|
||||
| group=story&author=abc |
|
||||
| group=story&author= |
|
||||
| author=-1 |
|
||||
|
||||
Rule: Grouped rows, one per story
|
||||
|
||||
Scenario: All languages, page 1
|
||||
Given the "Journal" dataset and an owner session
|
||||
When the client sends GET /v1/article/?group=story&size=5
|
||||
Then the response status is 200 and total is 8, page 1, size 5
|
||||
And the story_id of the items are 1, 2, 3, 4, 5 in that order
|
||||
And counts.all is 8
|
||||
|
||||
Scenario: All languages, page 2 is the last
|
||||
When the owner sends GET /v1/article/?group=story&size=5&page=2
|
||||
Then the story_id of the items are 6, 7, 8 and total is 8
|
||||
|
||||
Scenario: A page beyond the last is empty with the real total
|
||||
When the owner sends GET /v1/article/?group=story&size=5&page=99
|
||||
Then items is [] and total is 8 and counts.all is 8
|
||||
|
||||
Scenario: A grouped row describes the representative version and the story
|
||||
When the owner sends GET /v1/article/?group=story
|
||||
Then the item of story 1 carries the fields of article 101 (id 101, lang "en", title, slug, is_online, site_id, story_id 1 and the other list fields of the flat item)
|
||||
And it adds story_updated_at "2026-10-30T10:00:00Z", story_authors (the union of the bylines of the readable versions, without duplicates) and capabilities of article 101
|
||||
And it adds coverage and missing_langs
|
||||
|
||||
Scenario Outline: The representative is the default-language version, else the first readable by language code
|
||||
Given the "Journal" dataset and an owner session
|
||||
When the client sends GET /v1/article/?group=story
|
||||
Then the representative id of story <story> is <id> and its lang is "<lang>"
|
||||
|
||||
Examples:
|
||||
| story | id | lang |
|
||||
| 1 | 101 | en |
|
||||
| 4 | 401 | en |
|
||||
| 8 | 801 | es |
|
||||
|
||||
Scenario: The representative falls back when the default version is unreadable
|
||||
Given story 5 holds a draft en article created by another user
|
||||
When a restricted writer sends GET /v1/article/?group=story
|
||||
Then the representative of story 5 is the first readable version by language code, never the hidden one
|
||||
And no field of the row names the hidden one
|
||||
|
||||
Scenario: story_updated_at is the latest readable version update
|
||||
Given article 102 (fr draft of story 1, creator another user) was updated at 2026-11-01 08:00 and is hidden from the caller
|
||||
When a restricted writer or a reader sends GET /v1/article/?group=story
|
||||
Then story 1's story_updated_at ignores article 102
|
||||
When the owner sends the same request
|
||||
Then story 1's story_updated_at is "2026-11-01T08:00:00Z"
|
||||
|
||||
Scenario Outline: Coverage lists readable versions in the chip order, then the languages that can be created
|
||||
Given the "Journal" dataset and an owner session
|
||||
When the client sends GET /v1/article/?group=story
|
||||
Then the coverage of story <story> is <coverage>
|
||||
And each entry has id, lang, title, is_online, language_enabled and capabilities, like an entry of "translations" in features/article_story_lifecycle.feature
|
||||
And missing_langs of story <story> is <missing>
|
||||
|
||||
Examples:
|
||||
| story | coverage | missing |
|
||||
| 1 | en, ar, de, fr | [] |
|
||||
| 3 | en, fr | ["ar","de"] |
|
||||
| 4 | en | ["ar","de","fr"] |
|
||||
| 8 | es (language_enabled false) | ["en","ar","de","fr"] |
|
||||
|
||||
Scenario: Coverage order is default, other enabled by tag, disabled by tag; missing is listed by the same rule
|
||||
Given story 8 gains a readable it version, and story 8's default en version does not exist
|
||||
Then the coverage of story 8 is es then it, both with language_enabled false
|
||||
And missing_langs starts with the default language en
|
||||
|
||||
Scenario: A caller who cannot create gets no missing_langs
|
||||
When a reader sends GET /v1/article/?group=story
|
||||
Then missing_langs is [] on every row
|
||||
And the coverage lists only the readable versions of each story
|
||||
|
||||
Scenario: The row of a story whose versions are all hidden does not exist
|
||||
Given story 4's only version is a draft of another user
|
||||
When a restricted writer sends GET /v1/article/?group=story
|
||||
Then story 4 is in no item, in total or in counts
|
||||
And total is the same as if article 401 did not exist
|
||||
|
||||
Scenario: Hidden versions leave no trace
|
||||
Given the "Journal" dataset and a restricted writer who cannot read the drafts 102, 104, 401, 502 and 602
|
||||
When the restricted writer sends GET /v1/article/?group=story&size=100
|
||||
Then the items, total, counts and every coverage and missing_langs equal those of a database in which those articles do not exist
|
||||
And no response field or header names them
|
||||
|
||||
Scenario: Unassigned articles are their own story with an empty language
|
||||
Given article 30 has lang "" and its own story
|
||||
When the owner sends GET /v1/article/?group=story
|
||||
Then its row has lang "", coverage with its single version, and missing_langs []
|
||||
And it never appears in the "not in" view of any language
|
||||
|
||||
Rule: Language selected means one row per version of that language
|
||||
|
||||
Scenario: French selected
|
||||
When the owner sends GET /v1/article/?group=story&lang=fr&size=5
|
||||
Then total is 4 and the story_id of the items are 1, 2, 3, 6
|
||||
And each representative is the fr version: ids 102, 202, 302, 602
|
||||
And each row still carries the whole story's coverage and story_authors
|
||||
|
||||
Scenario: The row opens the selected version and sorts by its update
|
||||
Given the fr versions have the updated_at values 2026-10-30, 2026-10-29, 2026-10-28 and 2026-10-13
|
||||
When the owner sends GET /v1/article/?group=story&lang=fr
|
||||
Then the order is stories 1, 2, 3, 6
|
||||
|
||||
Scenario: Disabled languages can be selected
|
||||
When the owner sends GET /v1/article/?group=story&lang=es
|
||||
Then total is 1 and the item is story 8, representative 801 and its coverage entry has language_enabled false
|
||||
|
||||
Scenario: A language nobody has gives an empty list
|
||||
When the owner sends GET /v1/article/?group=story&lang=it
|
||||
Then items is [] and total is 0
|
||||
|
||||
Scenario: lang also narrows the flat list
|
||||
When the owner sends GET /v1/article/?lang=fr
|
||||
Then the flat envelope holds the 4 fr articles, total is 4 and no item has another lang
|
||||
And counts.all, counts.online and counts.draft count the fr versions only and still ignore q and status
|
||||
|
||||
Rule: The not-in view lists stories without a readable version
|
||||
|
||||
Scenario: Not in French
|
||||
When the owner sends GET /v1/article/?group=story&lang=fr&tab=missing&size=5
|
||||
Then total is 4 and the story_id of the items are 4, 5, 7, 8
|
||||
And each representative is as in the all-languages view (401, 501, 701, 801)
|
||||
And missing_langs of each row contains "fr"
|
||||
|
||||
Scenario: Needs translation, no language selected
|
||||
When the owner sends GET /v1/article/?group=story&tab=missing&size=5
|
||||
Then total is 7 and the stories are 2, 3, 4, 5, 6, 7, 8 (story 1 has every enabled language)
|
||||
|
||||
Scenario: Missing means no readable version, not no stored version
|
||||
Given a hidden fr draft exists on story 4
|
||||
When a restricted writer sends GET /v1/article/?group=story&lang=fr&tab=missing
|
||||
Then story 4 is listed as not in French
|
||||
And nothing in the response tells whether a fr version exists
|
||||
|
||||
Scenario: Sites that do not enable the language are excluded
|
||||
Given site "atelier" enables en, en-GB and fr only, with one story that has en and fr versions only
|
||||
When the owner sends GET /v1/article/?group=story&lang=de&tab=missing
|
||||
Then no atelier story is listed
|
||||
When the owner sends GET /v1/article/?group=story&lang=es&tab=missing
|
||||
Then no story is listed, because es is disabled on journal
|
||||
|
||||
Scenario: Needs translation counts only languages the caller can create
|
||||
When a reader sends GET /v1/article/?group=story&tab=missing
|
||||
Then total is 0 and items is []
|
||||
And a reader's lang=fr tab=missing still lists the stories without a readable fr version
|
||||
|
||||
Scenario: Nothing missing
|
||||
Given site "press" with 3 stories, each with en and fr versions, and enabled languages en, fr
|
||||
When the owner sends GET /v1/article/?group=story&tab=missing&site=press
|
||||
Then items is [], total is 0 and counts.missing is 0
|
||||
|
||||
Rule: Filters apply to one readable version together
|
||||
|
||||
Scenario: Status matches a readable version of the story
|
||||
When the owner sends GET /v1/article/?group=story&status=draft
|
||||
Then the stories are 1, 4, 5, 6 and total is 4
|
||||
|
||||
Scenario: Combined filters need a single readable version to satisfy them together
|
||||
Given story 1 holds article 101 (en, online, byline Marta Alves) and article 102 (fr, draft, byline Camille Roche)
|
||||
When the owner sends GET /v1/article/?group=story&status=draft&author=<id of Marta Alves>
|
||||
Then story 1 is not listed, because no single version is both a draft and by Marta Alves
|
||||
When the owner sends GET /v1/article/?group=story&status=online&author=<id of Marta Alves>
|
||||
Then story 1 is listed
|
||||
When the owner sends GET /v1/article/?group=story&status=draft&author=<id of Camille Roche>
|
||||
Then story 1 is listed
|
||||
|
||||
Scenario: story_authors is the union even when the filter matched one version
|
||||
Given the same story 1
|
||||
When the owner sends GET /v1/article/?group=story&author=<id of Camille Roche>
|
||||
Then story 1 is listed and its story_authors holds Marta Alves and Camille Roche
|
||||
|
||||
Scenario: Search matches any readable title of the story
|
||||
Given article 102 is titled "Comment nous chiffrons une rénovation" and article 101 "How we price a bathroom renovation"
|
||||
When the owner sends GET /v1/article/?group=story&q=RENOVATION
|
||||
Then story 1 is listed with the title of its representative (101)
|
||||
And the search ignores case and accents like features/articles_pagination_api.feature
|
||||
When the owner sends GET /v1/article/?group=story&lang=fr&q=chiffrons
|
||||
Then story 1 is listed with article 102 as representative
|
||||
When the owner sends GET /v1/article/?group=story&lang=en&q=chiffrons
|
||||
Then items is []
|
||||
|
||||
Scenario: Search in the not-in view matches readable source titles
|
||||
When the owner sends GET /v1/article/?group=story&lang=fr&tab=missing&q=Spring
|
||||
Then story 4 is listed
|
||||
|
||||
Scenario: Author in language mode
|
||||
When the owner sends GET /v1/article/?group=story&lang=fr&author=<id of Camille Roche>
|
||||
Then the stories are 2 and 6 (the fr version's own byline decides)
|
||||
|
||||
Scenario: Site filter
|
||||
When the owner sends GET /v1/article/?group=story&site=press
|
||||
Then only press stories are listed, and an unknown site key gives items [] and total 0 as for the flat list
|
||||
|
||||
Scenario: SEO filter on one version
|
||||
Given article 502 has suggestions and article 501 has none
|
||||
When the owner sends GET /v1/article/?group=story&seo=suggestions
|
||||
Then story 5 is listed once, with its own representative 501
|
||||
|
||||
Scenario: All filters combine with paging and stay stable
|
||||
Given 12 stories match
|
||||
When the owner requests pages 1, 2 and 3 of ?group=story&size=5 with the same filters
|
||||
Then the pages hold 5, 5 and 2 stories with no repeat, and total is 12 on each
|
||||
|
||||
Rule: Sort applies to the whole list at story level
|
||||
|
||||
Scenario Outline: Sort orders stories, not the page
|
||||
Given the "Journal" dataset and an owner session
|
||||
When the client sends GET /v1/article/?group=story&sort=<sort>&size=100
|
||||
Then the story order is <order>
|
||||
|
||||
Examples:
|
||||
| sort | order |
|
||||
| updated_at:desc | 1,2,3,4,5,6,7,8 |
|
||||
| updated_at:asc | 8,7,6,5,4,3,2,1 |
|
||||
| title:asc | by representative title, then story_id descending |
|
||||
| title:desc | by representative title descending, then story_id descending |
|
||||
| status:asc | drafts first by representative is_online, then story_id descending |
|
||||
| created_at:asc | by the earliest created_at among readable versions |
|
||||
|
||||
Scenario: Ties end with story_id descending
|
||||
Given two stories with the same latest update
|
||||
Then the one with the higher story_id comes first on every page
|
||||
|
||||
Rule: Counts
|
||||
|
||||
Background:
|
||||
Given the grouped body is {"items","total","page","size","counts"}
|
||||
And counts holds all, online, draft, suggestions and missing, in snake_case
|
||||
And in grouped mode the counts respect lang, q, author, seo and site but never status or tab, so the tab counts stay comparable; they count the unit of the mode (stories, or versions of the selected language)
|
||||
And online and draft count units with a readable online or draft version (the selected version in language mode)
|
||||
And missing counts the units the "not in" view would list under the same lang, q, author, seo and site
|
||||
And suggestions keeps its meaning (units with a readable version whose seo_status is suggestions, ignoring every other parameter)
|
||||
And the flat list counts are unchanged
|
||||
|
||||
Scenario: Counts of the Journal dataset, all languages
|
||||
When the owner sends GET /v1/article/?group=story
|
||||
Then counts is {"all": 8, "online": 7, "draft": 4, "suggestions": 0, "missing": 7}
|
||||
|
||||
Scenario: Counts of the Journal dataset, French
|
||||
When the owner sends GET /v1/article/?group=story&lang=fr
|
||||
Then counts.all is 4, counts.online is 2, counts.draft is 2 and counts.missing is 4
|
||||
|
||||
Scenario: Counts follow the search
|
||||
When the owner sends GET /v1/article/?group=story&q=zinc
|
||||
Then items is [], total is 0 and counts is {"all": 0, "online": 0, "draft": 0, "suggestions": 0, "missing": 0}
|
||||
And suggestions is the only count that ignores q
|
||||
|
||||
Scenario: Counts follow the author filter
|
||||
When the owner sends GET /v1/article/?group=story&author=<id of Marta Alves>
|
||||
Then total is 3, the stories are 1, 4, 7 and counts.all is 3
|
||||
|
||||
Scenario: Counts do not move with status or tab
|
||||
When the owner sends GET /v1/article/?group=story&lang=fr&status=draft&tab=missing
|
||||
Then counts.all is 4, counts.online is 2 and counts.draft is 2, the same as without status and tab
|
||||
|
||||
Scenario: A reader's counts cover readable versions only
|
||||
When a reader sends GET /v1/article/?group=story
|
||||
Then counts.draft is 0 and counts.all equals the number of stories with an online version
|
||||
|
||||
Scenario: Total versus counts
|
||||
When the owner sends GET /v1/article/?group=story&tab=missing
|
||||
Then total is counts.missing and total is 7
|
||||
|
||||
Rule: Failure
|
||||
|
||||
Scenario: Repository failure answers 500
|
||||
Given the repository fails on its next call
|
||||
When the owner sends GET /v1/article/?group=story
|
||||
Then the status is 500 and the body is "Failed to retrieve articles"
|
||||
|
||||
Scenario: A failure while counting fails the request
|
||||
Given the repository fails while the counts are evaluated
|
||||
Then the status is 500 and the body is "Failed to retrieve articles"
|
||||
|
||||
Rule: Persistence portability
|
||||
|
||||
Scenario: The grouped, access-filtered query passes the same cases on every database
|
||||
Given the "Journal" dataset, a hidden-version dataset and the cases above (all languages, lang, tab=missing, status+author together, q with accents, sort whitelist, paging, counts)
|
||||
Then they pass on SQLite, MySQL 8.4, MariaDB 11 and PostgreSQL with identical ids, totals and counts
|
||||
And the result does not depend on the database collation for the id sets
|
||||
And no raw SQL uses a dialect-only construct
|
||||
|
||||
Rule: The SPA, editorial list screen
|
||||
|
||||
Background:
|
||||
Given /article keeps its route and URL conventions of features/articles_pagination_screen.feature, and the screen follows docs/design/screens/localization-list.md group L7
|
||||
And ArticlesPage always sends group=story; the URL carries site, lang, tab (omitted when all), q, status, author, seo, page and size, defaults omitted, and any filter change drops page
|
||||
And an invalid value in the URL falls back to its default and is replaced in the URL
|
||||
And the unit label (UnitLabel, aria-live polite) states the unit: "8 articles", "4 French versions", "4 articles not in French", "7 articles need translation"
|
||||
And the tab "Needs review" is not rendered (it ships with release 2); loc.l7.review is not part of this slice
|
||||
And the tabs: with no language "All articles" and "Needs translation"; with a language "All", "Published" (status=online), "Drafts" (status=draft) and "Not in {Language}", each with its count from counts
|
||||
And the row menu on every width lists Open {language}, Open another language, Add translation, Publish or Unpublish {language}, Copy API URL, Delete {language} version, each shown when its capability or condition holds (docs/design/localization-tables.md "Row actions"), and every row has the menu button
|
||||
And coverage chips follow docs/design/localization-tables.md "Coverage chips for many languages": 5 shown at 1440 and 4 at 1024 and 390, then "+N" named "N more languages: ..."; dashed chips only for missing_langs and buttons on desktop only
|
||||
And below md the filters open a sheet (Site, Language, Status, Author, SEO, Clear all, "Show N results") and the tabs scroll with an edge fade and "Scroll tabs left/right" buttons that keep Not in {Language} reachable
|
||||
And copy comes from the design; no revision number, id or HTTP status name is shown
|
||||
|
||||
Scenario Outline: The design states of group L7 are rendered from the matching response
|
||||
Given the Journal dataset (and Press, Atelier or the limited reader where the state names it) mocked on GET /v1/article/ as above
|
||||
When the user opens <route>
|
||||
Then the screen shows state <state> as drawn: <visible>
|
||||
|
||||
Examples:
|
||||
| state | route | visible |
|
||||
| loc.l7.all | /article | 5 of 8 rows, unit "8 articles", tabs "All articles" "Needs translation", pager page 1 of 2 |
|
||||
| loc.l7.page2 | /article?page=2 | 3 of 8 rows, pager page 2 of 2 |
|
||||
| loc.l7.fr | /article?lang=fr | unit "4 French versions", tabs All, Published, Drafts, Not in French |
|
||||
| loc.l7.frMissing | /article?lang=fr&tab=missing | unit "4 articles not in French" |
|
||||
| loc.l7.emptyMissing | /article?tab=missing&site=press | no summary, no pager, the nothing-missing empty state |
|
||||
| loc.l7.noResults | /article?q=zinc | no-results empty state, every count 0 |
|
||||
| loc.l7.frLoading | /article?lang=fr (request pending) | counts shown as "–", skeleton rows, aria-busy |
|
||||
| loc.l7.listFail | /article?lang=fr (request fails) | failure state with the filters kept and a retry |
|
||||
| loc.l7.limited | /article as a reader without create | no dashed chips, no Add translation item |
|
||||
| loc.l7.author | /article?author=<Marta Alves id> | 3 articles, filter chip and "Clear all" |
|
||||
| loc.l7.langFilter | language filter opened | Enabled and Disabled groups |
|
||||
| loc.l7.rowMenu | a row menu opened | the same actions on desktop and mobile |
|
||||
| loc.l7.filters | /article?lang=fr&status=draft at 390 | filter sheet with Journal, French, Draft: "Show 2 results" |
|
||||
| loc.l7.tabsScroll | tabs scrolled (1440, 390) | Not in French reachable; fade and chevrons |
|
||||
| loc.l7.long | /article?site=atelier (8 languages) | chips collapse to +3, +4, +4; Draft and SEO filters |
|
||||
@@ -0,0 +1,42 @@
|
||||
# QA: Editorial article list by story and language
|
||||
|
||||
Order of work is part of the procedure: the repository query and its real-database tests come first (items 1 to 4), then the use case and the HTTP contract (5 to 8), then the SPA (9 onward). Backend items are Go tests in `test/`, black-box.
|
||||
|
||||
## Automated, repository (SQLite file, task test)
|
||||
|
||||
1. Seed the "Journal" dataset (8 stories, 17 versions, bylines, updated_at as in the feature) and the "hidden-version" variant through the real `ArticleRepository` on a temp SQLite file with `database.Migrate`. Drive the new grouped query through `article.Repository` with the new filter fields (group, lang, tab, author) and assert, table-driven over the feature's Examples: story ids per page, `total`, representative per story, coverage order, `missing_langs`, `story_updated_at`, `story_authors`, counts.
|
||||
2. Access filter inside the query: for a caller with scope `own`, `all`-online and `online only`, assert the hidden-version dataset yields the same ids, totals and counts as a database where the hidden articles do not exist. Also check page boundaries (a hidden version must not shift a page: 5 per page of readable stories).
|
||||
3. Filter semantics: the "single readable version satisfies all version-level filters" cases (status+author, status+q, author+seo), the "story_authors is the union" case, the "missing" cases (no readable version, site not enabling the language, disabled language, unassigned articles) and accent-insensitive q in grouped, language and not-in modes.
|
||||
4. Sort whitelist and stability: each sort of the feature, ties by story_id descending, no overlap or gap between pages 1 to 3 on a 12-story set.
|
||||
|
||||
## Automated, real databases (task test:mysql, task test:postgres)
|
||||
|
||||
Repeat items 1 to 4 against MySQL 8.4, MariaDB 11 and PostgreSQL. Compare ids, totals and counts with SQLite, not only pass/fail. Also assert no query returns a different id set under a different collation (title sort aside). Record NOT RUN with the reason if the daemon is down, or use the podman shim (see memory). Inspect the executed SQL once (GORM debug) to confirm no dialect-only construct, no per-row query for coverage (coverage and authors are loaded in a bounded number of queries per page), and that request text is never concatenated.
|
||||
|
||||
## Automated, use case (task test)
|
||||
|
||||
5. `ListArticlesUseCase` over fakes: grouped command, role grants for owner, reader and restricted writer, fake `site` and `localization` repositories. Assert representative choice, `missing_langs` only when `create_translation` is held, the counts rules, and that a hidden version never reaches the result. Assert sentinel errors with `errors.Is`. Existing use case tests stay green.
|
||||
|
||||
## Automated, HTTP (task test)
|
||||
|
||||
6. `article.NewHandler(...)` over the real use cases and fakes, a fresh `chi.NewRouter()` mirroring `NewArticleRouter` in `api/router.go` (GET / under `jwtauth.Verifier` only), `validator.InitValidator()` first, `httptest`, jwt cookies signed with a test `jwtauth.New("HS256", ...)` secret (owner, reader, restricted writer, unverified user, expired, none).
|
||||
7. "Public lists are unchanged": bare request and a public request keep the flat keys exactly (compare the key set and values with a golden produced before the slice); `lang=fr` and `author` narrow the flat list; `group=story` for the three public callers and every invalid query in the Examples answer 400 with `apperr.BodyErrorMessage` and the fake repository records no call.
|
||||
8. Grouped response shape: decode and assert `items[]` keys (flat item keys plus `story_updated_at`, `story_authors`, `coverage`, `missing_langs`, `capabilities`), `total`, `page`, `size`, `counts` keys (all, online, draft, suggestions, missing), `items` `[]` not null, snake_case, errors plain text. Repository failure and count failure answer 500 "Failed to retrieve articles". Docs: `docs/` gains the grouped contract (parameters, shape, counts, errors) so the SPA mocks `/v1` from it.
|
||||
|
||||
## Automated, SPA (Vitest + Vue Test Utils, task test:web)
|
||||
|
||||
9. Mock `GET /v1/article/` per state with the Journal responses of the feature (and Press, Atelier and the reader). One test per design state of group L7 except `loc.l7.review`: `loc.l7.all`, `page2`, `fr`, `frMissing`, `emptyMissing`, `noResults`, `frLoading`, `listFail`, `limited`, `author`, `langFilter`, `rowMenu`, `filters`, `tabsScroll`, `long`. Assert: the request query (`group=story`, lang, tab, author, page, size; defaults omitted), the URL after a successful response, the unit label text, the tab labels and counts, "–" counts while loading, the retained filters on failure, the chip order and collapse (+N, accessible names), dashed chips only from `missing_langs`, and the identical row menu items at mobile and desktop widths.
|
||||
10. URL state: invalid `lang`, `tab=review`, `author=abc`, `page=abc` fall back and are replaced; every filter change drops `page`; the tab and filter chips match the URL; "Clear all" empties all filters.
|
||||
11. Regression of the amended screens: `ArticlesPage` still passes the sort, pagination, row action, delete and toast cases of `articles_list` and `articles_pagination_screen`, now on grouped rows.
|
||||
|
||||
## Visual (task visual:check -- localization-list or the L7 group)
|
||||
|
||||
Compare each state with its board frame at desktop (1440), 1024 where the board draws it, and mobile (390), light and dark. The gate is hard: every capture within 10 %. Mock `/v1` from `web/visual/states.ts` with the board's own data (`docs/design/source/locale-fixtures.js`: Journal 8 stories, 17 versions, page size 5; Press; Atelier; the limited scope). Titles, bylines (Marta Alves, Camille Roche, Luis Ferrer) and the language names come from the fixture. All states use relative text ("2 h ago", "Yesterday", "3 days ago"): list them as `skip: clock-sensitive` only if the SPA renders them from the current time; otherwise freeze nothing and use the board's absolute dates. `loc.l7.review` is out of scope (release 2). `loc.l7.tabsScroll` is captured at 1440 and 390 only.
|
||||
|
||||
## Manual
|
||||
|
||||
- Keyboard and focus: tab order through search, filters, tabs (arrow keys), rows, row menu (Enter or Down opens, Esc returns to the button), chips, pager; focus ring on the dashed chip buttons; the filter sheet traps focus and returns it to its trigger.
|
||||
- Screen reader: the unit label announces politely on every filter change; chips announce "French: draft" and "3 more languages: ...".
|
||||
- Hover and motion: tab fade and chevrons, skeleton pulse, sheet open and close, reduced motion.
|
||||
- Real databases through a running server: open the list against a real SQLite file with several languages, a restricted account, and the same data on MySQL and PostgreSQL; compare counts with the Languages page.
|
||||
- Real SMTP: not applicable.
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Articles list screen
|
||||
Superseded in part by article_list_localization: the call is GET /v1/article/?group=story&... and rows are story rows. The row anatomy gains the coverage chips (first) and the "Authors" union. The row menu items and order are Open {language}, Open another language, Add translation, Publish or Unpublish {language}, Copy API URL, Delete {language} version (docs/design/localization-tables.md "Row actions"), each shown by its capability, replacing "Edit, Publish or Unpublish, Copy API URL, Delete…" for rows with those capabilities; the Delete… confirmation copy is unchanged here (slice 5). The tabs "All / Online / Drafts" become the two modes of the new feature: "All articles" and "Needs translation" with no language; "All", "Published", "Drafts" and "Not in {Language}" with one. The 6-article fixture is replaced by the Journal dataset for the grouped scenarios; rows with no lang assigned keep today's scenarios with empty coverage. "Copy API URL" still copies {origin}/v1/article/{id} of the version the row menu acts on.
|
||||
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
As a verified RenewCMS owner
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
> Superseded in part by article_list_localization: references to the flat list become the grouped request (GET /v1/article/?group=story&...) on story rows; the Go and Vitest cases are items 6 to 11 of features/article_list_localization.qa.md. The manual items below are kept.
|
||||
|
||||
## Implementation note for coder (informational, not a QA assertion)
|
||||
|
||||
`internal/infrastructure/persistence/repositories/articleRepository.go`'s `FindByFilters` gains
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Paginated article listing
|
||||
Superseded in part by article_list_localization: the flat envelope below is unchanged, and the optional parameters lang and author are added to it (see the Background); the story-grouped shape is a separate opt-in.
|
||||
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
As an API consumer or the RenewCMS back-office
|
||||
@@ -15,6 +16,10 @@ Feature: Paginated article listing
|
||||
And q (optional, max 200 characters; case-insensitive, accent-insensitive, matches anywhere in the title) and status (optional, online or draft) filter the items
|
||||
And total is the number of visible articles matching q and status, before paging; page and size echo the values applied
|
||||
And counts ignores q and status, so the tab counts do not move while the user searches; it respects visibility: for a public caller all equals online and draft is 0
|
||||
And this counts statement is true for the flat shape and is explicitly NOT true for the grouped shape, where counts follow lang, q, author, seo and site (features/article_list_localization.feature)
|
||||
And the flat counts.suggestions rule is unchanged
|
||||
And the flat envelope above is unchanged; a client opts in to the story-grouped editorial shape with group=story (features/article_list_localization.feature); public and unverified callers cannot
|
||||
And lang (flat: narrows items, total and counts.all, counts.online and counts.draft to that language) and author (a byline id, flat: narrows by byline) are additive, optional, validated parameters; an invalid value answers 400 with apperr.BodyErrorMessage
|
||||
And status=draft requested by a public caller matches nothing: items [], total 0
|
||||
And sort (optional) is one of title:asc, title:desc, status:asc, status:desc, created_at:asc, created_at:desc, updated_at:asc, updated_at:desc; the default is updated_at:desc; status orders by is_online (asc puts drafts first); every ordering ends with id descending as the tie-break, so pages are stable and never overlap
|
||||
And sort is mapped to a column and direction through a fixed whitelist in the repository, so request text never reaches the SQL; the same ordering applies on the search path (q) before slicing
|
||||
@@ -176,6 +181,14 @@ Feature: Paginated article listing
|
||||
| sort=updated_at |
|
||||
| sort=title:asc;DROP TABLE |
|
||||
| sort=TITLE:ASC |
|
||||
| lang=english |
|
||||
| lang=xx |
|
||||
| lang= |
|
||||
| author=0 |
|
||||
| author=abc |
|
||||
| author= |
|
||||
| group=stories |
|
||||
| tab=missing |
|
||||
|
||||
Scenario: Invalid format is reported first
|
||||
When a client sends GET /v1/article/?page=0&format=pdf
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
|
||||
|
||||
> Superseded in part by article_list_localization: the flat envelope tested here is unchanged; the additive lang and author parameters, their 400 rows and the grouped request (group=story) are covered by items 6 to 11 of features/article_list_localization.qa.md. The manual items below are kept.
|
||||
|
||||
## HTTP-level (Go, `test/`, `task test`)
|
||||
|
||||
Build `article.NewHandler(...)` over the real `ListArticlesUseCase` and a fake `article.Repository` (extend `mockArticleRepo` in `test/article_test.go`, keeping type names unique across files). The fake implements the interface contract: paging (Page/Size 0 = unpaged), q/status filtering, ordering updated_at DESC then id DESC, total, and the visibility counts. Mount on a fresh `chi.NewRouter()` mirroring `NewArticleRouter` in `api/router.go` (GET / under `jwtauth.Verifier` only, test `jwtauth.New("HS256", ...)`). Call `validator.InitValidator()`. Use a user fake for the verified, unverified and missing cases, as in `test/public_article_listing_test.go`. No live server, no SQLite file.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Feature: Articles list pagination
|
||||
Superseded in part by article_list_localization: the request is GET /v1/article/?group=story&format=html&...; page, size, q, status, sort, lang, tab, author, site and seo live in the URL; items are story rows (representative version plus coverage) and total is the unit total (stories, or versions of the selected language). The tab counts follow lang, q, author, seo and site (never status or tab) instead of staying fixed while searching (deliberate behavior change); "No results" shows every count at 0 (board loc.l7.noResults). A change of lang, tab or author, like any filter, drops page. The summary "Showing {from}–{to} of {total}" is unchanged and the UnitLabel sits above it. Scenarios 4, 6 and 7 keep their wording with story rows and the grouped request.
|
||||
|
||||
As a verified RenewCMS user with many articles
|
||||
I want the Articles list split into pages with a page-size menu, server-side search and tabs, and shareable URLs
|
||||
@@ -91,15 +92,15 @@ Feature: Articles list pagination
|
||||
When the user types "plaster" in the search field and the debounce elapses
|
||||
Then GET ...&q=plaster&page=1 is sent
|
||||
And on success the URL is /article?q=plaster (page omitted)
|
||||
And the tab counts are unchanged
|
||||
And the same reset happens for a tab change, a sort change and a page-size change
|
||||
And the tab counts follow the new search (lang, q, author, seo and site; never status or tab)
|
||||
And the same reset happens for a tab change, a sort change, a page-size change, and a lang, author, site or seo change
|
||||
|
||||
Scenario: 9 Empty after search
|
||||
Given GET ...&q=tiles resolves 200 with items [], total 0 and counts {all 6, online 4, draft 2}
|
||||
Given GET ...&q=tiles resolves 200 with items [], total 0 and counts {all 0, online 0, draft 0, suggestions 0, missing 0}
|
||||
When the user searches "tiles"
|
||||
Then the paginator and the summary are hidden
|
||||
And the no-results state shows "No articles match "tiles"", "Check the spelling, or search for a shorter part of the title." and "Clear search"
|
||||
And the tabs still read "All 6" "Online 4" "Drafts 2"
|
||||
And every tab count reads 0 (board loc.l7.noResults), because counts follow the search
|
||||
|
||||
Scenario Outline: 10 Page size menu
|
||||
Given the user is on /article?page=3
|
||||
@@ -130,7 +131,7 @@ Feature: Articles list pagination
|
||||
Given the Default list has loaded and the user is on /article?page=3
|
||||
When the user selects the "Drafts" tab
|
||||
Then GET ...&status=draft&page=1 is sent and, on success, the URL is /article?status=draft
|
||||
And the tab counts are unchanged
|
||||
And the tab counts are unchanged, because counts never follow status or tab
|
||||
|
||||
Scenario Outline: Sort header, server-side, resets to page 1
|
||||
Given the user is on /article?page=7
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# QA: Articles list pagination (SPA)
|
||||
|
||||
> Superseded in part by article_list_localization: the mocked request is the grouped one (GET /v1/article/?group=story&format=html&...) on story rows, tab counts follow lang, q, author, seo and site, and the lang, tab, author, site and seo reset cases are covered by items 9 to 11 of features/article_list_localization.qa.md. The manual items below are kept.
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi` for `GET /v1/article/?format=html&...` (the route key includes the query) per case. Mount ArticlesPage inside DashboardShell with a verified session and a test router at /article, as in features/articles_list.qa.md. The fixture is 312 articles at 25 per page, built by a helper, with counts {all, online, draft}. No system date freezing.
|
||||
|
||||
@@ -37,6 +37,8 @@ type Filters struct {
|
||||
Size int
|
||||
Q string
|
||||
Status string
|
||||
Lang *string
|
||||
AuthorID *uint32
|
||||
Sort Sort
|
||||
Unpaged bool
|
||||
}
|
||||
@@ -93,6 +95,10 @@ type Repository interface {
|
||||
FindPage(filters Filters) (PageResult, error)
|
||||
CountByVisibility(onlineOnly bool) (Counts, error)
|
||||
CountVisibleTo(userID uint32) (Counts, error)
|
||||
CountVersions(filters Filters) (Counts, error)
|
||||
FindStoryPage(filters StoryFilters) (StoryPage, error)
|
||||
CountStories(filters StoryFilters) (StoryCounts, error)
|
||||
ListByStories(storyIDs []uint32) ([]Article, error)
|
||||
Get(id uint32) (Article, error)
|
||||
GetByName(name string) (Article, error)
|
||||
GetAll() ([]Article, error)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package article
|
||||
|
||||
const (
|
||||
TabAll = "all"
|
||||
TabMissing = "missing"
|
||||
)
|
||||
|
||||
type StoryFilters struct {
|
||||
SkipMissing bool
|
||||
OnlyOnline bool
|
||||
VisibleTo *uint32
|
||||
SiteID *uint32
|
||||
Lang string
|
||||
Missing bool
|
||||
Status string
|
||||
AuthorID *uint32
|
||||
Q string
|
||||
VersionIDs *[]uint32
|
||||
Sort Sort
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
func (f StoryFilters) PerLanguage() bool {
|
||||
return f.Lang != "" && !f.Missing
|
||||
}
|
||||
|
||||
type StoryRef struct {
|
||||
StoryID uint32
|
||||
ArticleID uint32
|
||||
}
|
||||
|
||||
type StoryPage struct {
|
||||
Refs []StoryRef
|
||||
Total int
|
||||
}
|
||||
|
||||
type StoryCounts struct {
|
||||
All int `json:"all"`
|
||||
Online int `json:"online"`
|
||||
Draft int `json:"draft"`
|
||||
Suggestions int `json:"suggestions"`
|
||||
Missing int `json:"missing"`
|
||||
}
|
||||
@@ -61,6 +61,12 @@ func (a *ArticleRepository) scoped(filters domain.Filters) *gorm.DB {
|
||||
case domain.StatusDraft:
|
||||
query = query.Where("is_online = ?", false)
|
||||
}
|
||||
if filters.Lang != nil {
|
||||
query = query.Where("lang = ?", *filters.Lang)
|
||||
}
|
||||
if filters.AuthorID != nil {
|
||||
query = query.Where("id IN (SELECT article_id FROM article_authors WHERE author_id = ?)", *filters.AuthorID)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
@@ -188,6 +194,22 @@ func (a *ArticleRepository) CountVisibleTo(userID uint32) (domain.Counts, error)
|
||||
return domain.Counts{All: int(online + draft), Online: int(online), Draft: int(draft)}, nil
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) CountVersions(filters domain.Filters) (domain.Counts, error) {
|
||||
narrowed := domain.Filters{Lang: filters.Lang}
|
||||
var online, draft int64
|
||||
if err := a.scoped(narrowed).Where("is_online = ?", true).Count(&online).Error; err != nil {
|
||||
return domain.Counts{}, err
|
||||
}
|
||||
onlineOnly := filters.IsOnline != nil && *filters.IsOnline
|
||||
if !onlineOnly {
|
||||
narrowed.VisibleTo = filters.VisibleTo
|
||||
if err := a.scoped(narrowed).Where("is_online = ?", false).Count(&draft).Error; err != nil {
|
||||
return domain.Counts{}, err
|
||||
}
|
||||
}
|
||||
return domain.Counts{All: int(online + draft), Online: int(online), Draft: int(draft)}, nil
|
||||
}
|
||||
|
||||
func toDomainArticles(entityArticles []entity.Article) []domain.Article {
|
||||
domainArticles := make([]domain.Article, 0, len(entityArticles))
|
||||
for _, entityArticle := range entityArticles {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/article"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type storySQL struct {
|
||||
conditions []string
|
||||
args []any
|
||||
}
|
||||
|
||||
func (s *storySQL) add(condition string, args ...any) {
|
||||
s.conditions = append(s.conditions, condition)
|
||||
s.args = append(s.args, args...)
|
||||
}
|
||||
|
||||
func (s storySQL) where() string {
|
||||
return strings.Join(s.conditions, " AND ")
|
||||
}
|
||||
|
||||
func readableCondition(alias string, filters domain.StoryFilters) storySQL {
|
||||
condition := storySQL{}
|
||||
condition.add(alias + ".deleted_at IS NULL")
|
||||
if filters.OnlyOnline {
|
||||
condition.add(alias+".is_online = ?", true)
|
||||
}
|
||||
if filters.VisibleTo != nil {
|
||||
condition.add("("+alias+".is_online = ? OR "+alias+".created_by = ?)", true, *filters.VisibleTo)
|
||||
}
|
||||
return condition
|
||||
}
|
||||
|
||||
func (s *storySQL) merge(other storySQL) {
|
||||
s.conditions = append(s.conditions, other.conditions...)
|
||||
s.args = append(s.args, other.args...)
|
||||
}
|
||||
|
||||
func lacksReadableVersionIn(languageExpression string, filters domain.StoryFilters, languageArgs ...any) storySQL {
|
||||
readable := readableCondition("w", filters)
|
||||
condition := storySQL{}
|
||||
condition.add("NOT EXISTS (SELECT 1 FROM articles w WHERE w.story_id = q.story_id AND "+readable.where()+" AND w.lang = "+languageExpression+")", append(readable.args, languageArgs...)...)
|
||||
return condition
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) versionRestriction(filters domain.StoryFilters) ([]uint32, bool, error) {
|
||||
restricted := filters.VersionIDs != nil
|
||||
var allowed []uint32
|
||||
if restricted {
|
||||
allowed = *filters.VersionIDs
|
||||
}
|
||||
query := strings.TrimSpace(filters.Q)
|
||||
if query == "" {
|
||||
return allowed, restricted, nil
|
||||
}
|
||||
readable := readableCondition("q", filters)
|
||||
if filters.SiteID != nil {
|
||||
readable.add("q.site_id = ?", *filters.SiteID)
|
||||
}
|
||||
var candidates []entity.Article
|
||||
if err := a.db.Raw("SELECT q.id AS id, q.title AS title FROM articles q WHERE "+readable.where(), readable.args...).Scan(&candidates).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
needle := foldForSearch(query)
|
||||
permitted := map[uint32]bool{}
|
||||
for _, id := range allowed {
|
||||
permitted[id] = true
|
||||
}
|
||||
matching := make([]uint32, 0)
|
||||
for _, candidate := range candidates {
|
||||
if restricted && !permitted[candidate.ID] {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(foldForSearch(candidate.Title), needle) {
|
||||
matching = append(matching, candidate.ID)
|
||||
}
|
||||
}
|
||||
return matching, true, nil
|
||||
}
|
||||
|
||||
func qualifyingVersions(filters domain.StoryFilters, allowed []uint32, restricted bool) storySQL {
|
||||
condition := readableCondition("q", filters)
|
||||
if filters.SiteID != nil {
|
||||
condition.add("q.site_id = ?", *filters.SiteID)
|
||||
}
|
||||
if filters.PerLanguage() {
|
||||
condition.add("q.lang = ?", filters.Lang)
|
||||
}
|
||||
switch filters.Status {
|
||||
case domain.StatusOnline:
|
||||
condition.add("q.is_online = ?", true)
|
||||
case domain.StatusDraft:
|
||||
condition.add("q.is_online = ?", false)
|
||||
}
|
||||
if filters.AuthorID != nil {
|
||||
condition.add("q.id IN (SELECT aa.article_id FROM article_authors aa WHERE aa.author_id = ?)", *filters.AuthorID)
|
||||
}
|
||||
if restricted {
|
||||
if len(allowed) == 0 {
|
||||
condition.add("1 = 0")
|
||||
} else {
|
||||
condition.add("q.id IN ?", allowed)
|
||||
}
|
||||
}
|
||||
if filters.Missing {
|
||||
condition.merge(lacksReadableVersionIn("?", filters, ""))
|
||||
condition.merge(missingCondition(filters))
|
||||
}
|
||||
return condition
|
||||
}
|
||||
|
||||
func missingCondition(filters domain.StoryFilters) storySQL {
|
||||
condition := storySQL{}
|
||||
if filters.Lang != "" {
|
||||
condition.add("EXISTS (SELECT 1 FROM site_languages sl WHERE sl.site_id = q.site_id AND sl.lang = ? AND sl.enabled = ?)", filters.Lang, true)
|
||||
condition.merge(lacksReadableVersionIn("?", filters, filters.Lang))
|
||||
return condition
|
||||
}
|
||||
absent := lacksReadableVersionIn("sl.lang", filters)
|
||||
condition.add("EXISTS (SELECT 1 FROM site_languages sl WHERE sl.site_id = q.site_id AND sl.enabled = ? AND "+strings.Join(absent.conditions, " AND ")+")", append([]any{true}, absent.args...)...)
|
||||
return condition
|
||||
}
|
||||
|
||||
func unitExpression(filters domain.StoryFilters) string {
|
||||
if filters.PerLanguage() {
|
||||
return "COUNT(*)"
|
||||
}
|
||||
return "COUNT(DISTINCT q.story_id)"
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) countUnits(filters domain.StoryFilters, allowed []uint32, restricted bool) (int, error) {
|
||||
qualifying := qualifyingVersions(filters, allowed, restricted)
|
||||
var total int64
|
||||
err := a.db.Raw("SELECT "+unitExpression(filters)+" FROM articles q WHERE "+qualifying.where(), qualifying.args...).Scan(&total).Error
|
||||
return int(total), err
|
||||
}
|
||||
|
||||
var storyRowOrder = map[domain.SortField]string{
|
||||
domain.SortByTitle: "s.sort_title",
|
||||
domain.SortByStatus: "s.sort_online",
|
||||
domain.SortByCreatedAt: "s.sort_created",
|
||||
domain.SortByUpdatedAt: "s.sort_updated",
|
||||
}
|
||||
|
||||
var versionRowOrder = map[domain.SortField]string{
|
||||
domain.SortByTitle: "LOWER(q.title)",
|
||||
domain.SortByStatus: "q.is_online",
|
||||
domain.SortByCreatedAt: "q.created_at",
|
||||
domain.SortByUpdatedAt: "q.updated_at",
|
||||
}
|
||||
|
||||
func storyOrderClause(columns map[domain.SortField]string, sort domain.Sort, storyColumn string) string {
|
||||
column, ok := columns[sort.Field]
|
||||
direction, directionOk := sortDirections[sort.Direction]
|
||||
if !ok || !directionOk {
|
||||
column, direction = columns[domain.SortByUpdatedAt], sortDirections[domain.SortDesc]
|
||||
}
|
||||
return column + " " + direction + ", " + storyColumn + " DESC"
|
||||
}
|
||||
|
||||
type storyRefRow struct {
|
||||
StoryID uint32
|
||||
ArticleID uint32
|
||||
}
|
||||
|
||||
func toStoryRefs(rows []storyRefRow) []domain.StoryRef {
|
||||
refs := make([]domain.StoryRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
refs = append(refs, domain.StoryRef{StoryID: row.StoryID, ArticleID: row.ArticleID})
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) FindStoryPage(filters domain.StoryFilters) (domain.StoryPage, error) {
|
||||
filters.Page = max(filters.Page, 1)
|
||||
filters.Size = max(filters.Size, 0)
|
||||
allowed, restricted, err := a.versionRestriction(filters)
|
||||
if err != nil {
|
||||
return domain.StoryPage{}, err
|
||||
}
|
||||
total, err := a.countUnits(filters, allowed, restricted)
|
||||
if err != nil {
|
||||
return domain.StoryPage{}, err
|
||||
}
|
||||
page := domain.StoryPage{Refs: []domain.StoryRef{}, Total: total}
|
||||
if total == 0 {
|
||||
return page, nil
|
||||
}
|
||||
qualifying := qualifyingVersions(filters, allowed, restricted)
|
||||
offset := (filters.Page - 1) * filters.Size
|
||||
var rows []storyRefRow
|
||||
if filters.PerLanguage() {
|
||||
query := "SELECT q.story_id AS story_id, q.id AS article_id FROM articles q WHERE " + qualifying.where() +
|
||||
" ORDER BY " + storyOrderClause(versionRowOrder, filters.Sort, "q.story_id") + " LIMIT ? OFFSET ?"
|
||||
err = a.db.Raw(query, append(qualifying.args, filters.Size, offset)...).Scan(&rows).Error
|
||||
} else {
|
||||
query, args := storyRowsQuery(filters, qualifying)
|
||||
err = a.db.Raw(query, append(args, filters.Size, offset)...).Scan(&rows).Error
|
||||
}
|
||||
if err != nil {
|
||||
return domain.StoryPage{}, err
|
||||
}
|
||||
page.Refs = toStoryRefs(rows)
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func storyRowsQuery(filters domain.StoryFilters, qualifying storySQL) (string, []any) {
|
||||
readable := readableCondition("a", filters)
|
||||
args := []any{true}
|
||||
args = append(args, readable.args...)
|
||||
args = append(args, qualifying.args...)
|
||||
query := "SELECT s.story_id AS story_id, s.article_id AS article_id FROM (" +
|
||||
"SELECT r.story_id AS story_id, r.id AS article_id, r.sort_title AS sort_title, r.sort_online AS sort_online, r.sort_updated AS sort_updated, r.sort_created AS sort_created FROM (" +
|
||||
"SELECT a.id AS id, a.story_id AS story_id, LOWER(a.title) AS sort_title, a.is_online AS sort_online, " +
|
||||
"MAX(a.updated_at) OVER (PARTITION BY a.story_id) AS sort_updated, " +
|
||||
"MIN(a.created_at) OVER (PARTITION BY a.story_id) AS sort_created, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY a.story_id ORDER BY CASE WHEN d.lang IS NULL THEN 1 ELSE 0 END, a.lang, a.id) AS row_rank " +
|
||||
"FROM articles a LEFT JOIN site_languages d ON d.site_id = a.site_id AND d.lang = a.lang AND d.is_default = ? " +
|
||||
"WHERE " + readable.where() + " AND a.story_id IN (SELECT q.story_id FROM articles q WHERE " + qualifying.where() + ")" +
|
||||
") r WHERE r.row_rank = 1) s ORDER BY " + storyOrderClause(storyRowOrder, filters.Sort, "s.story_id") + " LIMIT ? OFFSET ?"
|
||||
return query, args
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) CountStories(filters domain.StoryFilters) (domain.StoryCounts, error) {
|
||||
filters.Status = ""
|
||||
allowed, restricted, err := a.versionRestriction(filters)
|
||||
if err != nil {
|
||||
return domain.StoryCounts{}, err
|
||||
}
|
||||
counts := domain.StoryCounts{}
|
||||
for _, target := range []struct {
|
||||
status string
|
||||
into *int
|
||||
}{{"", &counts.All}, {domain.StatusOnline, &counts.Online}, {domain.StatusDraft, &counts.Draft}} {
|
||||
scoped := filters
|
||||
scoped.Missing = false
|
||||
scoped.Status = target.status
|
||||
if *target.into, err = a.countUnits(scoped, allowed, restricted); err != nil {
|
||||
return domain.StoryCounts{}, err
|
||||
}
|
||||
}
|
||||
if filters.SkipMissing {
|
||||
return counts, nil
|
||||
}
|
||||
missing := filters
|
||||
missing.Missing = true
|
||||
counts.Missing, err = a.countUnits(missing, allowed, restricted)
|
||||
return counts, err
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) ListByStories(storyIDs []uint32) ([]domain.Article, error) {
|
||||
var found []entity.Article
|
||||
for _, chunk := range chunkIDs(storyIDs) {
|
||||
var loaded []entity.Article
|
||||
if err := a.db.Model(&entity.Article{}).Where("story_id IN ?", chunk).Order("id ASC").Find(&loaded).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
found = append(found, loaded...)
|
||||
}
|
||||
return a.withAuthors(a.db, toDomainArticles(found))
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/author"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/language"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ListStoriesCommand struct {
|
||||
Format article.BodyFormat
|
||||
Caller ContentCaller
|
||||
Page int
|
||||
Size int
|
||||
Q string
|
||||
Status string
|
||||
Sort article.Sort
|
||||
SiteKey *string
|
||||
SEO string
|
||||
Lang string
|
||||
Tab string
|
||||
Author *uint32
|
||||
}
|
||||
|
||||
type StoryRow struct {
|
||||
Article article.Article
|
||||
SEO article.SEOSummary
|
||||
UpdatedAt time.Time
|
||||
Authors []author.Byline
|
||||
Coverage []article.Translation
|
||||
MissingLangs []string
|
||||
Capabilities []string
|
||||
}
|
||||
|
||||
type StoryRowsPage struct {
|
||||
Rows []StoryRow
|
||||
Total int
|
||||
Page int
|
||||
Size int
|
||||
Counts article.StoryCounts
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) ListStoriesPage(command ListStoriesCommand) (StoryRowsPage, error) {
|
||||
visibility := visibilityFor(command.Caller)
|
||||
creatable := command.Caller.Grants.HoldsFor(role.KeyContentCreate, builtintype.KeyArticle)
|
||||
empty := StoryRowsPage{Rows: []StoryRow{}, Page: command.Page, Size: command.Size}
|
||||
filters := storyFilters(command, visibility, command.Lang == "" && !creatable)
|
||||
owners := &siteOwners{repository: u.sites}
|
||||
if command.SiteKey != nil {
|
||||
siteID, known, err := u.siteIDOf(*command.SiteKey)
|
||||
if err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
if !known {
|
||||
return empty, nil
|
||||
}
|
||||
filters.SiteID = &siteID
|
||||
}
|
||||
summaries, visibleVersions, err := u.summarizeVisibleVersions(visibility, owners)
|
||||
if err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
if command.SEO != "" {
|
||||
allowed, err := u.versionsWithSEOStatus(command, filters, summaries)
|
||||
if err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
filters.VersionIDs = &allowed
|
||||
}
|
||||
counts, err := u.articleRepository.CountStories(filters)
|
||||
if err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
counts.Suggestions = countSuggestedStories(visibleVersions, summaries)
|
||||
empty.Counts = counts
|
||||
if command.Tab == article.TabMissing && command.Lang == "" && !creatable {
|
||||
return empty, nil
|
||||
}
|
||||
found, err := u.articleRepository.FindStoryPage(filters)
|
||||
if err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
empty.Total = found.Total
|
||||
if len(found.Refs) == 0 {
|
||||
return empty, nil
|
||||
}
|
||||
rows, err := u.storyRows(command, found.Refs, creatable, owners)
|
||||
if err != nil {
|
||||
return StoryRowsPage{}, err
|
||||
}
|
||||
empty.Rows = rows
|
||||
return empty, nil
|
||||
}
|
||||
|
||||
func storyFilters(command ListStoriesCommand, visibility articleVisibility, skipMissing bool) article.StoryFilters {
|
||||
return article.StoryFilters{
|
||||
SkipMissing: skipMissing,
|
||||
OnlyOnline: visibility.onlineOnly,
|
||||
VisibleTo: visibility.userID,
|
||||
Lang: command.Lang,
|
||||
Missing: command.Tab == article.TabMissing,
|
||||
Status: command.Status,
|
||||
AuthorID: command.Author,
|
||||
Q: command.Q,
|
||||
Sort: command.Sort,
|
||||
Page: command.Page,
|
||||
Size: command.Size,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) summarizeVisibleVersions(visibility articleVisibility, owners *siteOwners) (map[uint32]article.SEOSummary, []article.Article, error) {
|
||||
found, err := u.articleRepository.FindPage(visibility.apply(article.Filters{Unpaged: true}))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
summaries, err := u.summarize(found.Items, owners)
|
||||
return summaries, found.Items, err
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) versionsWithSEOStatus(command ListStoriesCommand, filters article.StoryFilters, summaries map[uint32]article.SEOSummary) ([]uint32, error) {
|
||||
narrowed := article.Filters{VisibleTo: filters.VisibleTo, SiteID: filters.SiteID, AuthorID: filters.AuthorID, Q: filters.Q, Unpaged: true}
|
||||
if filters.OnlyOnline {
|
||||
online := true
|
||||
narrowed.IsOnline = &online
|
||||
}
|
||||
found, err := u.articleRepository.FindPage(narrowed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed := []uint32{}
|
||||
for _, version := range found.Items {
|
||||
if summaries[version.ID].Status == command.SEO {
|
||||
allowed = append(allowed, version.ID)
|
||||
}
|
||||
}
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
func countSuggestedStories(versions []article.Article, summaries map[uint32]article.SEOSummary) int {
|
||||
stories := map[uint32]bool{}
|
||||
standalone := 0
|
||||
for _, version := range versions {
|
||||
if summaries[version.ID].Status != article.SEOStatusSuggestions {
|
||||
continue
|
||||
}
|
||||
if version.StoryID == 0 {
|
||||
standalone++
|
||||
continue
|
||||
}
|
||||
stories[version.StoryID] = true
|
||||
}
|
||||
return len(stories) + standalone
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) storyRows(command ListStoriesCommand, refs []article.StoryRef, creatable bool, owners *siteOwners) ([]StoryRow, error) {
|
||||
storyIDs := make([]uint32, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
storyIDs = append(storyIDs, ref.StoryID)
|
||||
}
|
||||
loaded, err := u.articleRepository.ListByStories(storyIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byStory := map[uint32][]article.Article{}
|
||||
for _, version := range command.Caller.readableVersions(loaded) {
|
||||
byStory[version.StoryID] = append(byStory[version.StoryID], version)
|
||||
}
|
||||
siteRows := map[uint32][]localization.Row{}
|
||||
rows := make([]StoryRow, 0, len(refs))
|
||||
representatives := make([]article.Article, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
versions := byStory[ref.StoryID]
|
||||
representative, found := versionByID(versions, ref.ArticleID)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
languages, cached := siteRows[representative.SiteID]
|
||||
if !cached {
|
||||
if languages, err = u.localization.Rows(representative.SiteID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
siteRows[representative.SiteID] = languages
|
||||
}
|
||||
rows = append(rows, buildStoryRow(command.Caller, representative, versions, languages, creatable))
|
||||
representatives = append(representatives, representative)
|
||||
}
|
||||
summaries, err := u.summarize(representatives, owners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rendered, err := renderArticles(u.bodyRenderer, representatives, command.Format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range rows {
|
||||
rows[index].Article = rendered[index]
|
||||
rows[index].SEO = summaries[rendered[index].ID]
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func versionByID(versions []article.Article, id uint32) (article.Article, bool) {
|
||||
index := slices.IndexFunc(versions, func(version article.Article) bool { return version.ID == id })
|
||||
if index < 0 {
|
||||
return article.Article{}, false
|
||||
}
|
||||
return versions[index], true
|
||||
}
|
||||
|
||||
func buildStoryRow(caller ContentCaller, representative article.Article, versions []article.Article, languages []localization.Row, creatable bool) StoryRow {
|
||||
ordered := sortByLanguage(versions, languages)
|
||||
coverage := make([]article.Translation, 0, len(ordered))
|
||||
updatedAt := time.Time{}
|
||||
authors := []author.Byline{}
|
||||
seen := map[uint32]bool{}
|
||||
for _, version := range ordered {
|
||||
coverage = append(coverage, article.Translation{
|
||||
ID: version.ID,
|
||||
Lang: version.Lang,
|
||||
Title: version.Title,
|
||||
IsOnline: version.IsOnline,
|
||||
LanguageEnabled: version.Lang == language.Unassigned || localization.EnabledTag(languages, version.Lang),
|
||||
Capabilities: caller.capabilitiesFor(version),
|
||||
})
|
||||
if version.UpdatedAt.After(updatedAt) {
|
||||
updatedAt = version.UpdatedAt
|
||||
}
|
||||
for _, byline := range version.Authors {
|
||||
if !seen[byline.ID] {
|
||||
seen[byline.ID] = true
|
||||
authors = append(authors, byline)
|
||||
}
|
||||
}
|
||||
}
|
||||
return StoryRow{
|
||||
UpdatedAt: updatedAt,
|
||||
Authors: authors,
|
||||
Coverage: coverage,
|
||||
MissingLangs: missingLanguages(ordered, languages, creatable),
|
||||
Capabilities: caller.capabilitiesFor(representative),
|
||||
}
|
||||
}
|
||||
|
||||
func missingLanguages(versions []article.Article, languages []localization.Row, creatable bool) []string {
|
||||
missing := []string{}
|
||||
if !creatable || slices.ContainsFunc(versions, func(version article.Article) bool { return version.Lang == language.Unassigned }) {
|
||||
return missing
|
||||
}
|
||||
present := languagesOf(versions)
|
||||
for _, row := range languages {
|
||||
if row.Enabled && !slices.Contains(present, row.Tag) {
|
||||
missing = append(missing, row.Tag)
|
||||
}
|
||||
}
|
||||
defaultTag := localization.DefaultTag(languages)
|
||||
slices.SortFunc(missing, func(left string, right string) int { return localization.CompareTags(defaultTag, left, right) })
|
||||
return missing
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package useCases
|
||||
import (
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/builtintype"
|
||||
"RenewCMS/internal/domain/localization"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"errors"
|
||||
@@ -12,10 +13,11 @@ type ListArticlesUseCase struct {
|
||||
articleRepository article.Repository
|
||||
bodyRenderer article.BodyRenderer
|
||||
sites site.Repository
|
||||
localization localization.Repository
|
||||
}
|
||||
|
||||
func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, sites site.Repository) *ListArticlesUseCase {
|
||||
return &ListArticlesUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, sites: sites}
|
||||
func NewListArticlesUseCase(articleRepository article.Repository, bodyRenderer article.BodyRenderer, sites site.Repository, localizationRepository localization.Repository) *ListArticlesUseCase {
|
||||
return &ListArticlesUseCase{articleRepository: articleRepository, bodyRenderer: bodyRenderer, sites: sites, localization: localizationRepository}
|
||||
}
|
||||
|
||||
type ListArticlesCommand struct {
|
||||
@@ -28,6 +30,8 @@ type ListArticlesCommand struct {
|
||||
Sort article.Sort
|
||||
SiteKey *string
|
||||
SEO string
|
||||
Lang *string
|
||||
Author *uint32
|
||||
}
|
||||
|
||||
type ArticlePage struct {
|
||||
@@ -40,13 +44,17 @@ type ArticlePage struct {
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) ListArticlesPage(command ListArticlesCommand) (ArticlePage, error) {
|
||||
switch command.Caller.Grants.ScopeFor(role.KeyContentRead, builtintype.KeyArticle) {
|
||||
return u.listVisibleByRepository(command, visibilityFor(command.Caller))
|
||||
}
|
||||
|
||||
func visibilityFor(caller ContentCaller) articleVisibility {
|
||||
switch caller.Grants.ScopeFor(role.KeyContentRead, builtintype.KeyArticle) {
|
||||
case role.ScopeAll:
|
||||
return u.listVisibleByRepository(command, articleVisibility{})
|
||||
return articleVisibility{}
|
||||
case role.ScopeOwn:
|
||||
return u.listVisibleByRepository(command, articleVisibility{userID: &command.Caller.UserID})
|
||||
return articleVisibility{userID: &caller.UserID}
|
||||
default:
|
||||
return u.listVisibleByRepository(command, articleVisibility{onlineOnly: true})
|
||||
return articleVisibility{onlineOnly: true}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +72,13 @@ func (v articleVisibility) apply(filters article.Filters) article.Filters {
|
||||
return filters
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) countFlat(command ListArticlesCommand, visibility articleVisibility) (article.Counts, error) {
|
||||
if command.Lang != nil {
|
||||
return u.countVisibleVersions(command, visibility)
|
||||
}
|
||||
return u.countVisible(visibility)
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) countVisible(visibility articleVisibility) (article.Counts, error) {
|
||||
if visibility.userID != nil {
|
||||
return u.articleRepository.CountVisibleTo(*visibility.userID)
|
||||
@@ -73,7 +88,7 @@ func (u *ListArticlesUseCase) countVisible(visibility articleVisibility) (articl
|
||||
|
||||
func (u *ListArticlesUseCase) listVisibleByRepository(command ListArticlesCommand, visibility articleVisibility) (ArticlePage, error) {
|
||||
onlineOnly := visibility.onlineOnly
|
||||
counts, err := u.countVisible(visibility)
|
||||
counts, err := u.countFlat(command, visibility)
|
||||
if err != nil {
|
||||
return ArticlePage{}, err
|
||||
}
|
||||
@@ -149,7 +164,12 @@ func (u *ListArticlesUseCase) summarizeVisible(visibility articleVisibility) (ma
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u.summarize(found.Items)
|
||||
return u.summarize(found.Items, &siteOwners{repository: u.sites})
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) countVisibleVersions(command ListArticlesCommand, visibility articleVisibility) (article.Counts, error) {
|
||||
filters := visibility.apply(article.Filters{Lang: command.Lang})
|
||||
return u.articleRepository.CountVersions(filters)
|
||||
}
|
||||
|
||||
func countSuggestions(summaries map[uint32]article.SEOSummary) int {
|
||||
@@ -167,19 +187,35 @@ func pageBySEOStatus(items []article.Article, summaries map[uint32]article.SEOSu
|
||||
return pageSlice(matching, command.Page, command.Size), len(matching)
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) summarize(items []article.Article) (map[uint32]article.SEOSummary, error) {
|
||||
type siteOwners struct {
|
||||
repository site.Repository
|
||||
byID map[uint32]site.Site
|
||||
}
|
||||
|
||||
func (o *siteOwners) get() (map[uint32]site.Site, error) {
|
||||
if o.byID != nil {
|
||||
return o.byID, nil
|
||||
}
|
||||
known, err := o.repository.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.byID = make(map[uint32]site.Site, len(known))
|
||||
for _, owner := range known {
|
||||
o.byID[owner.ID] = owner
|
||||
}
|
||||
return o.byID, nil
|
||||
}
|
||||
|
||||
func (u *ListArticlesUseCase) summarize(items []article.Article, lookup *siteOwners) (map[uint32]article.SEOSummary, error) {
|
||||
summaries := make(map[uint32]article.SEOSummary, len(items))
|
||||
if len(items) == 0 {
|
||||
return summaries, nil
|
||||
}
|
||||
known, err := u.sites.List()
|
||||
owners, err := lookup.get()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owners := make(map[uint32]site.Site, len(known))
|
||||
for _, owner := range known {
|
||||
owners[owner.ID] = owner
|
||||
}
|
||||
for _, item := range items {
|
||||
summaries[item.ID] = assessSEO(item, owners[item.SiteID]).Summary()
|
||||
}
|
||||
@@ -198,5 +234,5 @@ func (u *ListArticlesUseCase) siteIDOf(key string) (uint32, bool, error) {
|
||||
}
|
||||
|
||||
func pageFilters(command ListArticlesCommand) article.Filters {
|
||||
return article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, Sort: command.Sort}
|
||||
return article.Filters{Page: command.Page, Size: command.Size, Q: command.Q, Status: command.Status, Sort: command.Sort, Lang: command.Lang, AuthorID: command.Author}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
|
||||
Token: env.token,
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), renderer, recorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites()),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer, plainSites(), plainLocalization()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder, plainSites(), plainAuthors(), env.images, newFakeLocalizationStore(env.articles), plainStoryMover(env.articles)),
|
||||
CreateTranslationUseCase: plainTranslation(env.articles),
|
||||
DescribeArticleUseCase: plainDescribe(env.articles),
|
||||
|
||||
@@ -45,7 +45,7 @@ func newBodyFormatRouter(articleRepo *mockArticleRepo, userRepo *mockUserRepo, r
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), renderer, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, renderer),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites(), plainLocalization()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
|
||||
@@ -25,7 +25,7 @@ func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), renderer, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, renderer),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites()),
|
||||
useCases.NewListArticlesUseCase(articleRepo, renderer, plainSites(), plainLocalization()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
|
||||
@@ -37,7 +37,7 @@ func newCreatedByEnv(t *testing.T) (*activityHTTPEnv, http.Handler) {
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, e.generator, e.users, e.clock),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(e.articles), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(e.articles, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, plainSites()),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(e.articles, &fakeBodyRenderer{}, plainSites(), plainLocalization()),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(e.articles, discardRecorder),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(e.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(e.articles), plainStoryMover(e.articles)),
|
||||
CreateTranslationUseCase: plainTranslation(e.articles),
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const failedToRetrieveArticles = "Failed to retrieve articles\n"
|
||||
|
||||
func expectIDs(t *testing.T, label string, got []int, want ...int) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%s: got %v, want %v", label, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func expectCounts(t *testing.T, got map[string]int, want map[string]int) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("counts: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func bylineNames(item map[string]any, field string) []string {
|
||||
names := []string{}
|
||||
for _, entry := range item[field].([]any) {
|
||||
names = append(names, entry.(map[string]any)["name"].(string))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestArticleListLocalizationSQLite(t *testing.T) {
|
||||
runArticleListLocalization(t, openContractSQLite)
|
||||
}
|
||||
|
||||
func TestMySQLArticleListLocalization(t *testing.T) {
|
||||
runArticleListLocalization(t, openContractMySQL)
|
||||
}
|
||||
|
||||
func TestPostgresArticleListLocalization(t *testing.T) {
|
||||
runArticleListLocalization(t, openContractPostgres)
|
||||
}
|
||||
|
||||
func runArticleListLocalization(t *testing.T, open func(*testing.T) *gorm.DB) {
|
||||
validator.InitValidator()
|
||||
fresh := func(t *testing.T) *localizationListEnv { return newLocalizationListEnv(t, open) }
|
||||
shared := fresh(t)
|
||||
base := func(t *testing.T) *localizationListEnv {
|
||||
shared.t = t
|
||||
shared.repo.calls = 0
|
||||
shared.repo.failMethod = ""
|
||||
return shared
|
||||
}
|
||||
|
||||
t.Run("A bare request is still the flat envelope", func(t *testing.T) {
|
||||
env := base(t)
|
||||
rec, _ := env.get(localizationOwnerID, "")
|
||||
var keys map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &keys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(keys) != 5 || keys["items"] == nil || keys["total"] == nil || keys["page"] == nil || keys["size"] == nil || keys["counts"] == nil {
|
||||
t.Fatalf("unexpected envelope keys: %s", rec.Body.String())
|
||||
}
|
||||
_, body := env.get(localizationOwnerID, "?size=100")
|
||||
if body.Total != 17 || len(body.Items) != 17 {
|
||||
t.Fatalf("expected 17 versions, got total %d and %d items", body.Total, len(body.Items))
|
||||
}
|
||||
if _, grouped := body.Items[0]["coverage"]; grouped {
|
||||
t.Fatal("the flat item must not carry grouped fields")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lang, tab and group are not read unless asked", func(t *testing.T) {
|
||||
env := base(t)
|
||||
_, body := env.get(0, "?size=5")
|
||||
if body.Total != 12 || len(body.Items) != 5 {
|
||||
t.Fatalf("expected 12 online versions, got total %d and %d items", body.Total, len(body.Items))
|
||||
}
|
||||
for _, item := range body.Items {
|
||||
if _, ok := item["lang"]; !ok {
|
||||
t.Fatal("items keep lang")
|
||||
}
|
||||
if _, ok := item["story_id"]; !ok {
|
||||
t.Fatal("items keep story_id")
|
||||
}
|
||||
}
|
||||
expectCounts(t, body.Counts, map[string]int{"all": 12, "online": 12, "draft": 0, "suggestions": 0})
|
||||
})
|
||||
|
||||
t.Run("group=story needs an editorial caller", func(t *testing.T) {
|
||||
for name, caller := range map[string]uint32{"no cookie": 0, "malformed cookie": localizationMalformed, "unverified user": localizationUnverifiedID} {
|
||||
env := base(t)
|
||||
rec, _ := env.get(caller, "?group=story")
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), paginationBodyMismatch) {
|
||||
t.Fatalf("%s: expected 400, got %d %q", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
if env.repo.calls != 0 {
|
||||
t.Fatalf("%s: the repository was called", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid new parameters answer 400", func(t *testing.T) {
|
||||
queries := []string{
|
||||
"group=stories", "group=", "group=story&tab=review", "group=story&tab=published", "group=story&tab=", "tab=missing",
|
||||
"group=story&lang=english", "group=story&lang=xx", "group=story&lang=", "group=story&author=0", "group=story&author=abc",
|
||||
"group=story&author=", "author=-1", "lang=english", "lang=", "author=0",
|
||||
}
|
||||
for _, query := range queries {
|
||||
env := base(t)
|
||||
rec, _ := env.get(localizationOwnerID, "?"+query)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), paginationBodyMismatch) {
|
||||
t.Fatalf("%s: expected 400, got %d %q", query, rec.Code, rec.Body.String())
|
||||
}
|
||||
if env.repo.calls != 0 {
|
||||
t.Fatalf("%s: the repository was called", query)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("All languages, page 1", func(t *testing.T) {
|
||||
env := base(t)
|
||||
rec, body := env.get(localizationOwnerID, "?group=story&size=5")
|
||||
if rec.Code != http.StatusOK || body.Total != 8 || body.Page != 1 || body.Size != 5 {
|
||||
t.Fatalf("unexpected page: %d total=%d page=%d size=%d", rec.Code, body.Total, body.Page, body.Size)
|
||||
}
|
||||
expectIDs(t, "stories", body.storyIDs(), 1, 2, 3, 4, 5)
|
||||
if body.Counts["all"] != 8 {
|
||||
t.Fatalf("counts.all = %d", body.Counts["all"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("All languages, page 2 is the last", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story&size=5&page=2")
|
||||
expectIDs(t, "stories", body.storyIDs(), 6, 7, 8)
|
||||
if body.Total != 8 {
|
||||
t.Fatalf("total = %d", body.Total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A page beyond the last is empty with the real total", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story&size=5&page=99")
|
||||
if len(body.Items) != 0 || body.Total != 8 || body.Counts["all"] != 8 {
|
||||
t.Fatalf("unexpected page: %d items, total %d, counts %v", len(body.Items), body.Total, body.Counts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A grouped row describes the representative version and the story", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story")
|
||||
item := body.item(1)
|
||||
if int(item["id"].(float64)) != 101 || item["lang"] != "en" || item["site_id"] == nil || item["slug"] != "a-101" || item["is_online"] != true {
|
||||
t.Fatalf("unexpected representative: %v", item)
|
||||
}
|
||||
if item["story_updated_at"] != "2026-10-30T10:00:00Z" {
|
||||
t.Fatalf("story_updated_at = %v", item["story_updated_at"])
|
||||
}
|
||||
if names := bylineNames(item, "story_authors"); !reflect.DeepEqual(names, []string{"Marta Alves"}) {
|
||||
t.Fatalf("story_authors = %v", names)
|
||||
}
|
||||
for _, field := range []string{"coverage", "missing_langs", "capabilities"} {
|
||||
if item[field] == nil {
|
||||
t.Fatalf("%s is missing", field)
|
||||
}
|
||||
}
|
||||
entry := item["coverage"].([]any)[0].(map[string]any)
|
||||
for _, field := range []string{"id", "lang", "title", "is_online", "language_enabled", "capabilities"} {
|
||||
if _, ok := entry[field]; !ok {
|
||||
t.Fatalf("coverage entry lacks %s", field)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The representative is the default-language version, else the first readable by language code", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story")
|
||||
for story, want := range map[int]int{1: 101, 4: 401, 8: 801} {
|
||||
if got := int(body.item(story)["id"].(float64)); got != want {
|
||||
t.Fatalf("story %d representative = %d, want %d", story, got, want)
|
||||
}
|
||||
}
|
||||
if body.item(8)["lang"] != "es" {
|
||||
t.Fatalf("story 8 lang = %v", body.item(8)["lang"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The representative falls back when the default version is unreadable", func(t *testing.T) {
|
||||
env := fresh(t)
|
||||
env.must(env.db.Model(&entity.Article{}).Where("id = ?", 502).UpdateColumn("is_online", true).Error)
|
||||
env.must(env.db.Model(&entity.Article{}).Where("id = ?", 501).UpdateColumn("is_online", false).Error)
|
||||
_, body := env.get(localizationWriterID, "?group=story")
|
||||
item := body.item(5)
|
||||
if int(item["id"].(float64)) != 502 || item["lang"] != "de" {
|
||||
t.Fatalf("expected fallback to 502, got %v", item["id"])
|
||||
}
|
||||
if len(item["coverage"].([]any)) != 1 {
|
||||
t.Fatalf("the hidden version leaks: %v", item["coverage"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("story_updated_at is the latest readable version update", func(t *testing.T) {
|
||||
env := fresh(t)
|
||||
env.must(env.db.Model(&entity.Article{}).Where("id = ?", 102).UpdateColumn("updated_at", time.Date(2026, 11, 1, 8, 0, 0, 0, time.UTC)).Error)
|
||||
_, writer := env.get(localizationWriterID, "?group=story")
|
||||
if got := writer.item(1)["story_updated_at"]; got != "2026-10-30T09:00:00Z" {
|
||||
t.Fatalf("writer story_updated_at = %v", got)
|
||||
}
|
||||
_, owner := env.get(localizationOwnerID, "?group=story")
|
||||
if got := owner.item(1)["story_updated_at"]; got != "2026-11-01T08:00:00Z" {
|
||||
t.Fatalf("owner story_updated_at = %v", got)
|
||||
}
|
||||
expectIDs(t, "owner order", owner.storyIDs()[:2], 1, 2)
|
||||
})
|
||||
|
||||
t.Run("Coverage lists readable versions in the chip order, then the languages that can be created", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story")
|
||||
cases := []struct {
|
||||
story int
|
||||
coverage []string
|
||||
missing []string
|
||||
}{
|
||||
{1, []string{"en", "ar", "de", "fr"}, []string{}},
|
||||
{3, []string{"en", "fr"}, []string{"ar", "de"}},
|
||||
{4, []string{"en"}, []string{"ar", "de", "fr"}},
|
||||
{8, []string{"es"}, []string{"en", "ar", "de", "fr"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
item := body.item(c.story)
|
||||
if got := coverageLangs(item); !reflect.DeepEqual(got, c.coverage) {
|
||||
t.Fatalf("story %d coverage = %v", c.story, got)
|
||||
}
|
||||
if got := stringsOf(item["missing_langs"]); !reflect.DeepEqual(got, c.missing) {
|
||||
t.Fatalf("story %d missing_langs = %v", c.story, got)
|
||||
}
|
||||
}
|
||||
if body.item(8)["coverage"].([]any)[0].(map[string]any)["language_enabled"] != false {
|
||||
t.Fatal("es is disabled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Coverage order is default, other enabled by tag, disabled by tag; missing is listed by the same rule", func(t *testing.T) {
|
||||
env := fresh(t)
|
||||
env.addVersion(localizationVersion{id: 802, lang: "it", online: true, title: "Journal 802", updated: localizationDay(10, 8), byline: "Luis Ferrer", noindex: true, storyKey: 8})
|
||||
_, body := env.get(localizationOwnerID, "?group=story")
|
||||
item := body.item(8)
|
||||
if got := coverageLangs(item); !reflect.DeepEqual(got, []string{"es", "it"}) {
|
||||
t.Fatalf("coverage = %v", got)
|
||||
}
|
||||
if got := stringsOf(item["missing_langs"]); got[0] != "en" {
|
||||
t.Fatalf("missing_langs = %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A caller who cannot create gets no missing_langs", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationReaderID, "?group=story")
|
||||
for _, item := range body.Items {
|
||||
if len(stringsOf(item["missing_langs"])) != 0 {
|
||||
t.Fatalf("reader got missing_langs on %v", item["story_id"])
|
||||
}
|
||||
}
|
||||
if got := coverageLangs(body.item(1)); !reflect.DeepEqual(got, []string{"en", "de"}) {
|
||||
t.Fatalf("reader coverage = %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The row of a story whose versions are all hidden does not exist", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationWriterID, "?group=story&size=100")
|
||||
if body.item(4) != nil || body.Total != 7 || body.Counts["all"] != 7 {
|
||||
t.Fatalf("story 4 leaks: total=%d counts=%v", body.Total, body.Counts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Hidden versions leave no trace", func(t *testing.T) {
|
||||
env := fresh(t)
|
||||
_, with := env.get(localizationWriterID, "?group=story&size=100")
|
||||
withRec, _ := env.get(localizationWriterID, "?group=story&size=100")
|
||||
without := fresh(t)
|
||||
without.must(without.db.Unscoped().Where("id IN ?", []uint32{102, 104, 401, 502, 602}).Delete(&entity.Article{}).Error)
|
||||
withoutRec, _ := without.get(localizationWriterID, "?group=story&size=100")
|
||||
if withRec.Body.String() != withoutRec.Body.String() {
|
||||
t.Fatalf("hidden versions change the response:\n%s\n%s", withRec.Body.String(), withoutRec.Body.String())
|
||||
}
|
||||
if len(with.Items) == 0 {
|
||||
t.Fatal("expected rows")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Unassigned articles are their own story with an empty language", func(t *testing.T) {
|
||||
env := fresh(t)
|
||||
env.addVersion(localizationVersion{id: 30, lang: "", online: true, title: "Journal 30", updated: localizationDay(1, 9), byline: "Luis Ferrer", noindex: true, storyKey: 30})
|
||||
_, body := env.get(localizationOwnerID, "?group=story&size=100")
|
||||
item := body.item(30)
|
||||
if item["lang"] != "" || len(item["coverage"].([]any)) != 1 || len(stringsOf(item["missing_langs"])) != 0 {
|
||||
t.Fatalf("unexpected unassigned row: %v", item)
|
||||
}
|
||||
_, missing := env.get(localizationOwnerID, "?group=story&lang=fr&tab=missing&size=100")
|
||||
if missing.item(30) != nil {
|
||||
t.Fatal("unassigned article listed in the not-in view")
|
||||
}
|
||||
_, needs := env.get(localizationOwnerID, "?group=story&tab=missing&size=100")
|
||||
if needs.item(30) != nil {
|
||||
t.Fatal("unassigned article listed as needing translation")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("French selected", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story&lang=fr&size=5")
|
||||
if body.Total != 4 {
|
||||
t.Fatalf("total = %d", body.Total)
|
||||
}
|
||||
expectIDs(t, "stories", body.storyIDs(), 1, 2, 3, 6)
|
||||
expectIDs(t, "representatives", body.articleIDs(), 102, 202, 302, 602)
|
||||
if len(body.item(1)["coverage"].([]any)) != 4 || len(bylineNames(body.item(1), "story_authors")) != 1 {
|
||||
t.Fatalf("the row must carry the whole story: %v", body.item(1))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Disabled languages can be selected", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story&lang=es")
|
||||
expectIDs(t, "articles", body.articleIDs(), 801)
|
||||
if body.Total != 1 || body.item(8)["coverage"].([]any)[0].(map[string]any)["language_enabled"] != false {
|
||||
t.Fatalf("unexpected result: %v", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A language nobody has gives an empty list", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story&lang=it")
|
||||
if len(body.Items) != 0 || body.Total != 0 {
|
||||
t.Fatalf("expected nothing, got %d items", len(body.Items))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lang also narrows the flat list", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?lang=fr")
|
||||
if body.Total != 4 || len(body.Items) != 4 {
|
||||
t.Fatalf("total = %d", body.Total)
|
||||
}
|
||||
for _, item := range body.Items {
|
||||
if item["lang"] != "fr" {
|
||||
t.Fatalf("item with lang %v", item["lang"])
|
||||
}
|
||||
}
|
||||
if body.Counts["all"] != 4 || body.Counts["online"] != 2 || body.Counts["draft"] != 2 {
|
||||
t.Fatalf("counts = %v", body.Counts)
|
||||
}
|
||||
_, filtered := base(t).get(localizationOwnerID, "?lang=fr&status=draft&q=zinc")
|
||||
if filtered.Counts["all"] != 4 {
|
||||
t.Fatalf("flat counts must ignore q and status: %v", filtered.Counts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Not in French", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story&lang=fr&tab=missing&size=5")
|
||||
if body.Total != 4 {
|
||||
t.Fatalf("total = %d", body.Total)
|
||||
}
|
||||
expectIDs(t, "stories", body.storyIDs(), 4, 5, 7, 8)
|
||||
expectIDs(t, "representatives", body.articleIDs(), 401, 501, 701, 801)
|
||||
for _, item := range body.Items {
|
||||
if !listHasTag(stringsOf(item["missing_langs"]), "fr") {
|
||||
t.Fatalf("missing_langs lacks fr: %v", item["missing_langs"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Needs translation, no language selected", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story&tab=missing&size=5")
|
||||
if body.Total != 7 {
|
||||
t.Fatalf("total = %d", body.Total)
|
||||
}
|
||||
expectIDs(t, "stories", body.storyIDs(), 2, 3, 4, 5, 6)
|
||||
})
|
||||
|
||||
t.Run("Missing means no readable version, not no stored version", func(t *testing.T) {
|
||||
env := fresh(t)
|
||||
env.must(env.db.Model(&entity.Article{}).Where("id = ?", 401).UpdateColumn("is_online", true).Error)
|
||||
env.addVersion(localizationVersion{id: 402, lang: "fr", online: false, title: "Hidden French draft", updated: localizationDay(27, 8), byline: "Marta Alves", noindex: true, storyKey: 4})
|
||||
rec, body := env.get(localizationWriterID, "?group=story&lang=fr&tab=missing&size=100")
|
||||
if body.item(4) == nil {
|
||||
t.Fatal("story 4 must be listed as not in French")
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "402") || strings.Contains(rec.Body.String(), "Hidden French draft") {
|
||||
t.Fatal("the response tells a fr version exists")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Sites that do not enable the language are excluded", func(t *testing.T) {
|
||||
env := fresh(t)
|
||||
env.addVersion(localizationVersion{id: 5001, lang: "en", online: true, title: "Atelier one", updated: localizationDay(20, 9), byline: "Luis Ferrer", noindex: true, storyKey: 50, siteKey: "atelier"})
|
||||
env.addVersion(localizationVersion{id: 5002, lang: "fr", online: true, title: "Atelier un", updated: localizationDay(20, 8), byline: "Luis Ferrer", noindex: true, storyKey: 50, siteKey: "atelier"})
|
||||
_, german := env.get(localizationOwnerID, "?group=story&lang=de&tab=missing&size=100")
|
||||
if german.item(50) != nil || german.Total == 0 {
|
||||
t.Fatalf("atelier story listed or journal empty: %v", german.storyIDs())
|
||||
}
|
||||
_, spanish := env.get(localizationOwnerID, "?group=story&lang=es&tab=missing")
|
||||
if spanish.Total != 0 || len(spanish.Items) != 0 {
|
||||
t.Fatalf("es is disabled, got %d", spanish.Total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Needs translation counts only languages the caller can create", func(t *testing.T) {
|
||||
env := base(t)
|
||||
_, body := env.get(localizationReaderID, "?group=story&tab=missing")
|
||||
if body.Total != 0 || len(body.Items) != 0 {
|
||||
t.Fatalf("expected nothing, got %d", body.Total)
|
||||
}
|
||||
_, french := env.get(localizationReaderID, "?group=story&lang=fr&tab=missing&size=100")
|
||||
expectIDs(t, "reader stories", french.storyIDs(), 1, 5, 6, 7, 8)
|
||||
})
|
||||
|
||||
t.Run("Nothing missing", func(t *testing.T) {
|
||||
env := fresh(t)
|
||||
for story := uint32(60); story < 63; story++ {
|
||||
env.addVersion(localizationVersion{id: story * 100, lang: "en", online: true, title: "Press en", updated: localizationDay(5, 9), byline: "Luis Ferrer", noindex: true, storyKey: story, siteKey: "press"})
|
||||
env.addVersion(localizationVersion{id: story*100 + 1, lang: "fr", online: true, title: "Press fr", updated: localizationDay(5, 8), byline: "Luis Ferrer", noindex: true, storyKey: story, siteKey: "press"})
|
||||
}
|
||||
_, body := env.get(localizationOwnerID, "?group=story&tab=missing&site=press")
|
||||
if len(body.Items) != 0 || body.Total != 0 || body.Counts["missing"] != 0 || body.Counts["all"] != 3 {
|
||||
t.Fatalf("unexpected: total=%d counts=%v", body.Total, body.Counts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Status matches a readable version of the story", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story&status=draft")
|
||||
expectIDs(t, "stories", body.storyIDs(), 1, 4, 5, 6)
|
||||
if body.Total != 4 {
|
||||
t.Fatalf("total = %d", body.Total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Combined filters need a single readable version to satisfy them together", func(t *testing.T) {
|
||||
env := fresh(t)
|
||||
camille := env.authors["Camille Roche"]
|
||||
env.must(env.db.Model(&entity.ArticleAuthor{}).Where("article_id IN ?", []uint32{102, 104}).UpdateColumn("author_id", camille).Error)
|
||||
marta := env.authors["Marta Alves"]
|
||||
_, draftMarta := env.get(localizationOwnerID, "?group=story&status=draft&author="+itoa(marta))
|
||||
if draftMarta.item(1) != nil {
|
||||
t.Fatal("story 1 must not be listed")
|
||||
}
|
||||
_, onlineMarta := env.get(localizationOwnerID, "?group=story&status=online&author="+itoa(marta))
|
||||
if onlineMarta.item(1) == nil {
|
||||
t.Fatal("story 1 must be listed")
|
||||
}
|
||||
_, draftCamille := env.get(localizationOwnerID, "?group=story&status=draft&author="+itoa(camille))
|
||||
if draftCamille.item(1) == nil {
|
||||
t.Fatal("story 1 must be listed")
|
||||
}
|
||||
_, union := env.get(localizationOwnerID, "?group=story&author="+itoa(camille))
|
||||
if names := bylineNames(union.item(1), "story_authors"); !reflect.DeepEqual(names, []string{"Marta Alves", "Camille Roche"}) {
|
||||
t.Fatalf("story_authors = %v", names)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Search matches any readable title of the story", func(t *testing.T) {
|
||||
env := base(t)
|
||||
_, body := env.get(localizationOwnerID, "?group=story&q=RENOVATION")
|
||||
expectIDs(t, "articles", body.articleIDs(), 101)
|
||||
_, french := env.get(localizationOwnerID, "?group=story&lang=fr&q=chiffrons")
|
||||
expectIDs(t, "french", french.articleIDs(), 102)
|
||||
_, english := env.get(localizationOwnerID, "?group=story&lang=en&q=chiffrons")
|
||||
if len(english.Items) != 0 {
|
||||
t.Fatal("expected nothing")
|
||||
}
|
||||
_, accent := env.get(localizationOwnerID, "?group=story&q=r%C3%A9novation")
|
||||
expectIDs(t, "accent", accent.articleIDs(), 101)
|
||||
})
|
||||
|
||||
t.Run("Search in the not-in view matches readable source titles", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story&lang=fr&tab=missing&q=Spring")
|
||||
expectIDs(t, "stories", body.storyIDs(), 4)
|
||||
})
|
||||
|
||||
t.Run("Author in language mode", func(t *testing.T) {
|
||||
env := base(t)
|
||||
_, body := env.get(localizationOwnerID, "?group=story&lang=fr&author="+itoa(env.authors["Camille Roche"]))
|
||||
expectIDs(t, "stories", body.storyIDs(), 2, 6)
|
||||
})
|
||||
|
||||
t.Run("Site filter", func(t *testing.T) {
|
||||
env := fresh(t)
|
||||
env.addVersion(localizationVersion{id: 7001, lang: "en", online: true, title: "Press piece", updated: localizationDay(6, 9), byline: "Luis Ferrer", noindex: true, storyKey: 70, siteKey: "press"})
|
||||
_, body := env.get(localizationOwnerID, "?group=story&site=press")
|
||||
expectIDs(t, "stories", body.storyIDs(), 70)
|
||||
_, unknown := env.get(localizationOwnerID, "?group=story&site=nowhere")
|
||||
if len(unknown.Items) != 0 || unknown.Total != 0 {
|
||||
t.Fatal("unknown site must be empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SEO filter on one version", func(t *testing.T) {
|
||||
env := fresh(t)
|
||||
env.must(env.db.Model(&entity.Article{}).Where("id = ?", 502).UpdateColumn("noindex", false).Error)
|
||||
_, body := env.get(localizationOwnerID, "?group=story&seo=suggestions")
|
||||
expectIDs(t, "articles", body.articleIDs(), 501)
|
||||
if body.Total != 1 || body.Counts["suggestions"] != 1 {
|
||||
t.Fatalf("total=%d counts=%v", body.Total, body.Counts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("All filters combine with paging and stay stable", func(t *testing.T) {
|
||||
env := fresh(t)
|
||||
for story := uint32(21); story <= 24; story++ {
|
||||
env.addVersion(localizationVersion{id: story * 10, lang: "en", online: true, title: "Journal extra", updated: localizationDay(2, 9), byline: "Luis Ferrer", noindex: true, storyKey: story})
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
sizes := []int{}
|
||||
for page := 1; page <= 3; page++ {
|
||||
_, body := env.get(localizationOwnerID, "?group=story&size=5&page="+itoa(uint32(page)))
|
||||
if body.Total != 12 {
|
||||
t.Fatalf("page %d total = %d", page, body.Total)
|
||||
}
|
||||
sizes = append(sizes, len(body.Items))
|
||||
for _, id := range body.storyIDs() {
|
||||
if seen[id] {
|
||||
t.Fatalf("story %d repeats", id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(sizes, []int{5, 5, 2}) {
|
||||
t.Fatalf("page sizes = %v", sizes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Sort orders stories, not the page", func(t *testing.T) {
|
||||
env := base(t)
|
||||
cases := map[string][]int{
|
||||
"updated_at:desc": {1, 2, 3, 4, 5, 6, 7, 8},
|
||||
"updated_at:asc": {8, 7, 6, 5, 4, 3, 2, 1},
|
||||
"title:asc": {1, 2, 3, 5, 6, 7, 8, 4},
|
||||
"title:desc": {4, 8, 7, 6, 5, 3, 2, 1},
|
||||
"status:asc": {4, 8, 7, 6, 5, 3, 2, 1},
|
||||
"created_at:asc": {8, 7, 6, 5, 4, 3, 2, 1},
|
||||
}
|
||||
for sortValue, want := range cases {
|
||||
_, body := env.get(localizationOwnerID, "?group=story&size=100&sort="+sortValue)
|
||||
expectIDs(t, sortValue, body.storyIDs(), want...)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Ties end with story_id descending", func(t *testing.T) {
|
||||
env := fresh(t)
|
||||
for _, story := range []uint32{11, 12} {
|
||||
env.addVersion(localizationVersion{id: story * 10, lang: "en", online: true, title: "Tied", updated: localizationDay(1, 9), byline: "Luis Ferrer", noindex: true, storyKey: story})
|
||||
}
|
||||
_, body := env.get(localizationOwnerID, "?group=story&size=100")
|
||||
expectIDs(t, "tail", body.storyIDs()[8:], 12, 11)
|
||||
})
|
||||
|
||||
t.Run("Counts of the Journal dataset, all languages", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story")
|
||||
expectCounts(t, body.Counts, map[string]int{"all": 8, "online": 7, "draft": 4, "suggestions": 0, "missing": 7})
|
||||
})
|
||||
|
||||
t.Run("Counts of the Journal dataset, French", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story&lang=fr")
|
||||
expectCounts(t, body.Counts, map[string]int{"all": 4, "online": 2, "draft": 2, "suggestions": 0, "missing": 4})
|
||||
})
|
||||
|
||||
t.Run("Counts follow the search", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story&q=zinc")
|
||||
expectCounts(t, body.Counts, map[string]int{"all": 0, "online": 0, "draft": 0, "suggestions": 0, "missing": 0})
|
||||
if len(body.Items) != 0 || body.Total != 0 {
|
||||
t.Fatal("expected nothing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Counts follow the author filter", func(t *testing.T) {
|
||||
env := base(t)
|
||||
_, body := env.get(localizationOwnerID, "?group=story&author="+itoa(env.authors["Marta Alves"]))
|
||||
expectIDs(t, "stories", body.storyIDs(), 1, 4, 7)
|
||||
if body.Total != 3 || body.Counts["all"] != 3 {
|
||||
t.Fatalf("total=%d counts=%v", body.Total, body.Counts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Counts do not move with status or tab", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story&lang=fr&status=draft&tab=missing")
|
||||
if body.Counts["all"] != 4 || body.Counts["online"] != 2 || body.Counts["draft"] != 2 {
|
||||
t.Fatalf("counts = %v", body.Counts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A reader's counts cover readable versions only", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationReaderID, "?group=story")
|
||||
if body.Counts["draft"] != 0 || body.Counts["all"] != 7 || body.Counts["missing"] != 0 {
|
||||
t.Fatalf("counts = %v", body.Counts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Total versus counts", func(t *testing.T) {
|
||||
_, body := base(t).get(localizationOwnerID, "?group=story&tab=missing")
|
||||
if body.Total != body.Counts["missing"] || body.Total != 7 {
|
||||
t.Fatalf("total=%d counts=%v", body.Total, body.Counts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Missing membership and counts agree with the missing_langs of the rows", func(t *testing.T) {
|
||||
env := fresh(t)
|
||||
env.addVersion(localizationVersion{id: 31, lang: "", online: true, title: "Journal 31", updated: localizationDay(2, 9), byline: "Luis Ferrer", noindex: true, storyKey: 31})
|
||||
env.addVersion(localizationVersion{id: 32, lang: "en", online: true, title: "Journal 32", updated: localizationDay(2, 8), byline: "Luis Ferrer", noindex: true, storyKey: 31})
|
||||
env.addVersion(localizationVersion{id: 33, lang: "", online: true, title: "Journal 33", updated: localizationDay(3, 9), byline: "Luis Ferrer", noindex: true, storyKey: 33})
|
||||
env.addVersion(localizationVersion{id: 34, lang: "fr", online: false, title: "Journal 34", updated: localizationDay(3, 8), byline: "Luis Ferrer", noindex: true, storyKey: 33})
|
||||
env.addVersion(localizationVersion{id: 35, lang: "", online: true, title: "Journal 35", updated: localizationDay(4, 9), byline: "Luis Ferrer", noindex: true, storyKey: 35})
|
||||
_, all := env.get(localizationOwnerID, "?group=story&size=100")
|
||||
_, missing := env.get(localizationOwnerID, "?group=story&tab=missing&size=100")
|
||||
wanted := []int{}
|
||||
for _, item := range all.Items {
|
||||
if len(stringsOf(item["missing_langs"])) > 0 {
|
||||
wanted = append(wanted, int(item["story_id"].(float64)))
|
||||
}
|
||||
}
|
||||
got := missing.storyIDs()
|
||||
sort.Ints(wanted)
|
||||
sort.Ints(got)
|
||||
expectIDs(t, "needs translation", got, wanted...)
|
||||
if missing.Total != len(wanted) || missing.Counts["missing"] != len(wanted) || all.Counts["missing"] != len(wanted) {
|
||||
t.Fatalf("total=%d counts=%v, want %d", missing.Total, missing.Counts, len(wanted))
|
||||
}
|
||||
for _, mixed := range []int{31, 33, 35} {
|
||||
if missing.item(mixed) != nil {
|
||||
t.Fatalf("story %d holding an unassigned version listed as needing translation", mixed)
|
||||
}
|
||||
}
|
||||
for _, tag := range []string{"fr", "de", "ar", "en"} {
|
||||
_, notIn := env.get(localizationOwnerID, "?group=story&lang="+tag+"&tab=missing&size=100")
|
||||
expected := []int{}
|
||||
for _, item := range all.Items {
|
||||
if listHasTag(stringsOf(item["missing_langs"]), tag) {
|
||||
expected = append(expected, int(item["story_id"].(float64)))
|
||||
}
|
||||
}
|
||||
listed := notIn.storyIDs()
|
||||
sort.Ints(expected)
|
||||
sort.Ints(listed)
|
||||
expectIDs(t, "not in "+tag, listed, expected...)
|
||||
if notIn.Total != len(expected) || notIn.Counts["missing"] != len(expected) {
|
||||
t.Fatalf("not in %s: total=%d counts=%v, want %d", tag, notIn.Total, notIn.Counts, len(expected))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The sites are read once per request", func(t *testing.T) {
|
||||
env := base(t)
|
||||
env.siteRepo.lists = 0
|
||||
_, body := env.get(localizationOwnerID, "?group=story&size=100")
|
||||
if len(body.Items) == 0 || env.siteRepo.lists != 1 {
|
||||
t.Fatalf("items=%d site lists=%d, want rows and 1", len(body.Items), env.siteRepo.lists)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("An expired token is not an editorial caller", func(t *testing.T) {
|
||||
env := base(t)
|
||||
expired, err := env.token.Encode(map[string]any{"user_id": localizationOwnerID, "token_version": 0, "exp": time.Now().Add(-time.Hour).Unix()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/article/?group=story", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: expired})
|
||||
rec := httptest.NewRecorder()
|
||||
env.router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest || env.repo.calls != 0 {
|
||||
t.Fatalf("expected 400 without repository call, got %d calls=%d", rec.Code, env.repo.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The grouped envelope has the documented keys and never a null list", func(t *testing.T) {
|
||||
env := base(t)
|
||||
rec, _ := env.get(localizationOwnerID, "?group=story&lang=fr&q=zzzznomatch")
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(raw) != 5 || string(raw["items"]) != "[]" {
|
||||
t.Fatalf("unexpected empty envelope: %s", rec.Body.String())
|
||||
}
|
||||
var counts map[string]int
|
||||
if err := json.Unmarshal(raw["counts"], &counts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"all", "online", "draft", "suggestions", "missing"} {
|
||||
if _, ok := counts[key]; !ok {
|
||||
t.Fatalf("counts lack %s: %v", key, counts)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Repository failure answers 500", func(t *testing.T) {
|
||||
for _, method := range []string{"FindStoryPage", "CountStories", "FindPage"} {
|
||||
env := base(t)
|
||||
env.repo.failMethod = method
|
||||
rec, _ := env.get(localizationOwnerID, "?group=story")
|
||||
if rec.Code != http.StatusInternalServerError || rec.Body.String() != failedToRetrieveArticles {
|
||||
t.Fatalf("%s: %d %q", method, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func itoa(value uint32) string {
|
||||
return strconv.FormatUint(uint64(value), 10)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/article"
|
||||
"RenewCMS/api/pkg/access"
|
||||
"RenewCMS/api/pkg/authtoken"
|
||||
"RenewCMS/api/pkg/claims"
|
||||
domainArticle "RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/site"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
localizationOwnerID uint32 = 1
|
||||
localizationReaderID uint32 = 2
|
||||
localizationWriterID uint32 = 3
|
||||
localizationUnverifiedID uint32 = 4
|
||||
localizationOtherUserID uint32 = 9
|
||||
localizationMalformed uint32 = 999
|
||||
)
|
||||
|
||||
type localizationVersion struct {
|
||||
id uint32
|
||||
lang string
|
||||
online bool
|
||||
title string
|
||||
creator uint32
|
||||
updated time.Time
|
||||
byline string
|
||||
siteKey string
|
||||
noindex bool
|
||||
storyKey uint32
|
||||
}
|
||||
|
||||
type storyListingRepository struct {
|
||||
*persistence.ArticleRepository
|
||||
calls int
|
||||
failMethod string
|
||||
}
|
||||
|
||||
func (r *storyListingRepository) failure(method string) error {
|
||||
r.calls++
|
||||
if r.failMethod == method {
|
||||
return errRepositoryFailure
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *storyListingRepository) FindPage(filters domainArticle.Filters) (domainArticle.PageResult, error) {
|
||||
if err := r.failure("FindPage"); err != nil {
|
||||
return domainArticle.PageResult{}, err
|
||||
}
|
||||
return r.ArticleRepository.FindPage(filters)
|
||||
}
|
||||
|
||||
func (r *storyListingRepository) CountVersions(filters domainArticle.Filters) (domainArticle.Counts, error) {
|
||||
if err := r.failure("CountVersions"); err != nil {
|
||||
return domainArticle.Counts{}, err
|
||||
}
|
||||
return r.ArticleRepository.CountVersions(filters)
|
||||
}
|
||||
|
||||
func (r *storyListingRepository) FindStoryPage(filters domainArticle.StoryFilters) (domainArticle.StoryPage, error) {
|
||||
if err := r.failure("FindStoryPage"); err != nil {
|
||||
return domainArticle.StoryPage{}, err
|
||||
}
|
||||
return r.ArticleRepository.FindStoryPage(filters)
|
||||
}
|
||||
|
||||
func (r *storyListingRepository) CountStories(filters domainArticle.StoryFilters) (domainArticle.StoryCounts, error) {
|
||||
if err := r.failure("CountStories"); err != nil {
|
||||
return domainArticle.StoryCounts{}, err
|
||||
}
|
||||
return r.ArticleRepository.CountStories(filters)
|
||||
}
|
||||
|
||||
type listCountingSites struct {
|
||||
*persistence.SiteRepository
|
||||
lists int
|
||||
}
|
||||
|
||||
func (r *listCountingSites) List() ([]site.Site, error) {
|
||||
r.lists++
|
||||
return r.SiteRepository.List()
|
||||
}
|
||||
|
||||
type localizationListEnv struct {
|
||||
siteRepo *listCountingSites
|
||||
t *testing.T
|
||||
db *gorm.DB
|
||||
repo *storyListingRepository
|
||||
router *chi.Mux
|
||||
token *authtoken.Auth
|
||||
authors map[string]uint32
|
||||
sites map[string]uint32
|
||||
nextID uint32
|
||||
}
|
||||
|
||||
func localizationGrants(userID uint32) (role.GrantSet, bool) {
|
||||
switch userID {
|
||||
case localizationOwnerID:
|
||||
return role.Role{Grants: []role.Grant{
|
||||
{Key: role.KeyContentRead, Scope: role.ScopeAll},
|
||||
{Key: role.KeyContentCreate, Scope: role.ScopeAll},
|
||||
{Key: role.KeyContentUpdate, Scope: role.ScopeAll},
|
||||
{Key: role.KeyContentPublish, Scope: role.ScopeAll},
|
||||
{Key: role.KeyContentDelete, Scope: role.ScopeAll},
|
||||
}}.GrantSet(), true
|
||||
case localizationReaderID:
|
||||
return role.Role{}.GrantSet(), true
|
||||
case localizationWriterID:
|
||||
return role.Role{Grants: []role.Grant{
|
||||
{Key: role.KeyContentRead, Scope: role.ScopeOwn},
|
||||
{Key: role.KeyContentCreate, Scope: role.ScopeOwn},
|
||||
}}.GrantSet(), true
|
||||
}
|
||||
return role.GrantSet{}, false
|
||||
}
|
||||
|
||||
func localizationAccess(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if userID, ok := claims.UserId(r); ok {
|
||||
if grants, known := localizationGrants(userID); known {
|
||||
r = r.WithContext(access.WithCaller(r.Context(), access.Caller{UserID: userID, Grants: grants}))
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func newLocalizationListEnv(t *testing.T, open func(*testing.T) *gorm.DB) *localizationListEnv {
|
||||
t.Helper()
|
||||
db := open(t)
|
||||
repo := &storyListingRepository{ArticleRepository: persistence.NewArticleRepository(db)}
|
||||
siteRepo := &listCountingSites{SiteRepository: persistence.NewSiteRepository(db)}
|
||||
list := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, siteRepo, persistence.NewLocalizationRepository(db))
|
||||
handler := article.NewHandler(nil, nil, list, nil, nil, nil, nil)
|
||||
token := authtoken.New([]byte("test-secret"))
|
||||
router := chi.NewRouter()
|
||||
router.Route("/v1", func(r chi.Router) {
|
||||
r.Route("/article", func(r chi.Router) {
|
||||
r.Use(authtoken.Verifier(token))
|
||||
r.Use(localizationAccess)
|
||||
r.Get("/", handler.ListArticles)
|
||||
})
|
||||
})
|
||||
env := &localizationListEnv{siteRepo: siteRepo, t: t, db: db, repo: repo, router: router, token: token, authors: map[string]uint32{}, sites: map[string]uint32{}}
|
||||
env.seedJournal()
|
||||
return env
|
||||
}
|
||||
|
||||
func (e *localizationListEnv) must(err error) {
|
||||
e.t.Helper()
|
||||
if err != nil {
|
||||
e.t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *localizationListEnv) addSite(key string, defaultLang string, enabled []string, disabled []string) {
|
||||
e.t.Helper()
|
||||
site := entity.Site{Name: key, Key: key, BaseURL: "https://" + key + ".example.com", DefaultRobots: "index"}
|
||||
e.must(e.db.Create(&site).Error)
|
||||
e.sites[key] = site.ID
|
||||
for _, tag := range append(append([]string{}, enabled...), disabled...) {
|
||||
row := entity.SiteLanguage{SiteID: site.ID, Lang: tag, IsDefault: tag == defaultLang, Confirmed: true}
|
||||
e.must(e.db.Create(&row).Error)
|
||||
e.must(e.db.Model(&entity.SiteLanguage{}).Where("site_id = ? AND lang = ?", site.ID, tag).UpdateColumn("enabled", !listHasTag(disabled, tag)).Error)
|
||||
}
|
||||
}
|
||||
|
||||
func listHasTag(tags []string, tag string) bool {
|
||||
for _, candidate := range tags {
|
||||
if candidate == tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *localizationListEnv) addAuthor(name string) {
|
||||
e.t.Helper()
|
||||
row := entity.Author{Name: name, Slug: fmt.Sprintf("author-%d", len(e.authors)+1), SocialLinks: "[]"}
|
||||
e.must(e.db.Create(&row).Error)
|
||||
e.authors[name] = row.ID
|
||||
}
|
||||
|
||||
func (e *localizationListEnv) addVersion(version localizationVersion) {
|
||||
e.t.Helper()
|
||||
siteKey := version.siteKey
|
||||
if siteKey == "" {
|
||||
siteKey = "journal"
|
||||
}
|
||||
creator := version.creator
|
||||
if creator == 0 {
|
||||
creator = localizationOwnerID
|
||||
}
|
||||
var story entity.Story
|
||||
if err := e.db.First(&story, version.storyKey).Error; err != nil {
|
||||
e.must(e.db.Create(&entity.Story{ID: version.storyKey}).Error)
|
||||
}
|
||||
row := entity.Article{
|
||||
ID: version.id, Title: version.title, Body: "<p>body</p>", IsOnline: version.online, SiteID: e.sites[siteKey], CreatedBy: creator,
|
||||
Slug: fmt.Sprintf("a-%d", version.id), Noindex: version.noindex, Lang: version.lang, StoryID: version.storyKey,
|
||||
CreatedAt: version.updated.Add(-24 * time.Hour), UpdatedAt: version.updated, ContentRevision: 1, EditRevision: 1,
|
||||
}
|
||||
e.must(e.db.Create(&row).Error)
|
||||
if version.byline != "" {
|
||||
e.must(e.db.Create(&entity.ArticleAuthor{ArticleID: version.id, AuthorID: e.authors[version.byline], Position: 0}).Error)
|
||||
}
|
||||
}
|
||||
|
||||
func localizationDay(day int, hour int) time.Time {
|
||||
return time.Date(2026, time.October, day, hour, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func (e *localizationListEnv) seedJournal() {
|
||||
e.addSite("journal", "en", []string{"en", "fr", "de", "ar"}, []string{"es", "it"})
|
||||
e.addSite("press", "en", []string{"en", "fr"}, nil)
|
||||
e.addSite("atelier", "en", []string{"en", "en-GB", "fr"}, nil)
|
||||
for _, name := range []string{"Marta Alves", "Camille Roche", "Luis Ferrer"} {
|
||||
e.addAuthor(name)
|
||||
}
|
||||
type journalVersion struct {
|
||||
id uint32
|
||||
lang string
|
||||
online bool
|
||||
}
|
||||
stories := []struct {
|
||||
id uint32
|
||||
byline string
|
||||
latest time.Time
|
||||
latestID uint32
|
||||
versions []journalVersion
|
||||
}{
|
||||
{1, "Marta Alves", localizationDay(30, 10), 102, []journalVersion{{101, "en", true}, {102, "fr", false}, {103, "de", true}, {104, "ar", false}}},
|
||||
{2, "Camille Roche", localizationDay(29, 9), 202, []journalVersion{{201, "en", true}, {202, "fr", true}, {203, "de", true}}},
|
||||
{3, "Luis Ferrer", localizationDay(28, 9), 302, []journalVersion{{301, "en", true}, {302, "fr", true}}},
|
||||
{4, "Marta Alves", localizationDay(27, 9), 401, []journalVersion{{401, "en", false}}},
|
||||
{5, "Luis Ferrer", localizationDay(14, 9), 502, []journalVersion{{501, "en", true}, {502, "de", false}}},
|
||||
{6, "Camille Roche", localizationDay(13, 9), 602, []journalVersion{{601, "en", true}, {602, "fr", false}}},
|
||||
{7, "Marta Alves", localizationDay(12, 9), 702, []journalVersion{{701, "en", true}, {702, "de", true}}},
|
||||
{8, "Luis Ferrer", localizationDay(10, 9), 801, []journalVersion{{801, "es", true}}},
|
||||
}
|
||||
for _, story := range stories {
|
||||
for _, version := range story.versions {
|
||||
updated := story.latest
|
||||
if version.id != story.latestID {
|
||||
updated = story.latest.Add(-time.Hour)
|
||||
}
|
||||
e.addVersion(localizationVersion{id: version.id, lang: version.lang, online: version.online, title: fmt.Sprintf("Journal %d", version.id), updated: updated, byline: story.byline, noindex: true, storyKey: story.id})
|
||||
}
|
||||
}
|
||||
e.must(e.db.Model(&entity.Article{}).Where("id = ?", 101).UpdateColumn("title", "How we price a bathroom renovation").Error)
|
||||
e.must(e.db.Model(&entity.Article{}).Where("id = ?", 102).UpdateColumn("title", "Comment nous chiffrons une rénovation").Error)
|
||||
e.must(e.db.Model(&entity.Article{}).Where("id = ?", 401).UpdateColumn("title", "Spring maintenance checklist").Error)
|
||||
e.nextID = 900
|
||||
}
|
||||
|
||||
type storyListBody struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
Counts map[string]int `json:"counts"`
|
||||
}
|
||||
|
||||
func (e *localizationListEnv) get(userID uint32, query string) (*httptest.ResponseRecorder, storyListBody) {
|
||||
e.t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/article/"+query, nil)
|
||||
switch userID {
|
||||
case 0:
|
||||
case localizationMalformed:
|
||||
req.AddCookie(&http.Cookie{Name: "jwt", Value: "garbage"})
|
||||
default:
|
||||
setJwtCookie(e.t, req, e.token, userID)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.router.ServeHTTP(rec, req)
|
||||
var body storyListBody
|
||||
if rec.Code == http.StatusOK {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
e.t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
}
|
||||
return rec, body
|
||||
}
|
||||
|
||||
func (b storyListBody) storyIDs() []int {
|
||||
ids := []int{}
|
||||
for _, item := range b.Items {
|
||||
ids = append(ids, int(item["story_id"].(float64)))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (b storyListBody) articleIDs() []int {
|
||||
ids := []int{}
|
||||
for _, item := range b.Items {
|
||||
ids = append(ids, int(item["id"].(float64)))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (b storyListBody) item(storyID int) map[string]any {
|
||||
for _, item := range b.Items {
|
||||
if int(item["story_id"].(float64)) == storyID {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringsOf(value any) []string {
|
||||
listed, _ := value.([]any)
|
||||
out := []string{}
|
||||
for _, entry := range listed {
|
||||
out = append(out, entry.(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func coverageLangs(item map[string]any) []string {
|
||||
langs := []string{}
|
||||
for _, entry := range item["coverage"].([]any) {
|
||||
langs = append(langs, entry.(map[string]any)["lang"].(string))
|
||||
}
|
||||
return langs
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
)
|
||||
|
||||
func TestListStoriesPageSuggestionCounts(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
articles []article.Article
|
||||
want int
|
||||
}{
|
||||
{"versions of one story count once", []article.Article{
|
||||
{ID: 1, StoryID: 10, SiteID: 1, IsOnline: true},
|
||||
{ID: 2, StoryID: 10, SiteID: 1, IsOnline: true},
|
||||
}, 1},
|
||||
{"stories without an id count individually", []article.Article{
|
||||
{ID: 1, StoryID: 0, SiteID: 1, IsOnline: true},
|
||||
{ID: 2, StoryID: 0, SiteID: 1, IsOnline: true},
|
||||
{ID: 3, StoryID: 10, SiteID: 1, IsOnline: true},
|
||||
}, 3},
|
||||
{"offline versions are not visible to a reader", []article.Article{
|
||||
{ID: 1, StoryID: 10, SiteID: 1, IsOnline: false},
|
||||
}, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
for _, a := range c.articles {
|
||||
repo.articles[a.ID] = a
|
||||
}
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites(), newFakeLocalizationStore(repo))
|
||||
page, err := uc.ListStoriesPage(useCases.ListStoriesCommand{Page: 1, Size: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if page.Counts.Suggestions != c.want {
|
||||
t.Fatalf("suggestions = %d, want %d", page.Counts.Suggestions, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListArticlesPageSEOFilterAndSuggestionCount(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
for id := uint32(1); id <= 3; id++ {
|
||||
repo.articles[id] = article.Article{ID: id, StoryID: id, SiteID: 1, IsOnline: true}
|
||||
}
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites(), plainLocalization())
|
||||
page, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Page: 2, Size: 2, SEO: article.SEOStatusSuggestions})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if page.Total != 3 || len(page.Items) != 1 || page.Counts.Suggestions != 3 {
|
||||
t.Fatalf("total=%d items=%d suggestions=%d, want 3/1/3", page.Total, len(page.Items), page.Counts.Suggestions)
|
||||
}
|
||||
beyond, _ := uc.ListArticlesPage(useCases.ListArticlesCommand{Page: 3, Size: 2, SEO: article.SEOStatusSuggestions})
|
||||
if len(beyond.Items) != 0 || beyond.Total != 3 {
|
||||
t.Fatalf("beyond: items=%d total=%d, want 0/3", len(beyond.Items), beyond.Total)
|
||||
}
|
||||
none, _ := uc.ListArticlesPage(useCases.ListArticlesCommand{Page: 1, Size: 2, SEO: article.SEOStatusReady})
|
||||
if none.Total != 0 || len(none.Items) != 0 {
|
||||
t.Fatalf("ready: total=%d items=%d, want 0/0", none.Total, len(none.Items))
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func newArticlePublishingRouter(articleRepo *mockArticleRepo, token *authtoken.A
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites(), plainLocalization()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
|
||||
@@ -37,6 +37,7 @@ type mockArticleRepo struct {
|
||||
beforeMove func()
|
||||
onGet func(id uint32) error
|
||||
reverseStory bool
|
||||
listCalls int
|
||||
}
|
||||
|
||||
func newMockArticleRepo() *mockArticleRepo {
|
||||
@@ -104,6 +105,7 @@ func (m *mockArticleRepo) FindByFilters(filters article.Filters) ([]article.Arti
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult, error) {
|
||||
m.listCalls++
|
||||
if err := m.fail.trigger("FindPage"); err != nil {
|
||||
return article.PageResult{}, err
|
||||
}
|
||||
@@ -124,6 +126,12 @@ func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult,
|
||||
if filters.Status == article.StatusDraft && a.IsOnline {
|
||||
continue
|
||||
}
|
||||
if filters.Lang != nil && a.Lang != *filters.Lang {
|
||||
continue
|
||||
}
|
||||
if filters.AuthorID != nil && !slices.Contains(m.credits[a.ID], *filters.AuthorID) {
|
||||
continue
|
||||
}
|
||||
if filters.Q != "" && !strings.Contains(foldArticleSearch(a.Title), foldArticleSearch(filters.Q)) {
|
||||
continue
|
||||
}
|
||||
@@ -158,6 +166,41 @@ func (m *mockArticleRepo) CountByVisibility(onlineOnly bool) (article.Counts, er
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) CountVersions(filters article.Filters) (article.Counts, error) {
|
||||
m.listCalls++
|
||||
if err := m.fail.trigger("CountVersions"); err != nil {
|
||||
return article.Counts{}, err
|
||||
}
|
||||
counts := article.Counts{}
|
||||
for _, a := range m.articles {
|
||||
if filters.Lang != nil && a.Lang != *filters.Lang {
|
||||
continue
|
||||
}
|
||||
if a.IsOnline {
|
||||
counts.Online++
|
||||
} else if filters.IsOnline == nil && (filters.VisibleTo == nil || a.CreatedBy == *filters.VisibleTo) {
|
||||
counts.Draft++
|
||||
}
|
||||
}
|
||||
counts.All = counts.Online + counts.Draft
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) FindStoryPage(filters article.StoryFilters) (article.StoryPage, error) {
|
||||
m.listCalls++
|
||||
return article.StoryPage{Refs: []article.StoryRef{}}, m.fail.trigger("FindStoryPage")
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) CountStories(filters article.StoryFilters) (article.StoryCounts, error) {
|
||||
m.listCalls++
|
||||
return article.StoryCounts{}, m.fail.trigger("CountStories")
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) ListByStories(storyIDs []uint32) ([]article.Article, error) {
|
||||
m.listCalls++
|
||||
return []article.Article{}, m.fail.trigger("ListByStories")
|
||||
}
|
||||
|
||||
func (m *mockArticleRepo) CountVisibleTo(userID uint32) (article.Counts, error) {
|
||||
if err := m.fail.trigger("CountVisibleTo"); err != nil {
|
||||
return article.Counts{}, err
|
||||
|
||||
@@ -28,7 +28,7 @@ func newArticleTitleRulesRouter(repo *mockArticleRepo, token *authtoken.Auth) *c
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(repo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites(), plainLocalization()),
|
||||
useCases.NewDeleteArticleUseCase(repo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(repo), plainStoryMover(repo)),
|
||||
plainTranslation(repo),
|
||||
|
||||
@@ -22,7 +22,7 @@ func newArticlesListRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites(), plainLocalization()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
|
||||
@@ -578,7 +578,7 @@ func TestArticlesPaginationUseCase(t *testing.T) {
|
||||
t.Run("The typed sort reaches the repository and an absent sort keeps updated_at descending", func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
seedSortArticles(repo)
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites(), plainLocalization())
|
||||
sorted, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Caller: ownerCaller(1), Page: 1, Size: 25, Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}})
|
||||
if err != nil || sorted.Total != 3 || sorted.Counts.All != 3 {
|
||||
t.Fatalf("unexpected %v %+v", err, sorted)
|
||||
@@ -594,7 +594,7 @@ func TestArticlesPaginationUseCase(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
seedPaginationArticles(repo, 3, 0)
|
||||
repo.fail.failNextWith(method, sentinel)
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites(), plainLocalization())
|
||||
_, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Caller: ownerCaller(1), Page: 1, Size: 25})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("%s: expected sentinel, got %v", method, err)
|
||||
|
||||
@@ -26,7 +26,7 @@ func newEditArticleBodyRouter(articleRepo *mockArticleRepo, token *authtoken.Aut
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites(), plainLocalization()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
|
||||
@@ -69,7 +69,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
|
||||
Token: env.token,
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(env.articles), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites()),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}, plainSites(), plainLocalization()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), env.images, newFakeLocalizationStore(env.articles), plainStoryMover(env.articles)),
|
||||
CreateTranslationUseCase: plainTranslation(env.articles),
|
||||
DescribeArticleUseCase: plainDescribe(env.articles),
|
||||
|
||||
@@ -128,7 +128,7 @@ func newNotFoundErrorsRouter(articleRepo *mockArticleRepo, userRepo *mockUserRep
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites(), plainLocalization()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
|
||||
@@ -44,7 +44,7 @@ func newPublicArticleListingRouter(articleRepo *mockArticleRepo, token *authtoke
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites(), plainLocalization()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
|
||||
@@ -26,7 +26,7 @@ func newPublicArticleReadRouter(articleRepo *mockArticleRepo, userRepo *mockUser
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites(), plainLocalization()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
|
||||
@@ -191,7 +191,7 @@ func TestContentScopeArticleUseCases(t *testing.T) {
|
||||
|
||||
t.Run("ListArticlesPage returns the visible set with matching counts", func(t *testing.T) {
|
||||
repo, sites, _, _ := newRepos()
|
||||
list := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, sites)
|
||||
list := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, sites, plainLocalization())
|
||||
cases := []struct {
|
||||
name string
|
||||
caller useCases.ContentCaller
|
||||
@@ -530,7 +530,7 @@ func TestContentScopeListArticlesSiteLookupFailure(t *testing.T) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
sites := plainSites()
|
||||
sites.fail.failNextWith("GetByKey", boom)
|
||||
list := useCases.NewListArticlesUseCase(newMockArticleRepo(), &fakeBodyRenderer{}, sites)
|
||||
list := useCases.NewListArticlesUseCase(newMockArticleRepo(), &fakeBodyRenderer{}, sites, plainLocalization())
|
||||
_, err := list.ListArticlesPage(useCases.ListArticlesCommand{Page: 1, Size: 10, SiteKey: &key, Caller: useCases.ContentCaller{UserID: 9, Grants: grants}})
|
||||
if !errors.Is(err, boom) {
|
||||
t.Fatalf("expected the site lookup error, got %v", err)
|
||||
|
||||
@@ -132,7 +132,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
|
||||
Token: token,
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites(), plainLocalization()),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
CreateTranslationUseCase: plainTranslation(articleRepo),
|
||||
DescribeArticleUseCase: plainDescribe(articleRepo),
|
||||
|
||||
@@ -59,7 +59,7 @@ func newSeoEnv(t *testing.T) *seoEnv {
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images, newFakeLocalizationStore(p.articles), plainStoryMover(p.articles))
|
||||
c.CreateTranslationUseCase = plainTranslation(p.articles)
|
||||
c.DescribeArticleUseCase = plainDescribe(p.articles)
|
||||
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
|
||||
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites, plainLocalization())
|
||||
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
|
||||
c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestSeoFilterPerformance(t *testing.T) {
|
||||
if err := db.CreateInBatches(&rows, 100).Error; err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
list := useCases.NewListArticlesUseCase(persistence.NewArticleRepository(db), &fakeBodyRenderer{}, persistence.NewSiteRepository(db))
|
||||
list := useCases.NewListArticlesUseCase(persistence.NewArticleRepository(db), &fakeBodyRenderer{}, persistence.NewSiteRepository(db), plainLocalization())
|
||||
|
||||
filtered := useCases.ListArticlesCommand{Caller: ownerCaller(1), Page: 1, Size: 25, SEO: article.SEOStatusSuggestions, Sort: article.DefaultSort()}
|
||||
unfiltered := useCases.ListArticlesCommand{Caller: ownerCaller(1), Page: 1, Size: 25, Sort: article.DefaultSort()}
|
||||
|
||||
@@ -132,7 +132,7 @@ func newSeoOutEnv(t *testing.T) *seoOutEnv {
|
||||
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(counted, e.sites, e.images)
|
||||
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(counted, e.sites, e.images)
|
||||
c.ListSitemapUseCase = useCases.NewListSitemapUseCase(counted, e.sites)
|
||||
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(counted, &fakeBodyRenderer{}, e.sites)
|
||||
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(counted, &fakeBodyRenderer{}, e.sites, plainLocalization())
|
||||
router := chi.NewRouter()
|
||||
router.Mount("/v1", api.InitBackendRoutes(c, e.settings))
|
||||
e.router = router
|
||||
|
||||
@@ -330,7 +330,7 @@ func newSiteEnv(t *testing.T) *siteEnv {
|
||||
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images, newFakeLocalizationStore(p.articles), plainStoryMover(p.articles))
|
||||
c.CreateTranslationUseCase = plainTranslation(p.articles)
|
||||
c.DescribeArticleUseCase = plainDescribe(p.articles)
|
||||
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
|
||||
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites, plainLocalization())
|
||||
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
|
||||
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), p.articles)
|
||||
c.MergeMediaUseCase = mergeMedia
|
||||
@@ -418,6 +418,10 @@ func stringPointer(value string) *string { return &value }
|
||||
|
||||
func idPointer(value uint32) *uint32 { return &value }
|
||||
|
||||
func plainLocalization() *fakeLocalizationStore {
|
||||
return newFakeLocalizationStore(newMockArticleRepo())
|
||||
}
|
||||
|
||||
func plainSites() *mockSiteRepo {
|
||||
sites := newMockSiteRepo(newMockArticleRepo())
|
||||
sites.seed(1, "Default", "default", true)
|
||||
|
||||
@@ -25,7 +25,7 @@ func newStaleTokenInvalidationRouter(userRepo *mockUserRepo, articleRepo *mockAr
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites(), plainLocalization()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
|
||||
@@ -192,7 +192,7 @@ func (w *storyWorld) wire() {
|
||||
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(keys, nil, w.users, time.Now),
|
||||
CreateArticleUseCase: useCases.NewCreateArticleUseCase(w.transactor, renderer, w.recorder, w.sites, w.authors),
|
||||
GetArticleUseCase: useCases.NewGetArticleUseCase(w.articles, renderer),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(w.articles, renderer, w.sites),
|
||||
ListArticlesUseCase: useCases.NewListArticlesUseCase(w.articles, renderer, w.sites, plainLocalization()),
|
||||
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(w.articles, w.recorder),
|
||||
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(w.articles, renderer, w.recorder, w.sites, w.authors, w.images, w.rows, moveStory),
|
||||
CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(w.transactor, w.articles, w.rows, renderer, w.recorder),
|
||||
|
||||
@@ -199,7 +199,7 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
|
||||
|
||||
t.Run("ListArticlesPage unverified", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites(), plainLocalization())
|
||||
result, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Page: 1, Size: 25, Sort: article.Sort{Field: article.SortByTitle, Direction: article.SortAsc}})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -209,7 +209,7 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
|
||||
|
||||
t.Run("ListArticlesPage verified", func(t *testing.T) {
|
||||
repo := newRepo()
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites(), plainLocalization())
|
||||
result, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Caller: ownerCaller(1), Page: 1, Size: 25, Sort: article.Sort{Field: article.SortByTitle, Direction: article.SortAsc}})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
@@ -220,7 +220,7 @@ func TestListArticlesFiltersByIsOnline(t *testing.T) {
|
||||
|
||||
func TestListArticlesPageEmptyRepositoryReturnsEmptySlice(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites())
|
||||
uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites(), plainLocalization())
|
||||
|
||||
page, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: article.BodyFormatHTML, Caller: ownerCaller(1), Page: 1, Size: 25})
|
||||
if err != nil {
|
||||
|
||||
@@ -23,7 +23,7 @@ func newVerifiedAccountRequiredRouter(articleRepo *mockArticleRepo, userRepo *mo
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites(), plainLocalization()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
|
||||
@@ -215,7 +215,7 @@ func newDeleteArticleRouter(articleRepo *mockArticleRepo, token *authtoken.Auth)
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(passthroughCreateTransactor(articleRepo), &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors()),
|
||||
useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites()),
|
||||
useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}, plainSites(), plainLocalization()),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors(), newMockImageRepo(), newFakeLocalizationStore(articleRepo), plainStoryMover(articleRepo)),
|
||||
plainTranslation(articleRepo),
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { useRouter } from 'vue-router'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import SeoStatusChip from '@/components/SeoStatusChip.vue'
|
||||
import AuthorStack from '@/components/AuthorStack.vue'
|
||||
import { authorsSummary } from '@/lib/articleMeta'
|
||||
import { formatRelativeDate } from '@/lib/relativeDate'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { useArticleMenu } from '@/composables/useArticleMenu'
|
||||
import { seoChipOf } from '@/lib/articleSeoStatus'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
articles: Article[]
|
||||
loading: boolean
|
||||
skeletonRows?: number
|
||||
busyIds: Set<number>
|
||||
now?: Date
|
||||
}>(),
|
||||
{ skeletonRows: 5 },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
publish: [id: number]
|
||||
unpublish: [id: number]
|
||||
delete: [article: Article]
|
||||
'copy-url': [id: number]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function editPath(article: Article) {
|
||||
return `/article/${article.id}/edit`
|
||||
}
|
||||
|
||||
function isBusy(article: Article) {
|
||||
return props.busyIds.has(article.id)
|
||||
}
|
||||
|
||||
function pendingFor(article: Article) {
|
||||
if (!isBusy(article)) {
|
||||
return undefined
|
||||
}
|
||||
return article.is_online ? ('unpublish' as const) : ('publish' as const)
|
||||
}
|
||||
|
||||
const { actionsFor, publishHint } = useArticleMenu({
|
||||
isBusy,
|
||||
edit: (article) => router.push(editPath(article)),
|
||||
publish: (id) => emit('publish', id),
|
||||
unpublish: (id) => emit('unpublish', id),
|
||||
copyUrl: (id) => emit('copy-url', id),
|
||||
remove: (article) => emit('delete', article),
|
||||
})
|
||||
|
||||
function onRowClick(article: Article, event: MouseEvent) {
|
||||
if ((event.target as HTMLElement).closest('[data-row-menu]')) {
|
||||
return
|
||||
}
|
||||
router.push(editPath(article))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
class="-mx-4 border-y border-default bg-default md:hidden"
|
||||
:aria-busy="loading ? 'true' : undefined"
|
||||
>
|
||||
<template v-if="loading">
|
||||
<li
|
||||
v-for="n in skeletonRows"
|
||||
:key="n"
|
||||
class="flex min-h-17 flex-col justify-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
|
||||
>
|
||||
<USkeleton class="h-3.5 w-3/4 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-2/5 rounded-none bg-accented" />
|
||||
</li>
|
||||
</template>
|
||||
<template v-else>
|
||||
<li v-if="articles.length === 0">
|
||||
<slot name="empty" />
|
||||
</li>
|
||||
<li
|
||||
v-for="article in articles"
|
||||
:key="article.id"
|
||||
class="relative flex min-h-17 items-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
|
||||
:class="{ 'opacity-55': isBusy(article) }"
|
||||
@click="onRowClick(article, $event)"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<RouterLink
|
||||
:to="editPath(article)"
|
||||
:data-row-title="article.id"
|
||||
class="truncate text-[0.9375rem]/[21px] font-medium text-highlighted"
|
||||
@click.stop
|
||||
>
|
||||
{{ article.title }}
|
||||
</RouterLink>
|
||||
<div class="flex items-center gap-2.5 text-xs text-muted">
|
||||
<ArticleStatusBadge
|
||||
:online="article.is_online"
|
||||
size="sm"
|
||||
:pending="pendingFor(article)"
|
||||
/>
|
||||
<span
|
||||
v-if="publishHint(article)"
|
||||
class="sr-only"
|
||||
data-testid="publish-hint"
|
||||
>{{ publishHint(article) }}</span
|
||||
>
|
||||
<SeoStatusChip
|
||||
v-if="seoChipOf(article)"
|
||||
:chip="seoChipOf(article)!"
|
||||
size="sm"
|
||||
/>
|
||||
<span :title="article.updated_at"
|
||||
>{{ t('Updated') }}
|
||||
{{ formatRelativeDate(article.updated_at, now) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2 text-xs text-muted">
|
||||
<AuthorStack
|
||||
v-if="article.authors?.length"
|
||||
:authors="article.authors"
|
||||
:size="22"
|
||||
/>
|
||||
<span class="truncate">{{ authorsSummary(article.authors ?? []) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<UDropdownMenu
|
||||
:items="actionsFor(article)"
|
||||
:content="{ align: 'end' }"
|
||||
data-row-menu
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="t('Actions for {0}', [article.title])"
|
||||
class="size-11 justify-center text-toned"
|
||||
@click.stop
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import ArticleFilterFields from '@/components/ArticleFilterFields.vue'
|
||||
import { useIsDesktop } from '@/composables/useIsDesktop'
|
||||
import type { ArticleFilterFieldProps } from '@/lib/articleFilterFields'
|
||||
import { languageName } from '@/lib/siteLanguages'
|
||||
import { locale } from '@/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: ArticleFilterFieldProps
|
||||
chipCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:site': [site: string]
|
||||
'update:lang': [lang: string]
|
||||
'update:status': [status: ArticleFilterFieldProps['status']]
|
||||
'update:author': [author: number]
|
||||
'update:seo': [on: boolean]
|
||||
'open-filters': []
|
||||
}>()
|
||||
|
||||
const search = defineModel<string>('search', { required: true })
|
||||
|
||||
const desktop = useIsDesktop()
|
||||
|
||||
const placeholder = computed(() =>
|
||||
props.fields.lang
|
||||
? t('Search {0} titles', [languageName(props.fields.lang, locale.value)])
|
||||
: t('Search titles in every language'),
|
||||
)
|
||||
const filtersLabel = computed(() =>
|
||||
props.chipCount ? t('Filters · {0}', [props.chipCount]) : t('Filters'),
|
||||
)
|
||||
const filtersAria = computed(() =>
|
||||
props.chipCount ? t('Filters, {0} active', [props.chipCount]) : t('Filters'),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UInput
|
||||
v-model="search"
|
||||
type="search"
|
||||
:placeholder="placeholder"
|
||||
:aria-label="placeholder"
|
||||
class="min-w-0 flex-1 md:min-w-50"
|
||||
:ui="{
|
||||
base: 'h-11 rounded-none text-base ring-0 border border-accented bg-default md:h-8 md:text-sm',
|
||||
}"
|
||||
/>
|
||||
<template v-if="desktop">
|
||||
<ArticleFilterFields
|
||||
v-bind="fields"
|
||||
@update:site="emit('update:site', $event)"
|
||||
@update:lang="emit('update:lang', $event)"
|
||||
@update:status="emit('update:status', $event)"
|
||||
@update:author="emit('update:author', $event)"
|
||||
@update:seo="emit('update:seo', $event)"
|
||||
/>
|
||||
</template>
|
||||
<UButton
|
||||
v-else
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-sliders-horizontal"
|
||||
:aria-label="filtersAria"
|
||||
class="h-11 shrink-0 border border-accented bg-default px-3 text-highlighted ring-0"
|
||||
@click="emit('open-filters')"
|
||||
>
|
||||
{{ filtersLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import type { FilterChip, FilterChipKey } from '@/lib/articleFilterChips'
|
||||
|
||||
defineProps<{ chips: FilterChip[] }>()
|
||||
|
||||
const emit = defineEmits<{ remove: [key: FilterChipKey]; clear: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="chips.length"
|
||||
data-testid="article-filter-chips"
|
||||
class="flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<UBadge
|
||||
v-for="chip in chips"
|
||||
:key="chip.key"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
data-testid="article-filter-chip"
|
||||
:ui="{
|
||||
base: 'h-9 gap-1.5 rounded-none border border-accented bg-default py-0 ps-2 pe-1 text-xs font-normal text-highlighted ring-0 md:h-6',
|
||||
}"
|
||||
>
|
||||
{{ chip.label }}
|
||||
<template #trailing>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
:aria-label="t('Remove filter {0}', [chip.label])"
|
||||
class="size-7 justify-center rounded-none p-0 text-muted md:size-4"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="emit('remove', chip.key)"
|
||||
/>
|
||||
</template>
|
||||
</UBadge>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
class="h-11 px-3 text-sm font-medium text-toned md:h-7"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
{{ t('Clear all') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { locale, t, formatNumber } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import ArticleFilterSelect, { type FilterItem } from '@/components/ArticleFilterSelect.vue'
|
||||
import { SEO_FILTER, type ArticleStatus } from '@/lib/articleListQuery'
|
||||
import type { ArticleFilterFieldProps } from '@/lib/articleFilterFields'
|
||||
import { languageName, type SiteLanguage } from '@/lib/siteLanguages'
|
||||
|
||||
const ANY = 'any'
|
||||
const SITE_PREFIX = 'site:'
|
||||
|
||||
const props = defineProps<
|
||||
ArticleFilterFieldProps & {
|
||||
labeled?: boolean
|
||||
}
|
||||
>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:site': [site: string]
|
||||
'update:lang': [lang: string]
|
||||
'update:status': [status: '' | ArticleStatus]
|
||||
'update:author': [author: number]
|
||||
'update:seo': [on: boolean]
|
||||
}>()
|
||||
|
||||
const siteItems = computed<FilterItem[]>(() => [
|
||||
{ label: t('All sites'), value: ANY },
|
||||
...props.sites.map((site) => ({ label: site.name, value: SITE_PREFIX + site.key })),
|
||||
])
|
||||
|
||||
const languageItems = computed<FilterItem[]>(() => {
|
||||
const row = (language: SiteLanguage): FilterItem => ({
|
||||
label: languageName(language.lang, locale.value),
|
||||
value: language.lang,
|
||||
trailing:
|
||||
language.version_count === 1
|
||||
? t('1 version')
|
||||
: t('{0} versions', [formatNumber(language.version_count)]),
|
||||
})
|
||||
const enabled = props.languages.filter((language) => language.enabled)
|
||||
const disabled = props.languages.filter((language) => !language.enabled)
|
||||
return [
|
||||
{ type: 'label', label: t('Enabled') },
|
||||
{ label: t('All languages'), value: ANY },
|
||||
...enabled.map(row),
|
||||
...(disabled.length
|
||||
? [
|
||||
{ type: 'separator' as const },
|
||||
{ type: 'label' as const, label: t('Disabled') },
|
||||
...disabled.map(row),
|
||||
{
|
||||
type: 'label' as const,
|
||||
label: t(
|
||||
'Disabled languages keep their versions. They can’t get new ones.',
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
})
|
||||
|
||||
const statusItems = computed<FilterItem[]>(() => [
|
||||
{ label: t('Any status'), value: ANY },
|
||||
{ label: t('Published'), value: 'online' },
|
||||
{ label: t('Draft'), value: 'draft' },
|
||||
])
|
||||
|
||||
const authorItems = computed<FilterItem[]>(() => [
|
||||
{ label: t('Any author'), value: ANY },
|
||||
...props.authors.map((author) => ({
|
||||
label: author.name,
|
||||
value: String(author.id),
|
||||
trailing: formatNumber(author.article_count),
|
||||
})),
|
||||
])
|
||||
|
||||
const seoItems = computed<FilterItem[]>(() => [
|
||||
{ label: t('Any SEO state'), value: ANY },
|
||||
{
|
||||
label: t('Needs attention'),
|
||||
value: SEO_FILTER,
|
||||
trailing: props.suggestions === null ? undefined : formatNumber(props.suggestions),
|
||||
},
|
||||
])
|
||||
|
||||
const siteModel = computed({
|
||||
get: () => (props.site ? SITE_PREFIX + props.site : ANY),
|
||||
set: (value: string) =>
|
||||
emit('update:site', value === ANY ? '' : value.slice(SITE_PREFIX.length)),
|
||||
})
|
||||
const langModel = computed({
|
||||
get: () => props.lang || ANY,
|
||||
set: (value: string) => emit('update:lang', value === ANY ? '' : value),
|
||||
})
|
||||
const statusModel = computed({
|
||||
get: () => props.status || ANY,
|
||||
set: (value: string) => emit('update:status', value === ANY ? '' : (value as ArticleStatus)),
|
||||
})
|
||||
const authorModel = computed({
|
||||
get: () => (props.author ? String(props.author) : ANY),
|
||||
set: (value: string) => emit('update:author', value === ANY ? 0 : Number(value)),
|
||||
})
|
||||
const seoModel = computed({
|
||||
get: () => props.seo || ANY,
|
||||
set: (value: string) => emit('update:seo', value !== ANY),
|
||||
})
|
||||
|
||||
const WRAPPER = computed(() =>
|
||||
props.labeled ? 'flex flex-col gap-1.5' : 'w-full md:w-37.5 md:flex-none',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="severalSites" :class="WRAPPER">
|
||||
<span v-if="labeled" class="font-medium text-highlighted">{{ t('Site') }}</span>
|
||||
<ArticleFilterSelect
|
||||
v-model="siteModel"
|
||||
:items="siteItems"
|
||||
:label="t('Site')"
|
||||
:active="site !== ''"
|
||||
/>
|
||||
</div>
|
||||
<div :class="WRAPPER">
|
||||
<span v-if="labeled" class="font-medium text-highlighted">{{ t('Language') }}</span>
|
||||
<ArticleFilterSelect
|
||||
v-model="langModel"
|
||||
:items="languageItems"
|
||||
:label="t('Language')"
|
||||
:active="lang !== ''"
|
||||
/>
|
||||
</div>
|
||||
<div :class="WRAPPER">
|
||||
<span v-if="labeled" class="font-medium text-highlighted">{{ t('Status') }}</span>
|
||||
<ArticleFilterSelect
|
||||
v-model="statusModel"
|
||||
:items="statusItems"
|
||||
:label="t('Status')"
|
||||
:active="status !== ''"
|
||||
/>
|
||||
</div>
|
||||
<div :class="WRAPPER">
|
||||
<span v-if="labeled" class="font-medium text-highlighted">{{ t('Author') }}</span>
|
||||
<ArticleFilterSelect
|
||||
v-model="authorModel"
|
||||
:items="authorItems"
|
||||
:label="t('Author')"
|
||||
:active="author !== 0"
|
||||
/>
|
||||
</div>
|
||||
<div :class="WRAPPER">
|
||||
<span v-if="labeled" class="font-medium text-highlighted">{{ t('SEO') }}</span>
|
||||
<ArticleFilterSelect
|
||||
v-model="seoModel"
|
||||
:items="seoItems"
|
||||
:label="t('SEO')"
|
||||
:active="seo !== ''"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
export interface FilterOption {
|
||||
label: string
|
||||
value: string
|
||||
trailing?: string
|
||||
muted?: boolean
|
||||
}
|
||||
|
||||
export type FilterItem = FilterOption | { type: 'label'; label: string } | { type: 'separator' }
|
||||
|
||||
defineProps<{
|
||||
items: FilterItem[]
|
||||
label: string
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USelectMenu
|
||||
v-model="model"
|
||||
:items="items"
|
||||
value-key="value"
|
||||
:search-input="false"
|
||||
trailing-icon="i-lucide-chevrons-up-down"
|
||||
:aria-label="label"
|
||||
class="w-full"
|
||||
:ui="{
|
||||
base: `h-11 rounded-none text-base ring-0 border border-accented bg-default md:h-8 md:text-sm ${active ? 'text-highlighted' : 'text-muted'}`,
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content: 'min-w-55 rounded-none shadow-(--shadow-popover)',
|
||||
item: 'min-h-11 items-center px-3 py-0 data-[state=checked]:bg-(--rc-sand) data-[state=checked]:text-(--rc-on-sand) md:min-h-9',
|
||||
label: 'px-3 py-1 text-xs font-semibold uppercase tracking-wide text-muted',
|
||||
}"
|
||||
>
|
||||
<template #item-trailing="{ item }">
|
||||
<span v-if="(item as FilterOption).trailing" class="font-mono text-xs text-muted">
|
||||
{{ (item as FilterOption).trailing }}
|
||||
</span>
|
||||
</template>
|
||||
</USelectMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { t, formatNumber } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import ArticleFilterFields from '@/components/ArticleFilterFields.vue'
|
||||
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
|
||||
import type { ArticleFilterFieldProps } from '@/lib/articleFilterFields'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: ArticleFilterFieldProps
|
||||
total: number
|
||||
chipCount: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:site': [site: string]
|
||||
'update:lang': [lang: string]
|
||||
'update:status': [status: ArticleFilterFieldProps['status']]
|
||||
'update:author': [author: number]
|
||||
'update:seo': [on: boolean]
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
const SHEET_CONTENT =
|
||||
'max-h-11/12 pt-3 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const showLabel = computed(() =>
|
||||
props.total === 1 ? t('Show 1 result') : t('Show {0} results', [formatNumber(props.total)]),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="t('Filters')"
|
||||
:description="t('Filters')"
|
||||
:ui="{ ...BOTTOM_SHEET_UI, content: SHEET_CONTENT }"
|
||||
>
|
||||
<template #close>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-x"
|
||||
:aria-label="t('Close filters')"
|
||||
class="size-11 justify-center"
|
||||
@click="open = false"
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
<div data-testid="article-filters-sheet" class="flex flex-col gap-3.5">
|
||||
<ArticleFilterFields
|
||||
labeled
|
||||
v-bind="fields"
|
||||
@update:site="emit('update:site', $event)"
|
||||
@update:lang="emit('update:lang', $event)"
|
||||
@update:status="emit('update:status', $event)"
|
||||
@update:author="emit('update:author', $event)"
|
||||
@update:seo="emit('update:seo', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full gap-2">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 justify-center border border-accented bg-default px-3 text-highlighted ring-0"
|
||||
@click="emit('clear')"
|
||||
>
|
||||
{{ t('Clear all') }}
|
||||
</UButton>
|
||||
<UButton class="h-11 flex-1 justify-center px-3" @click="open = false">
|
||||
{{ showLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { locale, t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { languageName } from '@/lib/siteLanguages'
|
||||
|
||||
const QUERY_MAX_LENGTH = 40
|
||||
|
||||
const props = defineProps<{
|
||||
variant: 'no-results' | 'nothing-missing' | 'no-matches'
|
||||
query?: string
|
||||
lang?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ action: [] }>()
|
||||
|
||||
const language = computed(() => (props.lang ? languageName(props.lang, locale.value) : ''))
|
||||
const content = computed(() => {
|
||||
if (props.variant === 'nothing-missing') {
|
||||
return {
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: language.value
|
||||
? t('Every article you can see has a {0} version', [language.value])
|
||||
: t('Every article you can see is fully translated'),
|
||||
description: language.value
|
||||
? t('Articles without a {0} version will appear here.', [language.value])
|
||||
: t('Articles that need a translation will appear here.'),
|
||||
action: undefined,
|
||||
}
|
||||
}
|
||||
if (props.variant === 'no-matches') {
|
||||
return {
|
||||
icon: 'i-lucide-search',
|
||||
title: t('No articles match these filters'),
|
||||
description: t('Remove a filter to see more articles.'),
|
||||
action: t('Clear all'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
icon: 'i-lucide-search',
|
||||
title: t('No articles match "{0}"', [(props.query ?? '').slice(0, QUERY_MAX_LENGTH)]),
|
||||
description: t('Search covers titles in every language you can read.'),
|
||||
action: t('Clear search'),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
:icon="content.icon"
|
||||
:title="content.title"
|
||||
:description="content.description"
|
||||
:ui="{
|
||||
root: 'gap-2 border border-default bg-default px-4 py-12',
|
||||
avatar: 'mb-0 size-7 rounded-none bg-transparent p-0 text-toned',
|
||||
icon: 'size-7',
|
||||
title: 'text-base font-semibold text-highlighted',
|
||||
description: 'max-w-90 text-base text-toned text-pretty',
|
||||
}"
|
||||
>
|
||||
<template v-if="content.action" #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-7"
|
||||
@click="emit('action')"
|
||||
>
|
||||
{{ content.action }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const emit = defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAlert
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
icon="i-lucide-circle-x"
|
||||
:close="false"
|
||||
:title="t('Couldn\'t load articles')"
|
||||
:description="t('Your filters and page are kept. Try again.')"
|
||||
:ui="{
|
||||
root: 'items-start gap-2.5 rounded-none px-3.5 py-3',
|
||||
title: 'font-semibold text-highlighted',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-7"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
{{ t('Try again') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const OPACITIES = ['opacity-100', 'opacity-88', 'opacity-76', 'opacity-64', 'opacity-52']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div aria-busy="true" class="flex flex-col gap-2.5" data-testid="article-list-skeleton">
|
||||
<span role="status" class="sr-only">{{ t('Loading articles') }}</span>
|
||||
<USkeleton
|
||||
v-for="(opacity, index) in OPACITIES"
|
||||
:key="index"
|
||||
class="h-14 rounded-none bg-elevated md:h-11"
|
||||
:class="opacity"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { t, formatNumber } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { useScrollEdges } from '@/composables/useScrollEdges'
|
||||
import type { ListTab, ListTabKey } from '@/lib/storyList'
|
||||
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
|
||||
|
||||
const SCROLL_STEP_PX = 96
|
||||
const EMPTY_COUNT = '–'
|
||||
|
||||
const props = defineProps<{
|
||||
tabs: ListTab[]
|
||||
active: ListTabKey
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ select: [key: ListTabKey] }>()
|
||||
|
||||
const scroller = ref<HTMLElement | null>(null)
|
||||
const { hiddenBefore, hiddenAfter } = useScrollEdges(scroller)
|
||||
|
||||
const model = computed({
|
||||
get: () => props.active,
|
||||
set: (value: ListTabKey) => emit('select', value),
|
||||
})
|
||||
|
||||
const items = computed(() =>
|
||||
props.tabs.map((tab) => ({
|
||||
label: tab.label,
|
||||
value: tab.key,
|
||||
count: tab.count === null ? EMPTY_COUNT : formatNumber(tab.count),
|
||||
})),
|
||||
)
|
||||
|
||||
function scroll(direction: -1 | 1) {
|
||||
scroller.value?.scrollBy({ left: direction * SCROLL_STEP_PX })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex items-center gap-1 border-b border-default">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-chevron-left"
|
||||
:aria-label="t('Scroll tabs left')"
|
||||
:disabled="!hiddenBefore"
|
||||
class="size-11 shrink-0 justify-center md:hidden"
|
||||
@click="scroll(-1)"
|
||||
/>
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<div
|
||||
ref="scroller"
|
||||
class="overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<UTabs
|
||||
v-model="model"
|
||||
:items="items"
|
||||
variant="link"
|
||||
color="neutral"
|
||||
:content="false"
|
||||
:aria-label="t('Article views')"
|
||||
class="w-max"
|
||||
:ui="{
|
||||
list: 'gap-4 border-b-0 p-0 md:gap-6',
|
||||
trigger: `${UNDERLINE_TAB_TRIGGER} shrink-0 whitespace-nowrap md:h-9.5 max-md:h-11 max-md:text-base`,
|
||||
}"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<span class="truncate">{{ item.label }}</span
|
||||
>{{ ' '
|
||||
}}<span class="font-normal text-muted">{{
|
||||
(item as (typeof items)[number]).count
|
||||
}}</span>
|
||||
</template>
|
||||
</UTabs>
|
||||
</div>
|
||||
<div
|
||||
v-if="hiddenAfter"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 right-0 w-7 bg-linear-to-r from-transparent to-(--ui-bg) md:hidden"
|
||||
/>
|
||||
<div
|
||||
v-if="hiddenBefore"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 w-7 bg-linear-to-l from-transparent to-(--ui-bg) md:hidden"
|
||||
/>
|
||||
</div>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-chevron-right"
|
||||
:aria-label="t('Scroll tabs right')"
|
||||
:disabled="!hiddenAfter"
|
||||
class="size-11 shrink-0 justify-center md:hidden"
|
||||
@click="scroll(1)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,98 +0,0 @@
|
||||
<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>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
|
||||
const ALL_SITES = 'all'
|
||||
const SITE_PREFIX = 'site:'
|
||||
|
||||
const props = defineProps<{ sites: Site[] }>()
|
||||
|
||||
const siteKey = defineModel<string>({ default: '' })
|
||||
|
||||
const items = computed(() => [
|
||||
{ label: t('All sites'), value: ALL_SITES },
|
||||
...props.sites.map((site) => ({
|
||||
label: site.name,
|
||||
value: SITE_PREFIX + site.key,
|
||||
})),
|
||||
])
|
||||
const selected = computed({
|
||||
get: () => (siteKey.value ? SITE_PREFIX + siteKey.value : ALL_SITES),
|
||||
set: (value: string) => {
|
||||
siteKey.value = value === ALL_SITES ? '' : value.slice(SITE_PREFIX.length)
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<USelectMenu
|
||||
v-model="selected"
|
||||
:items="items"
|
||||
value-key="value"
|
||||
icon="i-lucide-globe"
|
||||
:search-input="false"
|
||||
:aria-label="t('Site')"
|
||||
class="w-full md:w-37.5"
|
||||
:ui="{
|
||||
base: `h-11 rounded-none text-lg ring-(--ui-border-accented) md:h-8 md:text-sm ${siteKey ? 'text-highlighted' : 'text-muted'}`,
|
||||
leadingIcon: 'size-3.5',
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content: 'rounded-none shadow-(--shadow-popover)',
|
||||
item: 'min-h-11 px-2 py-0 data-[state=checked]:bg-elevated sm:min-h-8',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,381 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref, watchPostEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import SeoStatusChip from '@/components/SeoStatusChip.vue'
|
||||
import AuthorStack from '@/components/AuthorStack.vue'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { useArticleMenu } from '@/composables/useArticleMenu'
|
||||
import { seoChipOf } from '@/lib/articleSeoStatus'
|
||||
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
articles: Article[]
|
||||
loading: boolean
|
||||
skeletonRows?: number
|
||||
sort?: string
|
||||
busyIds: Set<number>
|
||||
sites?: Site[]
|
||||
now?: Date
|
||||
}>(),
|
||||
{ skeletonRows: 6, sort: DEFAULT_ARTICLE_SORT, sites: () => [] },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
publish: [id: number]
|
||||
unpublish: [id: number]
|
||||
delete: [article: Article]
|
||||
'copy-url': [id: number]
|
||||
sort: [field: ArticleSortField]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const SINGLE_SITE_WIDTHS =
|
||||
'[&:nth-child(2)]:w-26 [&:nth-child(3)]:w-27.5 [&:nth-child(4)]:w-32.5 [&:nth-child(5)]:w-35 [&:nth-child(6)]:w-11'
|
||||
const SEVERAL_SITES_WIDTHS =
|
||||
'[&:nth-child(2)]:w-26 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-27.5 [&:nth-child(5)]:w-32.5 [&:nth-child(6)]:w-35 [&:nth-child(7)]:w-11'
|
||||
|
||||
const rows = computed(() =>
|
||||
props.loading
|
||||
? Array.from(
|
||||
{ length: props.skeletonRows },
|
||||
(_, index) => ({ id: -(index + 1) }) as Article,
|
||||
)
|
||||
: props.articles,
|
||||
)
|
||||
|
||||
const showSite = computed(() => props.sites.length > 1)
|
||||
const columnWidths = computed(() => (showSite.value ? SEVERAL_SITES_WIDTHS : SINGLE_SITE_WIDTHS))
|
||||
|
||||
const sorting = computed(() => {
|
||||
const [id, direction] = props.sort.split(':')
|
||||
return [{ id: id!, desc: direction === 'desc' }]
|
||||
})
|
||||
|
||||
const tableRoot = ref<HTMLElement | null>(null)
|
||||
const SORTABLE_COLUMNS = ['title', 'status', 'created_at', 'updated_at']
|
||||
|
||||
watchPostEffect(() => {
|
||||
const [active, direction] = props.sort.split(':')
|
||||
tableRoot.value?.querySelectorAll('thead th').forEach((header, index) => {
|
||||
if (SORTABLE_COLUMNS.includes(active!) && columns.value[index]?.id === active) {
|
||||
header.setAttribute('aria-sort', direction === 'desc' ? 'descending' : 'ascending')
|
||||
} else {
|
||||
header.removeAttribute('aria-sort')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const COLUMN_NAMES = {
|
||||
title: () => t('title'),
|
||||
status: () => t('status'),
|
||||
created_at: () => t('created'),
|
||||
updated_at: () => t('updated'),
|
||||
}
|
||||
|
||||
const caption = computed(() => {
|
||||
if (props.sort === DEFAULT_ARTICLE_SORT) {
|
||||
return t('Articles, sorted by last updated, newest first')
|
||||
}
|
||||
const [field, direction] = props.sort.split(':') as [ArticleSortField, string]
|
||||
return t('Articles, sorted by {0}, {1}', [
|
||||
COLUMN_NAMES[field](),
|
||||
direction === 'desc' ? t('descending') : t('ascending'),
|
||||
])
|
||||
})
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
id: 'title',
|
||||
get header() {
|
||||
return t('Title')
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: 'authors',
|
||||
get header() {
|
||||
return t('Authors')
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
...(showSite.value
|
||||
? [
|
||||
{
|
||||
id: 'site',
|
||||
get header() {
|
||||
return t('Site')
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
accessorKey: 'is_online',
|
||||
id: 'status',
|
||||
get header() {
|
||||
return t('Status')
|
||||
},
|
||||
size: 110,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
id: 'created_at',
|
||||
get header() {
|
||||
return t('Created')
|
||||
},
|
||||
size: 130,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
id: 'updated_at',
|
||||
get header() {
|
||||
return t('Updated')
|
||||
},
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
},
|
||||
{ id: 'actions', header: '', size: 44 },
|
||||
])
|
||||
|
||||
function siteNameOf(article: Article) {
|
||||
return props.sites.find((site) => site.id === article.site_id)?.name ?? ''
|
||||
}
|
||||
|
||||
function authorNames(article: Article) {
|
||||
return (article.authors ?? []).map((author) => author.name).join(', ')
|
||||
}
|
||||
|
||||
function sortIcon(field: string) {
|
||||
const [id, direction] = props.sort.split(':')
|
||||
if (id !== field) {
|
||||
return undefined
|
||||
}
|
||||
return direction === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
|
||||
}
|
||||
|
||||
function editPath(article: Article) {
|
||||
return `/article/${article.id}/edit`
|
||||
}
|
||||
|
||||
function isBusy(article: Article) {
|
||||
return props.busyIds.has(article.id)
|
||||
}
|
||||
|
||||
function pendingFor(article: Article) {
|
||||
if (!isBusy(article)) {
|
||||
return undefined
|
||||
}
|
||||
return article.is_online ? ('unpublish' as const) : ('publish' as const)
|
||||
}
|
||||
|
||||
const { actionsFor, publishHint } = useArticleMenu({
|
||||
isBusy,
|
||||
edit: (article) => router.push(editPath(article)),
|
||||
publish: (id) => emit('publish', id),
|
||||
unpublish: (id) => emit('unpublish', id),
|
||||
copyUrl: (id) => emit('copy-url', id),
|
||||
remove: (article) => emit('delete', article),
|
||||
})
|
||||
|
||||
function onSelect(_event: Event, row: { original: Article }) {
|
||||
if (props.loading) {
|
||||
return
|
||||
}
|
||||
router.push(editPath(row.original))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="tableRoot"
|
||||
:aria-busy="loading ? 'true' : undefined"
|
||||
class="hidden border border-default bg-default md:block"
|
||||
>
|
||||
<span v-if="loading" role="status" class="sr-only">{{ t('Loading articles') }}</span>
|
||||
<UTable
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
:ui="{
|
||||
base: 'table-fixed',
|
||||
tr: 'cursor-pointer',
|
||||
th: `px-3 ${columnWidths}`,
|
||||
td: `h-11 px-3 py-0 ${columnWidths}`,
|
||||
}"
|
||||
:sorting="sorting"
|
||||
:sorting-options="{ manualSorting: true }"
|
||||
@select="onSelect"
|
||||
>
|
||||
<template #caption>
|
||||
<caption class="sr-only">
|
||||
{{
|
||||
caption
|
||||
}}
|
||||
</caption>
|
||||
</template>
|
||||
|
||||
<template #title-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||
>
|
||||
{{ t('Title') }}
|
||||
<UIcon
|
||||
v-if="sortIcon(column.id)"
|
||||
:name="sortIcon(column.id)!"
|
||||
:data-sort-direction="props.sort.split(':')[1]"
|
||||
class="size-3"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #status-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||
>
|
||||
{{ t('Status') }}
|
||||
<UIcon
|
||||
v-if="sortIcon(column.id)"
|
||||
:name="sortIcon(column.id)!"
|
||||
:data-sort-direction="props.sort.split(':')[1]"
|
||||
class="size-3"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #created_at-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||
>
|
||||
{{ t('Created') }}
|
||||
<UIcon
|
||||
v-if="sortIcon(column.id)"
|
||||
:name="sortIcon(column.id)!"
|
||||
:data-sort-direction="props.sort.split(':')[1]"
|
||||
class="size-3"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #updated_at-header="{ column }">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
class="inline-flex items-center gap-1 text-highlighted"
|
||||
@click.stop="emit('sort', column.id as ArticleSortField)"
|
||||
>
|
||||
{{ t('Updated') }}
|
||||
<UIcon
|
||||
v-if="sortIcon(column.id)"
|
||||
:name="sortIcon(column.id)!"
|
||||
:data-sort-direction="props.sort.split(':')[1]"
|
||||
class="size-3"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #title-cell="{ row }">
|
||||
<USkeleton v-if="loading" class="h-3 w-3/5 rounded-none bg-accented" />
|
||||
<RouterLink
|
||||
v-else
|
||||
:to="editPath(row.original)"
|
||||
:data-row-title="row.original.id"
|
||||
class="font-medium text-highlighted"
|
||||
@click.stop
|
||||
>
|
||||
{{ row.original.title }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<template #authors-cell="{ row }">
|
||||
<USkeleton v-if="loading" class="h-6 w-15 rounded-none bg-accented" />
|
||||
<span v-else class="flex min-w-0 items-center" :title="authorNames(row.original)">
|
||||
<AuthorStack
|
||||
v-if="row.original.authors?.length"
|
||||
:authors="row.original.authors"
|
||||
/>
|
||||
<span v-else :aria-label="t('No authors')" class="text-dimmed">—</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #site-cell="{ row }">
|
||||
<USkeleton v-if="loading" class="h-3 w-17.5 rounded-none bg-accented" />
|
||||
<span v-else class="block truncate text-toned">{{ siteNameOf(row.original) }}</span>
|
||||
</template>
|
||||
|
||||
<template #status-cell="{ row }">
|
||||
<USkeleton v-if="loading" class="h-5 w-15 rounded-none bg-accented" />
|
||||
<span v-else class="flex items-center gap-1.5">
|
||||
<ArticleStatusBadge
|
||||
:online="row.original.is_online"
|
||||
size="md"
|
||||
:pending="pendingFor(row.original)"
|
||||
:aria-busy="isBusy(row.original) ? 'true' : undefined"
|
||||
/>
|
||||
<span
|
||||
v-if="publishHint(row.original)"
|
||||
class="sr-only"
|
||||
data-testid="publish-hint"
|
||||
>{{ publishHint(row.original) }}</span
|
||||
>
|
||||
<SeoStatusChip
|
||||
v-if="seoChipOf(row.original)"
|
||||
:chip="seoChipOf(row.original)!"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #created_at-cell="{ row }">
|
||||
<USkeleton v-if="loading" class="h-3 w-20 rounded-none bg-accented" />
|
||||
<span v-else class="text-muted">{{
|
||||
formatAbsoluteDateFromIso(row.original.created_at)
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #updated_at-cell="{ row }">
|
||||
<USkeleton v-if="loading" class="h-3 w-20 rounded-none bg-accented" />
|
||||
<span v-else class="text-muted" :title="row.original.updated_at">{{
|
||||
formatRelativeDate(row.original.updated_at, now)
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #actions-cell="{ row }">
|
||||
<UDropdownMenu
|
||||
v-if="!loading"
|
||||
:items="actionsFor(row.original)"
|
||||
:content="{ align: 'end' }"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-ellipsis"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="t('Actions for {0}', [row.original.title])"
|
||||
class="size-8 justify-center text-toned"
|
||||
@click.stop
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<slot name="empty" />
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,31 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{
|
||||
count: number | null
|
||||
}>()
|
||||
|
||||
const pressed = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton
|
||||
role="switch"
|
||||
:aria-checked="pressed"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-lightbulb"
|
||||
data-testid="needs-attention-toggle"
|
||||
class="h-11 shrink-0 justify-start gap-2 border px-2.5 text-md ring-0 md:h-8 md:text-sm"
|
||||
:class="
|
||||
pressed
|
||||
? 'border-(--rc-sand) bg-(--rc-sand) font-medium text-(--rc-on-sand) hover:bg-(--rc-sand)'
|
||||
: 'border-accented bg-default font-normal text-toned'
|
||||
"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="pressed = !pressed"
|
||||
>
|
||||
{{ t('Needs attention') }}
|
||||
<span v-if="count !== null" class="font-mono text-2xs font-medium">{{ count }}</span>
|
||||
</UButton>
|
||||
</template>
|
||||
@@ -1,52 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { SeoChip } from '@/lib/articleSeoStatus'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
chip: SeoChip
|
||||
size?: 'sm' | 'md'
|
||||
}>(),
|
||||
{ size: 'md' },
|
||||
)
|
||||
|
||||
const description = computed(() => {
|
||||
if (props.chip.kind === 'hidden') {
|
||||
return t('Hidden from search engines')
|
||||
}
|
||||
return props.chip.count === 1 ? t('1 suggestion') : t('{0} suggestions', [props.chip.count])
|
||||
})
|
||||
|
||||
const appearance = computed(() =>
|
||||
props.chip.kind === 'hidden'
|
||||
? { icon: 'i-lucide-eye-off', class: 'border border-accented bg-transparent text-toned' }
|
||||
: {
|
||||
icon: 'i-lucide-lightbulb',
|
||||
class: 'border border-(--rc-warning-soft) bg-(--rc-warning-soft) text-warning',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
role="img"
|
||||
color="neutral"
|
||||
variant="soft"
|
||||
:icon="appearance.icon"
|
||||
:title="description"
|
||||
:aria-label="description"
|
||||
data-testid="seo-status-chip"
|
||||
:class="appearance.class"
|
||||
:ui="{
|
||||
base: [
|
||||
size === 'md' ? 'h-5.5' : 'h-5',
|
||||
'shrink-0 gap-1 px-1.5 py-0 text-xs font-medium whitespace-nowrap',
|
||||
],
|
||||
leadingIcon: 'size-3',
|
||||
}"
|
||||
>
|
||||
{{ chip.kind === 'hidden' ? t('Hidden') : chip.count }}
|
||||
</UBadge>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { locale, t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import StoryCoverage from '@/components/StoryCoverage.vue'
|
||||
import StoryRowMenu from '@/components/StoryRowMenu.vue'
|
||||
import type { StoryItem } from '@/composables/useArticles'
|
||||
import { languageName } from '@/lib/siteLanguages'
|
||||
import {
|
||||
can,
|
||||
editPath,
|
||||
opensLine,
|
||||
rowAuthors,
|
||||
rowUpdated,
|
||||
shownInLine,
|
||||
type ListMode,
|
||||
} from '@/lib/storyList'
|
||||
import type { StoryMenuHandlers } from '@/lib/storyMenu'
|
||||
|
||||
const props = defineProps<{
|
||||
items: StoryItem[]
|
||||
mode: ListMode
|
||||
defaultLang: string
|
||||
busyIds: Set<number>
|
||||
handlers: StoryMenuHandlers
|
||||
lang: string
|
||||
now?: Date
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'add-translation': [item: StoryItem, lang: string] }>()
|
||||
|
||||
const router = useRouter()
|
||||
const languageLabel = computed(() => languageName(props.lang, locale.value))
|
||||
|
||||
function onRowClick(item: StoryItem, event: MouseEvent) {
|
||||
if (
|
||||
(event.target as HTMLElement).closest(
|
||||
'[data-row-menu],[data-testid="story-coverage"],button',
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
router.push(editPath(item))
|
||||
}
|
||||
|
||||
function byline(item: StoryItem) {
|
||||
return `${rowAuthors(item, props.mode)} · ${rowUpdated(item, props.mode, props.now)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="border border-default bg-default" data-testid="story-cards">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="flex min-h-14 flex-wrap items-center gap-x-3 gap-y-1.5 border-t border-default px-4 py-2.5 first:border-t-0"
|
||||
:class="busyIds.has(item.id) ? 'opacity-55' : ''"
|
||||
@click="onRowClick(item, $event)"
|
||||
>
|
||||
<div
|
||||
class="order-0 flex min-w-0 flex-col gap-0.5"
|
||||
:class="mode === 'missing' ? 'flex-1 basis-0' : 'flex-1 basis-7/10'"
|
||||
>
|
||||
<RouterLink
|
||||
:to="editPath(item)"
|
||||
:lang="item.lang || undefined"
|
||||
:data-row-title="item.id"
|
||||
class="truncate font-medium text-highlighted"
|
||||
@click.stop
|
||||
>
|
||||
{{ item.title }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-if="!item.is_online && !can(item.capabilities, 'publish')"
|
||||
class="sr-only"
|
||||
data-testid="publish-hint"
|
||||
>{{ t('Ask an editor to publish.') }}</span
|
||||
>
|
||||
<span v-if="busyIds.has(item.id)" role="status" class="sr-only">{{
|
||||
item.is_online ? t('Unpublishing…') : t('Publishing…')
|
||||
}}</span>
|
||||
<span
|
||||
v-if="mode === 'story' && shownInLine(item, defaultLang)"
|
||||
class="truncate text-xs text-muted"
|
||||
>
|
||||
{{ shownInLine(item, defaultLang) }}
|
||||
</span>
|
||||
<span v-if="mode === 'missing'" class="truncate text-xs text-muted">{{
|
||||
opensLine(item)
|
||||
}}</span>
|
||||
<span v-if="mode === 'version'" class="text-xs text-muted">{{ byline(item) }}</span>
|
||||
</div>
|
||||
<div v-if="mode === 'version'" class="order-1 flex gap-1.5">
|
||||
<ArticleStatusBadge :online="item.is_online" size="sm" />
|
||||
</div>
|
||||
<UButton
|
||||
v-if="mode === 'missing'"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-plus"
|
||||
:aria-label="t('Add {0} translation to {1}', [languageLabel, item.title])"
|
||||
class="order-1 size-11 justify-center border border-accented bg-default text-highlighted ring-0"
|
||||
@click.stop="emit('add-translation', item, lang)"
|
||||
/>
|
||||
<StoryRowMenu :item="item" :handlers="handlers" class="order-1" />
|
||||
<StoryCoverage
|
||||
v-if="mode === 'story'"
|
||||
:item="item"
|
||||
wrap
|
||||
class="order-2 basis-full"
|
||||
@add-translation="(row, l) => emit('add-translation', row, l)"
|
||||
/>
|
||||
<span v-if="mode === 'story'" class="order-3 flex-1 text-xs text-muted">{{
|
||||
byline(item)
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useIsDesktop } from '@/composables/useIsDesktop'
|
||||
import type { StoryItem } from '@/composables/useArticles'
|
||||
import {
|
||||
COVERAGE_LIMIT_DESKTOP,
|
||||
COVERAGE_LIMIT_NARROW,
|
||||
coverageChips,
|
||||
overflowLabel,
|
||||
type CoverageChip,
|
||||
} from '@/lib/storyList'
|
||||
|
||||
const props = defineProps<{
|
||||
item: StoryItem
|
||||
wrap?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'add-translation': [item: StoryItem, lang: string] }>()
|
||||
|
||||
const router = useRouter()
|
||||
const wideScreen = useIsDesktop('80rem')
|
||||
const buttonsEnabled = useIsDesktop('48rem')
|
||||
|
||||
const CHIP_UI = {
|
||||
base: 'h-5.5 gap-1 rounded-none px-1.5 font-mono text-xs font-medium ring-0',
|
||||
trailingIcon: 'size-3',
|
||||
}
|
||||
|
||||
const chips = computed(() =>
|
||||
coverageChips(props.item.coverage ?? [], props.item.missing_langs ?? []),
|
||||
)
|
||||
const limit = computed(() => (wideScreen.value ? COVERAGE_LIMIT_DESKTOP : COVERAGE_LIMIT_NARROW))
|
||||
const shown = computed(() => chips.value.slice(0, limit.value))
|
||||
const hidden = computed(() => chips.value.slice(limit.value))
|
||||
|
||||
function iconOf(chip: CoverageChip) {
|
||||
if (chip.status === 'published') {
|
||||
return 'i-lucide-circle-check'
|
||||
}
|
||||
return chip.status === 'draft' ? 'i-lucide-pencil' : 'i-lucide-plus'
|
||||
}
|
||||
|
||||
function classOf(chip: CoverageChip) {
|
||||
const tone =
|
||||
chip.status === 'missing'
|
||||
? 'border border-dashed border-accented bg-default text-muted'
|
||||
: 'border border-accented bg-default text-highlighted'
|
||||
return chip.disabled ? `${tone} opacity-75` : tone
|
||||
}
|
||||
|
||||
function openVersion(chip: CoverageChip) {
|
||||
if (chip.entry) {
|
||||
router.push(`/article/${chip.entry.id}/edit`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-testid="story-coverage"
|
||||
class="flex gap-1.5"
|
||||
:class="wrap ? 'flex-wrap' : 'flex-nowrap'"
|
||||
>
|
||||
<template v-for="chip in shown" :key="chip.lang">
|
||||
<UBadge
|
||||
v-if="chip.status === 'missing' && buttonsEnabled"
|
||||
as="button"
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:trailing-icon="iconOf(chip)"
|
||||
:aria-label="chip.label"
|
||||
:class="classOf(chip)"
|
||||
:ui="CHIP_UI"
|
||||
@click.stop="emit('add-translation', item, chip.lang)"
|
||||
>
|
||||
{{ chip.code }}
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-else
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:trailing-icon="iconOf(chip)"
|
||||
:aria-label="chip.label"
|
||||
:class="classOf(chip)"
|
||||
:ui="CHIP_UI"
|
||||
>
|
||||
{{ chip.code }}
|
||||
</UBadge>
|
||||
</template>
|
||||
<UPopover
|
||||
v-if="hidden.length"
|
||||
:content="{ align: 'start' }"
|
||||
:ui="{ content: 'rounded-none' }"
|
||||
>
|
||||
<UBadge
|
||||
as="button"
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
:aria-label="overflowLabel(hidden)"
|
||||
class="bg-elevated text-highlighted"
|
||||
:ui="CHIP_UI"
|
||||
@click.stop
|
||||
>
|
||||
+{{ hidden.length }}
|
||||
</UBadge>
|
||||
<template #content>
|
||||
<ul class="flex flex-col py-1">
|
||||
<li v-for="chip in hidden" :key="chip.lang">
|
||||
<UBadge
|
||||
as="button"
|
||||
type="button"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:trailing-icon="iconOf(chip)"
|
||||
:aria-label="chip.label"
|
||||
:class="`m-1 ${classOf(chip)}`"
|
||||
:ui="CHIP_UI"
|
||||
@click.stop="
|
||||
chip.entry
|
||||
? openVersion(chip)
|
||||
: buttonsEnabled && emit('add-translation', item, chip.lang)
|
||||
"
|
||||
>
|
||||
{{ chip.code }}
|
||||
</UBadge>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</UPopover>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { useIsDesktop } from '@/composables/useIsDesktop'
|
||||
import type { StoryItem } from '@/composables/useArticles'
|
||||
import { storyMenuItems, type StoryMenuHandlers, type StoryMenuItem } from '@/lib/storyMenu'
|
||||
|
||||
const props = defineProps<{
|
||||
item: StoryItem
|
||||
handlers: StoryMenuHandlers
|
||||
small?: boolean
|
||||
}>()
|
||||
|
||||
const desktop = useIsDesktop()
|
||||
const open = ref(false)
|
||||
const anotherOpen = ref(false)
|
||||
|
||||
const items = computed(() => storyMenuItems(props.item, props.handlers))
|
||||
const label = computed(() => t('Actions for {0}', [props.item.title]))
|
||||
|
||||
function dropdownItem(item: StoryMenuItem): Record<string, unknown> {
|
||||
return {
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
color: item.destructive ? 'error' : undefined,
|
||||
children: item.children?.map((child) => [dropdownItem(child)]).flat(),
|
||||
onSelect: item.onSelect,
|
||||
}
|
||||
}
|
||||
|
||||
const dropdownItems = computed(() => {
|
||||
const regular = items.value.filter((item) => !item.destructive).map(dropdownItem)
|
||||
const destructive = items.value.filter((item) => item.destructive).map(dropdownItem)
|
||||
return destructive.length ? [regular, destructive] : [regular]
|
||||
})
|
||||
|
||||
function select(item: StoryMenuItem) {
|
||||
if (item.children) {
|
||||
anotherOpen.value = !anotherOpen.value
|
||||
return
|
||||
}
|
||||
open.value = false
|
||||
item.onSelect()
|
||||
}
|
||||
|
||||
const TRIGGER_CLASS =
|
||||
'shrink-0 justify-center text-toned data-[state=open]:outline-2 data-[state=open]:outline-offset-2 data-[state=open]:outline-primary'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDropdownMenu
|
||||
v-if="desktop"
|
||||
:items="dropdownItems"
|
||||
:content="{ align: 'end' }"
|
||||
:ui="{
|
||||
content: 'w-60 rounded-none p-1 shadow-(--shadow-popover)',
|
||||
group: 'p-0',
|
||||
separator: 'my-1',
|
||||
item: 'h-8 gap-2 rounded-none px-2 text-sm',
|
||||
itemLeadingIcon: 'size-4',
|
||||
}"
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-ellipsis"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="label"
|
||||
data-row-menu
|
||||
class="size-7"
|
||||
:class="TRIGGER_CLASS"
|
||||
@click.stop
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
|
||||
<template v-else>
|
||||
<UButton
|
||||
icon="i-lucide-ellipsis"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="label"
|
||||
data-row-menu
|
||||
class="size-11"
|
||||
:class="TRIGGER_CLASS"
|
||||
@click.stop="open = true"
|
||||
/>
|
||||
<USlideover
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="item.title"
|
||||
:description="item.title"
|
||||
:ui="{
|
||||
content:
|
||||
'max-h-none pt-3 pb-6 before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented)',
|
||||
header: 'min-h-11 border-0 px-4 py-0 sm:px-4',
|
||||
title: 'text-md font-semibold text-highlighted',
|
||||
description: 'sr-only',
|
||||
close: 'top-0 end-1.5 size-11 justify-center p-0',
|
||||
body: 'p-0 sm:p-0',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div role="menu" :aria-label="item.title" class="flex flex-col">
|
||||
<template v-for="entry in items" :key="entry.key">
|
||||
<UButton
|
||||
role="menuitem"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
block
|
||||
:icon="entry.icon"
|
||||
:trailing-icon="entry.children ? 'i-lucide-chevron-down' : undefined"
|
||||
class="min-h-13 justify-start gap-3 px-4 text-start text-md"
|
||||
:class="entry.destructive ? 'text-error' : 'text-highlighted'"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
@click="select(entry)"
|
||||
>
|
||||
{{ entry.label }}
|
||||
</UButton>
|
||||
<template v-if="entry.children && anotherOpen">
|
||||
<UButton
|
||||
v-for="child in entry.children"
|
||||
:key="child.key"
|
||||
role="menuitem"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
block
|
||||
class="min-h-13 justify-start gap-3 ps-11 pe-4 text-start text-md text-highlighted"
|
||||
@click="select(child)"
|
||||
>
|
||||
{{ child.label }}
|
||||
</UButton>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
import { locale, t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import StoryCoverage from '@/components/StoryCoverage.vue'
|
||||
import StoryRowMenu from '@/components/StoryRowMenu.vue'
|
||||
import type { StoryItem } from '@/composables/useArticles'
|
||||
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
|
||||
import {
|
||||
can,
|
||||
editPath,
|
||||
opensLine,
|
||||
rowAuthors,
|
||||
rowUpdated,
|
||||
shownInLine,
|
||||
type ListMode,
|
||||
} from '@/lib/storyList'
|
||||
import type { StoryMenuHandlers } from '@/lib/storyMenu'
|
||||
import { languageName } from '@/lib/siteLanguages'
|
||||
|
||||
const props = defineProps<{
|
||||
items: StoryItem[]
|
||||
mode: ListMode
|
||||
sort: string
|
||||
defaultLang: string
|
||||
busyIds: Set<number>
|
||||
handlers: StoryMenuHandlers
|
||||
lang: string
|
||||
now?: Date
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
sort: [field: ArticleSortField]
|
||||
'add-translation': [item: StoryItem, lang: string]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const showHeader = computed(() => props.mode === 'story')
|
||||
|
||||
function open(item: StoryItem) {
|
||||
router.push(editPath(item))
|
||||
}
|
||||
|
||||
function ariaSort(field: ArticleSortField) {
|
||||
const [active, direction] = props.sort.split(':')
|
||||
if (active !== field) {
|
||||
return undefined
|
||||
}
|
||||
return direction === 'desc' ? ('descending' as const) : ('ascending' as const)
|
||||
}
|
||||
|
||||
function sortIcon(field: ArticleSortField) {
|
||||
const [active, direction] = props.sort.split(':')
|
||||
if (active !== field) {
|
||||
return undefined
|
||||
}
|
||||
return direction === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
|
||||
}
|
||||
|
||||
const COLUMN_NAMES = {
|
||||
title: () => t('title'),
|
||||
status: () => t('status'),
|
||||
created_at: () => t('created'),
|
||||
updated_at: () => t('updated'),
|
||||
}
|
||||
|
||||
const caption = computed(() => {
|
||||
if (props.sort === DEFAULT_ARTICLE_SORT) {
|
||||
return t('Articles, sorted by last updated, newest first')
|
||||
}
|
||||
const [field, direction] = props.sort.split(':') as [ArticleSortField, string]
|
||||
return t('Articles, sorted by {0}, {1}', [
|
||||
COLUMN_NAMES[field](),
|
||||
direction === 'desc' ? t('descending') : t('ascending'),
|
||||
])
|
||||
})
|
||||
|
||||
const languageLabel = computed(() => languageName(props.lang, locale.value))
|
||||
const CELL = 'py-0 pr-0 pl-3 align-middle'
|
||||
const SORT_BUTTON = 'inline-flex items-center gap-1 text-muted font-medium'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border border-default bg-default" data-testid="story-table">
|
||||
<table class="w-full table-fixed border-collapse text-base">
|
||||
<caption class="sr-only">
|
||||
{{
|
||||
caption
|
||||
}}
|
||||
</caption>
|
||||
<colgroup>
|
||||
<col />
|
||||
<col v-if="mode === 'story'" class="w-110.5" />
|
||||
<col v-if="mode === 'version'" class="w-36" />
|
||||
<col v-if="mode !== 'missing'" class="w-30.5" />
|
||||
<col v-if="mode !== 'missing'" class="w-23" />
|
||||
<col v-if="mode === 'missing'" class="w-62" />
|
||||
<col class="w-14" />
|
||||
</colgroup>
|
||||
<thead :class="showHeader ? 'bg-elevated text-muted' : 'sr-only'">
|
||||
<tr class="text-left font-medium">
|
||||
<th class="px-4 py-2 font-medium" :aria-sort="ariaSort('title')">
|
||||
<button type="button" :class="SORT_BUTTON" @click="emit('sort', 'title')">
|
||||
{{ t('Article') }}
|
||||
<UIcon
|
||||
v-if="sortIcon('title')"
|
||||
:name="sortIcon('title')!"
|
||||
:data-sort-direction="sort.split(':')[1]"
|
||||
class="size-3"
|
||||
/>
|
||||
</button>
|
||||
</th>
|
||||
<th v-if="mode === 'story'" class="py-2 pr-0 pl-3 font-medium">
|
||||
{{ t('Languages') }}
|
||||
</th>
|
||||
<th v-if="mode === 'version'" class="py-2 pr-0 pl-3 font-medium">
|
||||
{{ t('Status') }}
|
||||
</th>
|
||||
<th v-if="mode !== 'missing'" class="py-2 pr-0 pl-3 font-medium">
|
||||
{{ t('Author') }}
|
||||
</th>
|
||||
<th
|
||||
v-if="mode !== 'missing'"
|
||||
class="py-2 pr-0 pl-3 font-medium"
|
||||
:aria-sort="ariaSort('updated_at')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:class="SORT_BUTTON"
|
||||
@click="emit('sort', 'updated_at')"
|
||||
>
|
||||
{{ t('Updated') }}
|
||||
<UIcon
|
||||
v-if="sortIcon('updated_at')"
|
||||
:name="sortIcon('updated_at')!"
|
||||
:data-sort-direction="sort.split(':')[1]"
|
||||
class="size-3"
|
||||
/>
|
||||
</button>
|
||||
</th>
|
||||
<th v-if="mode === 'missing'" class="pl-3">
|
||||
<span class="sr-only">{{ t('Add translation…') }}</span>
|
||||
</th>
|
||||
<th class="pl-3">
|
||||
<span class="sr-only">{{ t('Actions') }}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in items"
|
||||
:key="row.id"
|
||||
class="h-12 cursor-pointer border-t border-default"
|
||||
:class="busyIds.has(row.id) ? 'opacity-55' : ''"
|
||||
@click="open(row)"
|
||||
>
|
||||
<td class="px-4 py-2.5 align-middle">
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<RouterLink
|
||||
:to="editPath(row)"
|
||||
:lang="row.lang || undefined"
|
||||
:data-row-title="row.id"
|
||||
class="truncate font-medium text-highlighted"
|
||||
@click.stop
|
||||
>
|
||||
{{ row.title }}
|
||||
</RouterLink>
|
||||
<span
|
||||
v-if="!row.is_online && !can(row.capabilities, 'publish')"
|
||||
class="sr-only"
|
||||
data-testid="publish-hint"
|
||||
>{{ t('Ask an editor to publish.') }}</span
|
||||
>
|
||||
<span v-if="busyIds.has(row.id)" role="status" class="sr-only">{{
|
||||
row.is_online ? t('Unpublishing…') : t('Publishing…')
|
||||
}}</span>
|
||||
<span
|
||||
v-if="mode === 'story' && shownInLine(row, defaultLang)"
|
||||
class="truncate text-xs/4.5 text-muted"
|
||||
>
|
||||
{{ shownInLine(row, defaultLang) }}
|
||||
</span>
|
||||
<span v-if="mode === 'missing'" class="truncate text-xs/4.5 text-muted">
|
||||
{{ opensLine(row) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="mode === 'story'" :class="CELL">
|
||||
<StoryCoverage
|
||||
:item="row"
|
||||
@add-translation="(item, l) => emit('add-translation', item, l)"
|
||||
/>
|
||||
</td>
|
||||
<td v-if="mode === 'version'" :class="CELL">
|
||||
<ArticleStatusBadge :online="row.is_online" size="sm" />
|
||||
</td>
|
||||
<td v-if="mode !== 'missing'" :class="`${CELL} truncate text-muted`">
|
||||
{{ rowAuthors(row, mode) }}
|
||||
</td>
|
||||
<td v-if="mode !== 'missing'" :class="`${CELL} whitespace-nowrap text-muted`">
|
||||
{{ rowUpdated(row, mode, now) }}
|
||||
</td>
|
||||
<td v-if="mode === 'missing'" :class="`${CELL} text-right`">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-plus"
|
||||
:aria-label="
|
||||
t('Add {0} translation to {1}', [languageLabel, row.title])
|
||||
"
|
||||
class="h-7 border border-accented bg-default px-3 text-highlighted ring-0"
|
||||
@click.stop="emit('add-translation', row, lang)"
|
||||
>
|
||||
{{ t('Add {0} translation', [languageLabel]) }}
|
||||
</UButton>
|
||||
</td>
|
||||
<td :class="`${CELL} pr-4 text-right`">
|
||||
<StoryRowMenu :item="row" :handlers="handlers" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -69,4 +69,24 @@ describe('useArticles', () => {
|
||||
|
||||
expect(result).toEqual(envelope)
|
||||
})
|
||||
|
||||
it('asks for the grouped list and sends lang, tab and author only when set', async () => {
|
||||
await useArticles().listStories({ page: 1, size: 5 })
|
||||
await useArticles().listStories({
|
||||
page: 2,
|
||||
size: 5,
|
||||
lang: 'fr',
|
||||
tab: 'missing',
|
||||
author: 7,
|
||||
})
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/v1/article/?format=html&page=1&size=5&group=story',
|
||||
)
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/v1/article/?format=html&page=2&size=5&group=story&lang=fr&tab=missing&author=7',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useAuthors, type Author } from '@/composables/useAuthors'
|
||||
import { useSiteLanguages } from '@/composables/useSiteLanguages'
|
||||
import type { SiteLanguage } from '@/lib/siteLanguages'
|
||||
|
||||
export function useArticleFilterOptions(siteKey: () => string) {
|
||||
const { getLanguages } = useSiteLanguages()
|
||||
const { listAllAuthors } = useAuthors()
|
||||
const languages = ref<SiteLanguage[]>([])
|
||||
const languagesKnown = ref(false)
|
||||
const authors = ref<Author[]>([])
|
||||
let token = 0
|
||||
|
||||
async function loadLanguages(key: string) {
|
||||
const current = ++token
|
||||
languagesKnown.value = false
|
||||
if (!key) {
|
||||
languages.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await getLanguages(key)
|
||||
if (current !== token) {
|
||||
return
|
||||
}
|
||||
languages.value = response.languages
|
||||
languagesKnown.value = true
|
||||
} catch {
|
||||
if (current === token) {
|
||||
languages.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAuthors() {
|
||||
try {
|
||||
authors.value = await listAllAuthors()
|
||||
} catch {
|
||||
authors.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(siteKey, loadLanguages, { immediate: true })
|
||||
loadAuthors()
|
||||
|
||||
return { languages, languagesKnown, authors }
|
||||
}
|
||||
@@ -1,11 +1,6 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import {
|
||||
useArticles,
|
||||
type Article,
|
||||
type ArticleCounts,
|
||||
type ArticleStatus,
|
||||
} from '@/composables/useArticles'
|
||||
import { useArticles, type StoryCounts, type StoryItem } from '@/composables/useArticles'
|
||||
import {
|
||||
usePagedQuery,
|
||||
useSearchInput,
|
||||
@@ -21,7 +16,9 @@ import {
|
||||
toRouteQuery,
|
||||
type ArticleListQuery,
|
||||
type ArticleSortField,
|
||||
type ArticleStatus,
|
||||
} from '@/lib/articleListQuery'
|
||||
import { activeTabKey, queryForTab, type ListTabKey } from '@/lib/storyList'
|
||||
import { PAGE_SIZES } from '@/lib/pageQuery'
|
||||
import { ARTICLES_PAGE_SIZE_KEY, readStoredPageSize, storePageSize } from '@/lib/pageSizeMemory'
|
||||
|
||||
@@ -37,7 +34,7 @@ interface ArticleListOptions {
|
||||
|
||||
export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions) {
|
||||
const route = useRoute()
|
||||
const { listArticles } = useArticles()
|
||||
const { listStories } = useArticles()
|
||||
|
||||
function parseWithStoredSize(query: typeof route.query): ArticleListQuery {
|
||||
const parsed = parseArticleQuery(query)
|
||||
@@ -48,13 +45,13 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
|
||||
return stored === null ? parsed : { ...parsed, size: stored }
|
||||
}
|
||||
|
||||
const items = ref<Article[]>([])
|
||||
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
|
||||
const items = ref<StoryItem[]>([])
|
||||
const counts = ref<StoryCounts | null>(null)
|
||||
const loadFailed = ref(false)
|
||||
|
||||
async function request(query: ArticleListQuery, context: { loaded: boolean }) {
|
||||
try {
|
||||
const response = await listArticles(query)
|
||||
const response = await listStories(query)
|
||||
return {
|
||||
kind: 'loaded',
|
||||
total: response.total,
|
||||
@@ -97,7 +94,7 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
|
||||
(q) => load({ ...desired.value, q, page: 1 }, 'replace'),
|
||||
)
|
||||
|
||||
const needsAttentionCount = computed(() => counts.value.suggestions ?? null)
|
||||
const needsAttentionCount = computed(() => counts.value?.suggestions ?? null)
|
||||
const settled = computed(() => hasLoaded.value && !loadFailed.value)
|
||||
const showPaginator = computed(() => settled.value && total.value > 0)
|
||||
|
||||
@@ -110,14 +107,57 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
|
||||
if (status === desired.value.status) {
|
||||
return
|
||||
}
|
||||
load({ ...desired.value, status, page: 1 }, 'push')
|
||||
load({ ...desired.value, status, tab: '', page: 1 }, 'push')
|
||||
}
|
||||
|
||||
function changeSite(site: string) {
|
||||
if (site === desired.value.site) {
|
||||
return
|
||||
}
|
||||
load({ ...desired.value, site, page: 1 }, 'push')
|
||||
load({ ...desired.value, site, lang: '', page: 1 }, 'push')
|
||||
}
|
||||
|
||||
function changeLang(lang: string) {
|
||||
if (lang === desired.value.lang) {
|
||||
return
|
||||
}
|
||||
load({ ...desired.value, lang, page: 1 }, 'push')
|
||||
}
|
||||
|
||||
function changeTab(key: ListTabKey) {
|
||||
if (key === activeTabKey(desired.value)) {
|
||||
return
|
||||
}
|
||||
load(queryForTab(desired.value, key), 'push')
|
||||
}
|
||||
|
||||
function changeAuthor(author: number) {
|
||||
if (author === desired.value.author) {
|
||||
return
|
||||
}
|
||||
load({ ...desired.value, author, page: 1 }, 'push')
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
load(
|
||||
{
|
||||
...desired.value,
|
||||
site: '',
|
||||
lang: '',
|
||||
tab: '',
|
||||
status: '',
|
||||
author: 0,
|
||||
seo: '',
|
||||
page: 1,
|
||||
},
|
||||
'push',
|
||||
)
|
||||
}
|
||||
|
||||
function dropUnknownLanguage(known: string[]) {
|
||||
if (desired.value.lang && !known.includes(desired.value.lang)) {
|
||||
load({ ...desired.value, lang: '', page: 1 }, 'replace')
|
||||
}
|
||||
}
|
||||
|
||||
function changeSeo(on: boolean) {
|
||||
@@ -163,6 +203,11 @@ export function useArticleList({ sitesSettled, usableSite }: ArticleListOptions)
|
||||
changeStatus,
|
||||
changeSite,
|
||||
changeSeo,
|
||||
changeLang,
|
||||
changeTab,
|
||||
changeAuthor,
|
||||
clearFilters,
|
||||
dropUnknownLanguage,
|
||||
changeSort,
|
||||
changeSearch: search.changeSearch,
|
||||
clearSearch: search.clearSearch,
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { t } from '@/i18n'
|
||||
import { useContentAccess } from '@/composables/useContentAccess'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
|
||||
|
||||
interface ArticleMenuHandlers {
|
||||
isBusy: (article: Article) => boolean
|
||||
edit: (article: Article) => void
|
||||
publish: (id: number) => void
|
||||
unpublish: (id: number) => void
|
||||
copyUrl: (id: number) => void
|
||||
remove: (article: Article) => void
|
||||
}
|
||||
|
||||
export function useArticleMenu(handlers: ArticleMenuHandlers) {
|
||||
const access = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
|
||||
|
||||
function publishHint(article: Article) {
|
||||
return !article.is_online && !access.canPublish(article.created_by)
|
||||
? t('Ask an editor to publish.')
|
||||
: ''
|
||||
}
|
||||
|
||||
function actionsFor(article: Article) {
|
||||
const row = access.rowAccess(article.created_by)
|
||||
const toggle = access.canPublish(article.created_by)
|
||||
? [
|
||||
{
|
||||
get label() {
|
||||
return article.is_online ? t('Unpublish') : t('Publish')
|
||||
},
|
||||
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
|
||||
disabled: handlers.isBusy(article),
|
||||
onSelect: () => {
|
||||
if (article.is_online) {
|
||||
handlers.unpublish(article.id)
|
||||
} else {
|
||||
handlers.publish(article.id)
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
return [
|
||||
[
|
||||
{
|
||||
get label() {
|
||||
return t('Edit')
|
||||
},
|
||||
icon: 'i-lucide-pencil',
|
||||
disabled: !row.canEdit,
|
||||
description: row.canEdit ? undefined : row.editReason,
|
||||
onSelect: () => handlers.edit(article),
|
||||
},
|
||||
...toggle,
|
||||
{
|
||||
get label() {
|
||||
return t('Copy API URL')
|
||||
},
|
||||
icon: 'i-lucide-link',
|
||||
onSelect: () => handlers.copyUrl(article.id),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
get label() {
|
||||
return t('Delete…')
|
||||
},
|
||||
icon: 'i-lucide-trash-2',
|
||||
color: 'error' as const,
|
||||
disabled: !row.canDelete,
|
||||
description: row.canDelete ? undefined : row.deleteReason,
|
||||
onSelect: () => handlers.remove(article),
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
return { actionsFor, publishHint, access }
|
||||
}
|
||||
@@ -2,25 +2,16 @@ import { nextTick, ref, type Ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { useArticleLifecycle } from '@/composables/useArticleLifecycle'
|
||||
import type { Article, ArticleCounts } from '@/composables/useArticles'
|
||||
import type { Article, StoryItem } from '@/composables/useArticles'
|
||||
import type { ArticleListQuery } from '@/lib/articleListQuery'
|
||||
import { pageCountFor } from '@/lib/pagination'
|
||||
|
||||
interface ArticleRowActionsState {
|
||||
items: Ref<Article[]>
|
||||
total: Ref<number>
|
||||
counts: Ref<ArticleCounts>
|
||||
items: Ref<StoryItem[]>
|
||||
shown: Ref<ArticleListQuery>
|
||||
reload: (query: ArticleListQuery, mode: 'push' | 'replace') => void
|
||||
}
|
||||
|
||||
export function useArticleRowActions({
|
||||
items,
|
||||
total,
|
||||
counts,
|
||||
shown,
|
||||
reload,
|
||||
}: ArticleRowActionsState) {
|
||||
export function useArticleRowActions({ items, shown, reload }: ArticleRowActionsState) {
|
||||
const lifecycle = useArticleLifecycle()
|
||||
const toast = useLocalizedToast()
|
||||
const busyIds = ref<Set<number>>(new Set())
|
||||
@@ -35,48 +26,12 @@ export function useArticleRowActions({
|
||||
busyIds.value = next
|
||||
}
|
||||
|
||||
function shiftCounts(article: Article, delta: number) {
|
||||
const key = article.is_online ? 'online' : 'draft'
|
||||
counts.value = {
|
||||
...counts.value,
|
||||
all: Math.max(0, counts.value.all + delta),
|
||||
[key]: Math.max(0, counts.value[key] + delta),
|
||||
}
|
||||
}
|
||||
|
||||
function detachRow(id: number) {
|
||||
if (!items.value.some((article) => article.id === id)) {
|
||||
return
|
||||
}
|
||||
items.value = items.value.filter((article) => article.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
if (items.value.length === 0 && total.value > 0) {
|
||||
const page = Math.min(shown.value.page, pageCountFor(total.value, shown.value.size))
|
||||
reload({ ...shown.value, page }, 'replace')
|
||||
}
|
||||
}
|
||||
|
||||
function removeArticle(article: Article) {
|
||||
shiftCounts(article, -1)
|
||||
detachRow(article.id)
|
||||
}
|
||||
|
||||
function replaceArticle(updated: Article, previous: Article) {
|
||||
items.value = items.value.map((existing) =>
|
||||
existing.id === updated.id ? updated : existing,
|
||||
function refresh(removed = false) {
|
||||
const emptied = removed && items.value.length === 1 && shown.value.page > 1
|
||||
reload(
|
||||
{ ...shown.value, page: emptied ? shown.value.page - 1 : shown.value.page },
|
||||
'replace',
|
||||
)
|
||||
if (updated.is_online === previous.is_online) {
|
||||
return
|
||||
}
|
||||
counts.value = {
|
||||
...counts.value,
|
||||
online: counts.value.online + (updated.is_online ? 1 : -1),
|
||||
draft: counts.value.draft + (updated.is_online ? -1 : 1),
|
||||
}
|
||||
const filter = shown.value.status
|
||||
if (filter && (filter === 'online') !== updated.is_online) {
|
||||
detachRow(updated.id)
|
||||
}
|
||||
}
|
||||
|
||||
function notFoundToast(article: Article) {
|
||||
@@ -96,34 +51,21 @@ export function useArticleRowActions({
|
||||
function setOnline(article: Article, makeOnline: boolean) {
|
||||
return lifecycle.changeOnline(article, makeOnline, {
|
||||
pending: (busy) => setBusy(article.id, busy),
|
||||
onUpdated: (updated) => replaceArticle(updated, article),
|
||||
onUpdated: () => refresh(),
|
||||
onGone: () => goneFromList(article),
|
||||
})
|
||||
}
|
||||
|
||||
function findItem(id: number) {
|
||||
return items.value.find((existing) => existing.id === id)
|
||||
function handlePublish(article: Article) {
|
||||
return setOnline(article, true)
|
||||
}
|
||||
|
||||
function handlePublish(id: number) {
|
||||
const article = findItem(id)
|
||||
if (article) {
|
||||
setOnline(article, true)
|
||||
}
|
||||
function handleUnpublish(article: Article) {
|
||||
return setOnline(article, false)
|
||||
}
|
||||
|
||||
function handleUnpublish(id: number) {
|
||||
const article = findItem(id)
|
||||
if (article) {
|
||||
setOnline(article, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyUrl(id: number) {
|
||||
const article = findItem(id)
|
||||
if (!article) {
|
||||
return
|
||||
}
|
||||
async function handleCopyUrl(article: Article) {
|
||||
const id = article.id
|
||||
const url = `${window.location.origin}/v1/article/${id}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
@@ -153,8 +95,8 @@ export function useArticleRowActions({
|
||||
}
|
||||
|
||||
function goneFromList(article: Article) {
|
||||
removeArticle(article)
|
||||
toast.add(() => notFoundToast(article))
|
||||
refresh(true)
|
||||
}
|
||||
|
||||
async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) {
|
||||
@@ -171,7 +113,7 @@ export function useArticleRowActions({
|
||||
async function handleDelete(article: Article) {
|
||||
const idsAtOpen = items.value.map((existing) => existing.id)
|
||||
const confirmed = await lifecycle.confirmAndDelete(article, {
|
||||
onDeleted: () => removeArticle(article),
|
||||
onDeleted: () => refresh(true),
|
||||
onGone: () => goneFromList(article),
|
||||
})
|
||||
if (confirmed) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { SuggestionCode } from '@/lib/seoTypes'
|
||||
import type { ArticleSeo } from '@/lib/articleSeo'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { Byline } from '@/lib/articleTypes'
|
||||
import type { ArticleStatus, SEO_FILTER } from '@/lib/articleListQuery'
|
||||
import type { ArticleStatus, ArticleTab, SEO_FILTER } from '@/lib/articleListQuery'
|
||||
|
||||
export type { Byline }
|
||||
|
||||
@@ -49,10 +49,45 @@ export interface ListArticlesParams {
|
||||
seo?: '' | typeof SEO_FILTER
|
||||
}
|
||||
|
||||
export function useArticles() {
|
||||
const { request } = useApi()
|
||||
export type ArticleCapability = 'read' | 'edit' | 'publish' | 'delete' | 'create_translation'
|
||||
|
||||
function listArticles(params: ListArticlesParams): Promise<ArticlePage> {
|
||||
export interface CoverageEntry {
|
||||
id: number
|
||||
lang: string
|
||||
title: string
|
||||
is_online: boolean
|
||||
language_enabled: boolean
|
||||
capabilities: ArticleCapability[]
|
||||
}
|
||||
|
||||
export interface StoryItem extends Article {
|
||||
story_id?: number
|
||||
story_updated_at: string
|
||||
story_authors: Byline[]
|
||||
coverage: CoverageEntry[]
|
||||
missing_langs: string[]
|
||||
capabilities: ArticleCapability[]
|
||||
}
|
||||
|
||||
export interface StoryCounts extends ArticleCounts {
|
||||
missing: number
|
||||
}
|
||||
|
||||
export interface StoryPage {
|
||||
items: StoryItem[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
counts: StoryCounts
|
||||
}
|
||||
|
||||
export interface ListStoriesParams extends ListArticlesParams {
|
||||
lang?: string
|
||||
tab?: ArticleTab
|
||||
author?: number
|
||||
}
|
||||
|
||||
function listQuery(params: ListArticlesParams) {
|
||||
const query = new URLSearchParams({
|
||||
format: 'html',
|
||||
page: String(params.page),
|
||||
@@ -73,8 +108,30 @@ export function useArticles() {
|
||||
if (params.seo) {
|
||||
query.set('seo', params.seo)
|
||||
}
|
||||
return request<ArticlePage>(`/v1/article/?${query.toString()}`)
|
||||
return query
|
||||
}
|
||||
|
||||
export function useArticles() {
|
||||
const { request } = useApi()
|
||||
|
||||
function listArticles(params: ListArticlesParams): Promise<ArticlePage> {
|
||||
return request<ArticlePage>(`/v1/article/?${listQuery(params).toString()}`)
|
||||
}
|
||||
|
||||
return { listArticles }
|
||||
function listStories(params: ListStoriesParams): Promise<StoryPage> {
|
||||
const query = listQuery(params)
|
||||
query.set('group', 'story')
|
||||
if (params.lang) {
|
||||
query.set('lang', params.lang)
|
||||
}
|
||||
if (params.tab) {
|
||||
query.set('tab', params.tab)
|
||||
}
|
||||
if (params.author) {
|
||||
query.set('author', String(params.author))
|
||||
}
|
||||
return request<StoryPage>(`/v1/article/?${query.toString()}`)
|
||||
}
|
||||
|
||||
return { listArticles, listStories }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
|
||||
export type DesktopBreakpoint = '40rem' | '48rem' | '64rem'
|
||||
export type DesktopBreakpoint = '40rem' | '48rem' | '64rem' | '80rem'
|
||||
|
||||
export function useIsDesktop(minWidth: DesktopBreakpoint = '48rem') {
|
||||
return useMediaQuery(`(min-width: ${minWidth})`)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { articleListMessages } from './articleListMessages'
|
||||
import { messages } from './messages'
|
||||
|
||||
const SOURCES = [
|
||||
'components/ArticleFilterBar.vue',
|
||||
'components/ArticleFilterChips.vue',
|
||||
'components/ArticleFilterFields.vue',
|
||||
'components/ArticleFiltersSheet.vue',
|
||||
'components/ArticleListEmpty.vue',
|
||||
'components/ArticleListFailure.vue',
|
||||
'components/ArticleListTabs.vue',
|
||||
'components/StoryCardList.vue',
|
||||
'components/StoryCoverage.vue',
|
||||
'components/StoryRowMenu.vue',
|
||||
'components/StoryTable.vue',
|
||||
'lib/articleFilterChips.ts',
|
||||
'lib/storyList.ts',
|
||||
'lib/storyMenu.ts',
|
||||
'views/ArticleView.vue',
|
||||
]
|
||||
|
||||
const SAME_IN_FRENCH = new Set([
|
||||
'SEO',
|
||||
'Site',
|
||||
'Article',
|
||||
'Articles',
|
||||
'Actions',
|
||||
'1 version',
|
||||
'{0} versions',
|
||||
'1 article',
|
||||
'{0} articles',
|
||||
])
|
||||
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
|
||||
const NUMBERED = /\{\d+\}/g
|
||||
|
||||
function keysIn(source: string) {
|
||||
return [...source.matchAll(LITERAL)].map((match) =>
|
||||
(match[1] ?? match[2] ?? '').replace(/\\(['"\\])/g, '$1'),
|
||||
)
|
||||
}
|
||||
|
||||
describe('Article list messages', () => {
|
||||
const keys = SOURCES.flatMap((file) =>
|
||||
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
|
||||
file,
|
||||
key,
|
||||
})),
|
||||
)
|
||||
|
||||
it('finds the strings of the list screen', () => {
|
||||
expect(keys.length).toBeGreaterThan(60)
|
||||
})
|
||||
|
||||
it.each(keys.map(({ file, key }) => [file, key]))(
|
||||
'%s: "%s" has a French entry',
|
||||
(_file, key) => {
|
||||
const french = (messages as Record<string, string>)[key as string]
|
||||
|
||||
expect(french).toMatch(/\S/)
|
||||
if (!SAME_IN_FRENCH.has(key as string)) {
|
||||
expect(french).not.toBe(key)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it.each(Object.entries(articleListMessages))(
|
||||
'keeps the placeholders of "%s" in French',
|
||||
(english, french) => {
|
||||
expect([...french.matchAll(NUMBERED)].map((m) => m[0]).sort()).toEqual(
|
||||
[...english.matchAll(NUMBERED)].map((m) => m[0]).sort(),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
export const articleListMessages = {
|
||||
'Needs translation': 'À traduire',
|
||||
All: 'Tous',
|
||||
Published: 'Publiés',
|
||||
Drafts: 'Brouillons',
|
||||
'Not in {0}': 'Pas en {0}',
|
||||
'Article views': 'Vues des articles',
|
||||
'Scroll tabs left': 'Faire défiler les onglets vers la gauche',
|
||||
'Scroll tabs right': 'Faire défiler les onglets vers la droite',
|
||||
'{0} {1} versions': '{0} versions en {1}',
|
||||
'1 {0} version': '1 version en {0}',
|
||||
'{0} {1} drafts': '{0} brouillons en {1}',
|
||||
'1 {0} draft': '1 brouillon en {0}',
|
||||
'{0} published {1} versions': '{0} versions publiées en {1}',
|
||||
'1 published {0} version': '1 version publiée en {0}',
|
||||
'{0} articles not in {1}': '{0} articles sans version en {1}',
|
||||
'1 article not in {0}': '1 article sans version en {0}',
|
||||
'{0} articles need translation': '{0} articles à traduire',
|
||||
'1 article needs translation': '1 article à traduire',
|
||||
'Search titles in every language': 'Rechercher dans les titres de toutes les langues',
|
||||
'Search {0} titles': 'Rechercher dans les titres en {0}',
|
||||
'All languages': 'Toutes les langues',
|
||||
'Any status': 'Tout statut',
|
||||
'Any author': 'Tout auteur',
|
||||
'Any SEO state': 'Tout état SEO',
|
||||
'Filters · {0}': 'Filtres · {0}',
|
||||
'Filters, {0} active': 'Filtres, {0} actifs',
|
||||
'Close filters': 'Fermer les filtres',
|
||||
'Site: {0}': 'Site : {0}',
|
||||
'Status: {0}': 'Statut : {0}',
|
||||
'Author: {0}': 'Auteur : {0}',
|
||||
'SEO: Needs attention': 'SEO : À examiner',
|
||||
'Disabled languages keep their versions. They can’t get new ones.':
|
||||
'Les langues désactivées gardent leurs versions. Elles n’en reçoivent pas de nouvelles.',
|
||||
'{0} versions': '{0} versions',
|
||||
'1 version': '1 version',
|
||||
Authors: 'Auteurs',
|
||||
SEO: 'SEO',
|
||||
Actions: 'Actions',
|
||||
'No articles match these filters': 'Aucun article ne correspond à ces filtres',
|
||||
'Remove a filter to see more articles.': 'Retirez un filtre pour voir plus d’articles.',
|
||||
'Shown in {0}': 'Affiché en {0}',
|
||||
'Shown in {0} · language disabled': 'Affiché en {0} · langue désactivée',
|
||||
'Opens {0} · published': 'Ouvre {0} · publié',
|
||||
'Opens {0} · draft': 'Ouvre {0} · brouillon',
|
||||
'Opens {0} · published · language disabled': 'Ouvre {0} · publié · langue désactivée',
|
||||
'Opens {0} · draft · language disabled': 'Ouvre {0} · brouillon · langue désactivée',
|
||||
'Add {0} translation': 'Ajouter une traduction en {0}',
|
||||
'Add {0} translation to {1}': 'Ajouter une traduction en {0} à {1}',
|
||||
'Open {0}': 'Ouvrir {0}',
|
||||
'Open another language…': 'Ouvrir une autre langue…',
|
||||
'Add translation…': 'Ajouter une traduction…',
|
||||
'Publish {0}': 'Publier {0}',
|
||||
'Unpublish {0}': 'Dépublier {0}',
|
||||
'Delete {0} version': 'Supprimer la version {0}',
|
||||
'Open this version': 'Ouvrir cette version',
|
||||
published: 'publié',
|
||||
draft: 'brouillon',
|
||||
'no version': 'aucune version',
|
||||
'language disabled': 'langue désactivée',
|
||||
'{0}: {1}': '{0} : {1}',
|
||||
'{0} more languages: {1}': '{0} autres langues : {1}',
|
||||
'1 more language: {0}': '1 autre langue : {0}',
|
||||
'Every article you can see has a {0} version':
|
||||
'Tous les articles que vous voyez ont une version en {0}',
|
||||
'Articles without a {0} version will appear here.':
|
||||
'Les articles sans version en {0} apparaîtront ici.',
|
||||
'Every article you can see is fully translated':
|
||||
'Tous les articles que vous voyez sont entièrement traduits',
|
||||
'Articles that need a translation will appear here.':
|
||||
'Les articles à traduire apparaîtront ici.',
|
||||
'Search covers titles in every language you can read.':
|
||||
'La recherche porte sur les titres de toutes les langues que vous pouvez lire.',
|
||||
"Couldn't load articles": 'Impossible de charger les articles',
|
||||
'Your filters and page are kept. Try again.':
|
||||
'Vos filtres et votre page sont conservés. Réessayez.',
|
||||
'Showing only what you can read': 'Seul ce que vous pouvez lire s’affiche',
|
||||
"Languages, counts and missing-translation results reflect the versions you have permission to see. You can't create translations on this site.":
|
||||
'Les langues, les totaux et les traductions manquantes reflètent les versions que vous avez le droit de voir. Vous ne pouvez pas créer de traductions sur ce site.',
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { articleListMessages } from './articleListMessages'
|
||||
import { localizationMessages } from './localizationMessages'
|
||||
|
||||
export const messages = {
|
||||
...localizationMessages,
|
||||
...articleListMessages,
|
||||
'Not found': 'Introuvable',
|
||||
'Edit article': 'Modifier l’article',
|
||||
unselected: 'désélectionnée',
|
||||
|
||||
@@ -5,11 +5,7 @@ import { API_GROUPS, SESSION_GROUPS } from '@/data/apiCatalogue'
|
||||
import { messages } from './messages'
|
||||
|
||||
const SOURCES = [
|
||||
'components/ArticleCardList.vue',
|
||||
'components/ArticleListNotice.vue',
|
||||
'components/ArticleListToolbar.vue',
|
||||
'components/ArticleSiteFilter.vue',
|
||||
'components/ArticleTable.vue',
|
||||
'components/AuthorList.vue',
|
||||
'components/AuthorProfileCard.vue',
|
||||
'components/AuthorSelect.vue',
|
||||
|
||||
@@ -10,20 +10,17 @@ 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/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',
|
||||
|
||||
@@ -13,7 +13,7 @@ import { initializeLocale, setLocale, t } from './index'
|
||||
import { messages } from './messages'
|
||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
import { formatSize, pluralArticles } from '@/lib/mediaFormat'
|
||||
import { articleEnvelope, articleListUrl } from '@/views/__tests__/articleEnvelope'
|
||||
import { storyEnvelope, storyListUrl } from '@/views/__tests__/articleEnvelope'
|
||||
import { accessFor, ownerAccess } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async () => {
|
||||
@@ -371,9 +371,13 @@ describe('Hardener localization outcomes', () => {
|
||||
updated_at: '2025-02-14T12:00:00Z',
|
||||
}
|
||||
request.mockImplementation(async (url: string) =>
|
||||
articleEnvelope([article], {
|
||||
url.startsWith('/v1/article/')
|
||||
? storyEnvelope([article], {
|
||||
q: new URL(url, 'http://localhost').searchParams.get('q') ?? '',
|
||||
}),
|
||||
})
|
||||
: url.startsWith('/v1/author/')
|
||||
? { items: [], total: 0, page: 1, size: 100 }
|
||||
: [],
|
||||
)
|
||||
setLocale('fr')
|
||||
await mountRoute('/article?q=absent')
|
||||
@@ -438,7 +442,13 @@ describe('Hardener localization outcomes', () => {
|
||||
updated_at: '2025-02-14T12:00:00Z',
|
||||
}
|
||||
request.mockImplementation(async (url: string) =>
|
||||
url === articleListUrl() ? articleEnvelope([article]) : article,
|
||||
url === storyListUrl()
|
||||
? storyEnvelope([article])
|
||||
: url.startsWith('/v1/site/') || url.startsWith('/v1/author/')
|
||||
? url.startsWith('/v1/author/')
|
||||
? { items: [], total: 0, page: 1, size: 100 }
|
||||
: []
|
||||
: article,
|
||||
)
|
||||
await mountRoute(path)
|
||||
if (path === '/article') {
|
||||
|
||||
@@ -11,6 +11,9 @@ describe('parseArticleQuery', () => {
|
||||
sort: '',
|
||||
site: '',
|
||||
seo: '',
|
||||
lang: '',
|
||||
tab: '',
|
||||
author: 0,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +34,9 @@ describe('parseArticleQuery', () => {
|
||||
sort: 'title:asc',
|
||||
site: '',
|
||||
seo: '',
|
||||
lang: '',
|
||||
tab: '',
|
||||
author: 0,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,6 +61,51 @@ describe('parseArticleQuery', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('localized list parameters', () => {
|
||||
it('reads lang, tab and author', () => {
|
||||
expect(parseArticleQuery({ lang: 'fr', tab: 'missing', author: '12' })).toMatchObject({
|
||||
lang: 'fr',
|
||||
tab: 'missing',
|
||||
author: 12,
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['english', 'x', '', 'fr_FR', '12'])('falls back from the lang "%s"', (lang) => {
|
||||
expect(parseArticleQuery({ lang }).lang).toBe('')
|
||||
})
|
||||
|
||||
it.each(['review', 'all', 'published', ''])('falls back from the tab "%s"', (tab) => {
|
||||
expect(parseArticleQuery({ tab }).tab).toBe('')
|
||||
})
|
||||
|
||||
it.each(['abc', '0', '-1', '', '1.5'])('falls back from the author "%s"', (author) => {
|
||||
expect(parseArticleQuery({ author }).author).toBe(0)
|
||||
})
|
||||
|
||||
it('drops the status when the tab is Not in language', () => {
|
||||
expect(parseArticleQuery({ tab: 'missing', status: 'draft' }).status).toBe('')
|
||||
})
|
||||
|
||||
it('writes lang, tab and author and omits their defaults', () => {
|
||||
expect(
|
||||
toRouteQuery({
|
||||
...parseArticleQuery({}),
|
||||
lang: 'fr',
|
||||
tab: 'missing',
|
||||
author: 4,
|
||||
}),
|
||||
).toEqual({ lang: 'fr', tab: 'missing', author: '4' })
|
||||
expect(toRouteQuery(parseArticleQuery({}))).toEqual({})
|
||||
})
|
||||
|
||||
it('tells queries apart by lang, tab and author', () => {
|
||||
const base = parseArticleQuery({})
|
||||
expect(sameArticleQuery(base, { ...base, lang: 'fr' })).toBe(false)
|
||||
expect(sameArticleQuery(base, { ...base, tab: 'missing' })).toBe(false)
|
||||
expect(sameArticleQuery(base, { ...base, author: 3 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sort parameter', () => {
|
||||
it.each(['title:asc', 'status:desc', 'created_at:asc', 'created_at:desc'])(
|
||||
'keeps %s',
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { parseArticleQuery } from '@/lib/articleListQuery'
|
||||
import {
|
||||
activeTabKey,
|
||||
coverageChips,
|
||||
listTabs,
|
||||
overflowLabel,
|
||||
queryForTab,
|
||||
unitLabel,
|
||||
} from '@/lib/storyList'
|
||||
import type { CoverageEntry } from '@/composables/useArticles'
|
||||
|
||||
const counts = { all: 4, online: 2, draft: 2, suggestions: 0, missing: 4 }
|
||||
|
||||
function query(raw: Record<string, string> = {}) {
|
||||
return parseArticleQuery(raw)
|
||||
}
|
||||
|
||||
function entry(lang: string, isOnline: boolean, enabled = true): CoverageEntry {
|
||||
return {
|
||||
id: 1,
|
||||
lang,
|
||||
title: lang,
|
||||
is_online: isOnline,
|
||||
language_enabled: enabled,
|
||||
capabilities: ['read'],
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
describe('listTabs', () => {
|
||||
it('offers All articles and Needs translation when no language is selected', () => {
|
||||
expect(
|
||||
listTabs(query(), { ...counts, all: 8, missing: 7 }).map((tab) => [
|
||||
tab.label,
|
||||
tab.count,
|
||||
]),
|
||||
).toEqual([
|
||||
['All articles', 8],
|
||||
['Needs translation', 7],
|
||||
])
|
||||
})
|
||||
|
||||
it('offers All, Published, Drafts and Not in French for a language', () => {
|
||||
expect(
|
||||
listTabs(query({ lang: 'fr' }), counts).map((tab) => [tab.label, tab.count]),
|
||||
).toEqual([
|
||||
['All', 4],
|
||||
['Published', 2],
|
||||
['Drafts', 2],
|
||||
['Not in French', 4],
|
||||
])
|
||||
})
|
||||
|
||||
it('has no count before the first response', () => {
|
||||
expect(listTabs(query(), null).map((tab) => tab.count)).toEqual([null, null])
|
||||
})
|
||||
|
||||
it('names the language in French lowercase', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(listTabs(query({ lang: 'de' }), counts)[3]!.label).toBe('Pas en allemand')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab selection', () => {
|
||||
it('reads the active tab from tab and status', () => {
|
||||
expect(activeTabKey(query())).toBe('all')
|
||||
expect(activeTabKey(query({ lang: 'fr', status: 'draft' }))).toBe('draft')
|
||||
expect(activeTabKey(query({ lang: 'fr', tab: 'missing' }))).toBe('missing')
|
||||
expect(activeTabKey(query({ status: 'draft' }))).toBe('all')
|
||||
})
|
||||
|
||||
it('resets page and the other tab filters when a tab is chosen', () => {
|
||||
const base = { ...query({ lang: 'fr', status: 'draft' }), page: 3 }
|
||||
|
||||
expect(queryForTab(base, 'missing')).toMatchObject({ tab: 'missing', status: '', page: 1 })
|
||||
expect(queryForTab(base, 'online')).toMatchObject({ tab: '', status: 'online', page: 1 })
|
||||
expect(queryForTab(base, 'all')).toMatchObject({ tab: '', status: '', page: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('unitLabel', () => {
|
||||
it.each([
|
||||
[{}, 8, '8 articles'],
|
||||
[{}, 1, '1 article'],
|
||||
[{ lang: 'fr' }, 4, '4 French versions'],
|
||||
[{ lang: 'fr' }, 1, '1 French version'],
|
||||
[{ lang: 'fr', tab: 'missing' }, 4, '4 articles not in French'],
|
||||
[{ tab: 'missing' }, 7, '7 articles need translation'],
|
||||
[{ tab: 'missing' }, 1, '1 article needs translation'],
|
||||
[{ lang: 'fr', status: 'draft' }, 2, '2 French drafts'],
|
||||
[{ lang: 'fr', status: 'online' }, 2, '2 published French versions'],
|
||||
])('%j with %i rows reads "%s"', (raw, total, label) => {
|
||||
expect(unitLabel(query(raw), total)).toBe(label)
|
||||
})
|
||||
})
|
||||
|
||||
describe('coverageChips', () => {
|
||||
it('keeps the response order, then the dashed missing languages', () => {
|
||||
const chips = coverageChips([entry('en', true), entry('es', true, false)], ['fr', 'de'])
|
||||
|
||||
expect(chips.map((chip) => [chip.code, chip.status, chip.disabled])).toEqual([
|
||||
['EN', 'published', false],
|
||||
['ES', 'published', true],
|
||||
['FR', 'missing', false],
|
||||
['DE', 'missing', false],
|
||||
])
|
||||
expect(chips.map((chip) => chip.label)).toEqual([
|
||||
'English: published',
|
||||
'Spanish: published, language disabled',
|
||||
'French: no version',
|
||||
'German: no version',
|
||||
])
|
||||
})
|
||||
|
||||
it('names the hidden languages of the overflow chip', () => {
|
||||
const chips = coverageChips([], ['fr', 'de', 'ar'])
|
||||
|
||||
expect(overflowLabel(chips)).toBe('3 more languages: French, German, Arabic')
|
||||
expect(overflowLabel(chips.slice(0, 1))).toBe('1 more language: French')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { locale, t } from '@/i18n'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import type { ArticleListQuery } from '@/lib/articleListQuery'
|
||||
import { languageName } from '@/lib/siteLanguages'
|
||||
|
||||
export type FilterChipKey = 'site' | 'lang' | 'status' | 'author' | 'seo'
|
||||
|
||||
export interface FilterChip {
|
||||
key: FilterChipKey
|
||||
label: string
|
||||
}
|
||||
|
||||
export function filterChips(
|
||||
query: ArticleListQuery,
|
||||
sites: Site[],
|
||||
authors: Author[],
|
||||
): FilterChip[] {
|
||||
if (!query.status && !query.author && !query.seo) {
|
||||
return []
|
||||
}
|
||||
const chips: FilterChip[] = []
|
||||
const site = sites.find((entry) => entry.key === query.site)
|
||||
if (query.site) {
|
||||
chips.push({ key: 'site', label: t('Site: {0}', [site?.name ?? query.site]) })
|
||||
}
|
||||
if (query.lang) {
|
||||
chips.push({
|
||||
key: 'lang',
|
||||
label: t('Language: {0}', [languageName(query.lang, locale.value)]),
|
||||
})
|
||||
}
|
||||
if (query.status) {
|
||||
chips.push({
|
||||
key: 'status',
|
||||
label: t('Status: {0}', [query.status === 'draft' ? t('Draft') : t('Published')]),
|
||||
})
|
||||
}
|
||||
if (query.author) {
|
||||
const author = authors.find((entry) => entry.id === query.author)
|
||||
chips.push({
|
||||
key: 'author',
|
||||
label: t('Author: {0}', [author?.name ?? String(query.author)]),
|
||||
})
|
||||
}
|
||||
if (query.seo) {
|
||||
chips.push({ key: 'seo', label: t('SEO: Needs attention') })
|
||||
}
|
||||
return chips
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import type { ArticleStatus, SEO_FILTER } from '@/lib/articleListQuery'
|
||||
import type { SiteLanguage } from '@/lib/siteLanguages'
|
||||
|
||||
export interface ArticleFilterFieldProps {
|
||||
site: string
|
||||
sites: Site[]
|
||||
severalSites: boolean
|
||||
lang: string
|
||||
languages: SiteLanguage[]
|
||||
status: '' | ArticleStatus
|
||||
author: number
|
||||
authors: Author[]
|
||||
seo: '' | typeof SEO_FILTER
|
||||
suggestions: number | null
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from '@/lib/pageQuery'
|
||||
|
||||
export type ArticleStatus = 'online' | 'draft'
|
||||
export type ArticleTab = '' | 'missing'
|
||||
|
||||
const LANGUAGE_TAG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/
|
||||
|
||||
export const DEFAULT_ARTICLE_SORT = 'updated_at:desc'
|
||||
export const ARTICLE_SORT_FIELDS = ['title', 'status', 'created_at', 'updated_at'] as const
|
||||
@@ -24,6 +27,9 @@ export interface ArticleListQuery {
|
||||
sort: string
|
||||
site: string
|
||||
seo: '' | typeof SEO_FILTER
|
||||
lang: string
|
||||
tab: ArticleTab
|
||||
author: number
|
||||
}
|
||||
|
||||
const SORT_PATTERN = new RegExp(`^(${ARTICLE_SORT_FIELDS.join('|')}):(asc|desc)$`)
|
||||
@@ -44,15 +50,20 @@ export function nextSort(current: string, field: ArticleSortField) {
|
||||
export function parseArticleQuery(query: LocationQuery): ArticleListQuery {
|
||||
const page = firstQueryValue(query.page)
|
||||
const size = Number(firstQueryValue(query.size))
|
||||
const tab = firstQueryValue(query.tab) === 'missing' ? 'missing' : ''
|
||||
const status = firstQueryValue(query.status)
|
||||
const lang = firstQueryValue(query.lang)
|
||||
return {
|
||||
page: parsePositiveInteger(page) ?? 1,
|
||||
size: PAGE_SIZES.includes(size) ? size : DEFAULT_PAGE_SIZE,
|
||||
q: firstQueryValue(query.q).slice(0, SEARCH_MAX_LENGTH),
|
||||
status: status === 'online' || status === 'draft' ? status : '',
|
||||
status: tab === '' && (status === 'online' || status === 'draft') ? status : '',
|
||||
sort: validSort(firstQueryValue(query.sort)),
|
||||
site: firstQueryValue(query.site).slice(0, SITE_KEY_MAX),
|
||||
seo: firstQueryValue(query.seo) === SEO_FILTER ? SEO_FILTER : '',
|
||||
lang: LANGUAGE_TAG.test(lang) ? lang : '',
|
||||
tab,
|
||||
author: parsePositiveInteger(firstQueryValue(query.author)) ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +90,15 @@ export function toRouteQuery(query: ArticleListQuery) {
|
||||
if (query.seo) {
|
||||
route.seo = query.seo
|
||||
}
|
||||
if (query.lang) {
|
||||
route.lang = query.lang
|
||||
}
|
||||
if (query.tab) {
|
||||
route.tab = query.tab
|
||||
}
|
||||
if (query.author) {
|
||||
route.author = String(query.author)
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
@@ -90,6 +110,9 @@ export function sameArticleQuery(left: ArticleListQuery, right: ArticleListQuery
|
||||
left.status === right.status &&
|
||||
left.site === right.site &&
|
||||
left.seo === right.seo &&
|
||||
left.lang === right.lang &&
|
||||
left.tab === right.tab &&
|
||||
left.author === right.author &&
|
||||
effectiveSort(left.sort) === effectiveSort(right.sort)
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
export type SeoChip = { kind: 'suggestions'; count: number } | { kind: 'hidden' }
|
||||
|
||||
interface SeoStatusSource {
|
||||
seo_status?: unknown
|
||||
seo_suggestions?: unknown
|
||||
}
|
||||
|
||||
export function seoChipOf(article: SeoStatusSource): SeoChip | null {
|
||||
if (article.seo_status === 'hidden') {
|
||||
return { kind: 'hidden' }
|
||||
}
|
||||
const count = article.seo_suggestions
|
||||
if (article.seo_status === 'suggestions' && typeof count === 'number' && count > 0) {
|
||||
return { kind: 'suggestions', count }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { locale, t } from '@/i18n'
|
||||
import type {
|
||||
ArticleCapability,
|
||||
CoverageEntry,
|
||||
StoryCounts,
|
||||
StoryItem,
|
||||
} from '@/composables/useArticles'
|
||||
import type { ArticleListQuery, ArticleStatus } from '@/lib/articleListQuery'
|
||||
import { authorsSummary } from '@/lib/articleMeta'
|
||||
import { formatAbsoluteDate, formatRelativeDate } from '@/lib/relativeDate'
|
||||
import { languageName } from '@/lib/siteLanguages'
|
||||
|
||||
export type ListTabKey = 'all' | 'online' | 'draft' | 'missing'
|
||||
|
||||
export interface ListTab {
|
||||
key: ListTabKey
|
||||
label: string
|
||||
count: number | null
|
||||
}
|
||||
|
||||
export interface CoverageChip {
|
||||
lang: string
|
||||
code: string
|
||||
status: 'published' | 'draft' | 'missing'
|
||||
disabled: boolean
|
||||
label: string
|
||||
entry: CoverageEntry | null
|
||||
}
|
||||
|
||||
export const COVERAGE_LIMIT_DESKTOP = 5
|
||||
export const COVERAGE_LIMIT_NARROW = 4
|
||||
|
||||
export function nameInSentence(tag: string) {
|
||||
const name = languageName(tag, locale.value)
|
||||
return locale.value === 'fr' ? name.toLocaleLowerCase('fr') : name
|
||||
}
|
||||
|
||||
export function activeTabKey(query: ArticleListQuery): ListTabKey {
|
||||
if (query.tab === 'missing') {
|
||||
return 'missing'
|
||||
}
|
||||
if (query.lang && query.status) {
|
||||
return query.status
|
||||
}
|
||||
return 'all'
|
||||
}
|
||||
|
||||
export function listTabs(query: ArticleListQuery, counts: StoryCounts | null): ListTab[] {
|
||||
const count = (key: keyof StoryCounts) => (counts ? (counts[key] ?? 0) : null)
|
||||
if (!query.lang) {
|
||||
return [
|
||||
{ key: 'all', label: t('All articles'), count: count('all') },
|
||||
{ key: 'missing', label: t('Needs translation'), count: count('missing') },
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ key: 'all', label: t('All'), count: count('all') },
|
||||
{ key: 'online', label: t('Published'), count: count('online') },
|
||||
{ key: 'draft', label: t('Drafts'), count: count('draft') },
|
||||
{
|
||||
key: 'missing',
|
||||
label: t('Not in {0}', [nameInSentence(query.lang)]),
|
||||
count: count('missing'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function queryForTab(query: ArticleListQuery, key: ListTabKey): ArticleListQuery {
|
||||
const status: '' | ArticleStatus = key === 'online' || key === 'draft' ? key : ''
|
||||
return { ...query, tab: key === 'missing' ? 'missing' : '', status, page: 1 }
|
||||
}
|
||||
|
||||
export function unitLabel(query: ArticleListQuery, total: number) {
|
||||
const plural = total !== 1
|
||||
if (!query.lang) {
|
||||
if (query.tab === 'missing') {
|
||||
return plural
|
||||
? t('{0} articles need translation', [total])
|
||||
: t('1 article needs translation')
|
||||
}
|
||||
return plural ? t('{0} articles', [total]) : t('1 article')
|
||||
}
|
||||
const name = nameInSentence(query.lang)
|
||||
if (query.tab === 'missing') {
|
||||
return plural
|
||||
? t('{0} articles not in {1}', [total, name])
|
||||
: t('1 article not in {0}', [name])
|
||||
}
|
||||
if (query.status === 'draft') {
|
||||
return plural ? t('{0} {1} drafts', [total, name]) : t('1 {0} draft', [name])
|
||||
}
|
||||
if (query.status === 'online') {
|
||||
return plural
|
||||
? t('{0} published {1} versions', [total, name])
|
||||
: t('1 published {0} version', [name])
|
||||
}
|
||||
return plural ? t('{0} {1} versions', [total, name]) : t('1 {0} version', [name])
|
||||
}
|
||||
|
||||
function chipLabel(chip: Omit<CoverageChip, 'label'>) {
|
||||
const status =
|
||||
chip.status === 'published'
|
||||
? t('published')
|
||||
: chip.status === 'draft'
|
||||
? t('draft')
|
||||
: t('no version')
|
||||
const suffix = chip.disabled ? `, ${t('language disabled')}` : ''
|
||||
return `${t('{0}: {1}', [languageName(chip.lang, locale.value), status])}${suffix}`
|
||||
}
|
||||
|
||||
export function coverageChips(coverage: CoverageEntry[], missing: string[]): CoverageChip[] {
|
||||
const existing = coverage.map((entry) => {
|
||||
const chip = {
|
||||
lang: entry.lang,
|
||||
code: entry.lang.toUpperCase(),
|
||||
status: entry.is_online ? ('published' as const) : ('draft' as const),
|
||||
disabled: !entry.language_enabled,
|
||||
entry,
|
||||
}
|
||||
return { ...chip, label: chipLabel(chip) }
|
||||
})
|
||||
const absent = missing.map((lang) => {
|
||||
const chip = {
|
||||
lang,
|
||||
code: lang.toUpperCase(),
|
||||
status: 'missing' as const,
|
||||
disabled: false,
|
||||
entry: null,
|
||||
}
|
||||
return { ...chip, label: chipLabel(chip) }
|
||||
})
|
||||
return [...existing, ...absent]
|
||||
}
|
||||
|
||||
export function overflowLabel(hidden: CoverageChip[]) {
|
||||
const names = hidden.map((chip) => languageName(chip.lang, locale.value)).join(', ')
|
||||
return hidden.length === 1
|
||||
? t('1 more language: {0}', [names])
|
||||
: t('{0} more languages: {1}', [hidden.length, names])
|
||||
}
|
||||
|
||||
export function can(capabilities: ArticleCapability[] | undefined, capability: ArticleCapability) {
|
||||
return (capabilities ?? []).includes(capability)
|
||||
}
|
||||
|
||||
export type ListMode = 'story' | 'version' | 'missing'
|
||||
|
||||
export function listMode(query: ArticleListQuery): ListMode {
|
||||
if (query.lang) {
|
||||
return query.tab === 'missing' ? 'missing' : 'version'
|
||||
}
|
||||
return 'story'
|
||||
}
|
||||
|
||||
export function shownInLine(item: StoryItem, defaultLang: string) {
|
||||
if (!item.lang || !defaultLang || item.lang === defaultLang) {
|
||||
return ''
|
||||
}
|
||||
const name = languageName(item.lang, locale.value)
|
||||
const disabled =
|
||||
item.coverage?.find((entry) => entry.id === item.id)?.language_enabled === false
|
||||
return disabled ? t('Shown in {0} · language disabled', [name]) : t('Shown in {0}', [name])
|
||||
}
|
||||
|
||||
export function opensLine(item: StoryItem) {
|
||||
const name = languageName(item.lang ?? '', locale.value)
|
||||
const disabled =
|
||||
item.coverage?.find((entry) => entry.id === item.id)?.language_enabled === false
|
||||
if (disabled) {
|
||||
return item.is_online
|
||||
? t('Opens {0} · published · language disabled', [name])
|
||||
: t('Opens {0} · draft · language disabled', [name])
|
||||
}
|
||||
return item.is_online ? t('Opens {0} · published', [name]) : t('Opens {0} · draft', [name])
|
||||
}
|
||||
|
||||
export function storyUpdated(iso: string, now: Date = new Date()) {
|
||||
const text = formatRelativeDate(iso, now)
|
||||
const date = new Date(iso)
|
||||
if (text !== formatAbsoluteDate(date) || date.getFullYear() !== now.getFullYear()) {
|
||||
return text
|
||||
}
|
||||
return formatAbsoluteDate(date).replace(/\s*\d{4}$/, '')
|
||||
}
|
||||
|
||||
export function editPath(item: StoryItem) {
|
||||
return `/article/${item.id}/edit`
|
||||
}
|
||||
|
||||
export function rowAuthors(item: StoryItem, mode: ListMode) {
|
||||
return authorsSummary((mode === 'story' ? item.story_authors : item.authors) ?? [])
|
||||
}
|
||||
|
||||
export function rowUpdated(item: StoryItem, mode: ListMode, now?: Date) {
|
||||
return storyUpdated(mode === 'story' ? item.story_updated_at : item.updated_at, now)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { locale, t } from '@/i18n'
|
||||
import type { StoryItem } from '@/composables/useArticles'
|
||||
import { can } from '@/lib/storyList'
|
||||
import { languageName } from '@/lib/siteLanguages'
|
||||
|
||||
export interface StoryMenuItem {
|
||||
key: string
|
||||
label: string
|
||||
icon: string
|
||||
destructive?: boolean
|
||||
children?: StoryMenuItem[]
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export interface StoryMenuHandlers {
|
||||
open: (id: number) => void
|
||||
addTranslation: (item: StoryItem) => void
|
||||
publish: (item: StoryItem) => void
|
||||
unpublish: (item: StoryItem) => void
|
||||
copyUrl: (item: StoryItem) => void
|
||||
remove: (item: StoryItem) => void
|
||||
}
|
||||
|
||||
export function versionName(item: Pick<StoryItem, 'lang'>) {
|
||||
return item.lang ? languageName(item.lang, locale.value) : ''
|
||||
}
|
||||
|
||||
export function storyMenuItems(item: StoryItem, handlers: StoryMenuHandlers): StoryMenuItem[] {
|
||||
const name = versionName(item)
|
||||
const others = (item.coverage ?? []).filter((entry) => entry.id !== item.id)
|
||||
const items: StoryMenuItem[] = [
|
||||
{
|
||||
key: 'open',
|
||||
label: name ? t('Open {0}', [name]) : t('Edit'),
|
||||
icon: 'i-lucide-file-pen',
|
||||
onSelect: () => handlers.open(item.id),
|
||||
},
|
||||
]
|
||||
if ((item.coverage ?? []).length > 1) {
|
||||
items.push({
|
||||
key: 'open-another',
|
||||
label: t('Open another language…'),
|
||||
icon: 'i-lucide-languages',
|
||||
onSelect: () => undefined,
|
||||
children: others.map((entry) => ({
|
||||
key: `open-${entry.id}`,
|
||||
label: languageName(entry.lang, locale.value),
|
||||
icon: 'i-lucide-file-pen',
|
||||
onSelect: () => handlers.open(entry.id),
|
||||
})),
|
||||
})
|
||||
}
|
||||
if (can(item.capabilities, 'create_translation') && (item.missing_langs ?? []).length > 0) {
|
||||
items.push({
|
||||
key: 'add-translation',
|
||||
label: t('Add translation…'),
|
||||
icon: 'i-lucide-plus',
|
||||
onSelect: () => handlers.addTranslation(item),
|
||||
})
|
||||
}
|
||||
if (can(item.capabilities, 'publish')) {
|
||||
items.push(
|
||||
item.is_online
|
||||
? {
|
||||
key: 'unpublish',
|
||||
label: name ? t('Unpublish {0}', [name]) : t('Unpublish'),
|
||||
icon: 'i-lucide-eye-off',
|
||||
onSelect: () => handlers.unpublish(item),
|
||||
}
|
||||
: {
|
||||
key: 'publish',
|
||||
label: name ? t('Publish {0}', [name]) : t('Publish'),
|
||||
icon: 'i-lucide-eye',
|
||||
onSelect: () => handlers.publish(item),
|
||||
},
|
||||
)
|
||||
}
|
||||
items.push({
|
||||
key: 'copy-url',
|
||||
label: t('Copy API URL'),
|
||||
icon: 'i-lucide-copy',
|
||||
onSelect: () => handlers.copyUrl(item),
|
||||
})
|
||||
if (can(item.capabilities, 'delete')) {
|
||||
items.push({
|
||||
key: 'delete',
|
||||
label: name ? t('Delete {0} version', [name]) : t('Delete…'),
|
||||
icon: 'i-lucide-trash-2',
|
||||
destructive: true,
|
||||
onSelect: () => handlers.remove(item),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
type ArticleCapability = 'read' | 'edit' | 'publish' | 'delete' | 'create_translation'
|
||||
|
||||
interface Byline {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
interface CoverageEntry {
|
||||
id: number
|
||||
lang: string
|
||||
title: string
|
||||
is_online: boolean
|
||||
language_enabled: boolean
|
||||
capabilities: ArticleCapability[]
|
||||
}
|
||||
|
||||
interface StoryItem {
|
||||
id: number
|
||||
title: string
|
||||
body: string
|
||||
is_online: boolean
|
||||
lang: string
|
||||
site_id: number
|
||||
story_id: number
|
||||
created_by: number
|
||||
authors: Byline[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
story_updated_at: string
|
||||
story_authors: Byline[]
|
||||
coverage: CoverageEntry[]
|
||||
missing_langs: string[]
|
||||
capabilities: ArticleCapability[]
|
||||
}
|
||||
|
||||
interface StoryCounts {
|
||||
all: number
|
||||
online: number
|
||||
draft: number
|
||||
suggestions: number
|
||||
missing: number
|
||||
}
|
||||
|
||||
interface StoryPage {
|
||||
items: StoryItem[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
counts: StoryCounts
|
||||
}
|
||||
|
||||
type VersionState = [status: 'pub' | 'draft', needsReview: 0 | 1]
|
||||
|
||||
interface FixtureStory {
|
||||
n: number
|
||||
title: string
|
||||
versions: Record<string, VersionState>
|
||||
titles: Record<string, string>
|
||||
by: string
|
||||
updatedAt: string
|
||||
seo?: boolean
|
||||
}
|
||||
|
||||
export interface StorySite {
|
||||
key: string
|
||||
id: number
|
||||
langs: string[]
|
||||
disabled: string[]
|
||||
def: string
|
||||
stories: FixtureStory[]
|
||||
}
|
||||
|
||||
export interface StoryViewer {
|
||||
read?: string[]
|
||||
create?: boolean
|
||||
scope?: (story: FixtureStory) => boolean
|
||||
}
|
||||
|
||||
export interface StoryRequest {
|
||||
site: string
|
||||
lang: string
|
||||
tab: string
|
||||
status: string
|
||||
author: string
|
||||
seo: string
|
||||
q: string
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export const BYLINES: Record<string, { id: number; name: string; slug: string }> = {
|
||||
'Marta Alves': { id: 1, name: 'Marta Alves', slug: 'marta-alves' },
|
||||
'Camille Roche': { id: 2, name: 'Camille Roche', slug: 'camille-roche' },
|
||||
'Luis Ferrer': { id: 3, name: 'Luis Ferrer', slug: 'luis-ferrer' },
|
||||
}
|
||||
|
||||
function story(
|
||||
n: number,
|
||||
title: string,
|
||||
versions: Record<string, VersionState>,
|
||||
titles: Record<string, string>,
|
||||
by: string,
|
||||
updatedAt: string,
|
||||
seo = false,
|
||||
): FixtureStory {
|
||||
return { n, title, versions, titles, by, updatedAt, seo }
|
||||
}
|
||||
|
||||
const JOURNAL_STORIES = [
|
||||
story(
|
||||
1,
|
||||
'How we price a bathroom renovation',
|
||||
{ en: ['pub', 0], fr: ['draft', 1], de: ['pub', 0], ar: ['draft', 1] },
|
||||
{
|
||||
fr: 'Comment nous chiffrons une rénovation de salle de bain',
|
||||
de: 'So kalkulieren wir eine Badsanierung',
|
||||
ar: 'كيف نسعّر تجديد الحمّام',
|
||||
},
|
||||
'Marta Alves',
|
||||
'2026-10-30T10:00:00Z',
|
||||
),
|
||||
story(
|
||||
2,
|
||||
'A guide to lime plaster',
|
||||
{ en: ['pub', 0], fr: ['pub', 0], de: ['pub', 0] },
|
||||
{ fr: "Guide de l'enduit à la chaux", de: 'Ein Leitfaden zu Kalkputz' },
|
||||
'Camille Roche',
|
||||
'2026-10-29T09:00:00Z',
|
||||
),
|
||||
story(
|
||||
3,
|
||||
'Winter checklist for timber windows',
|
||||
{ en: ['pub', 0], fr: ['pub', 1] },
|
||||
{ fr: "Liste d'hiver pour les fenêtres en bois" },
|
||||
'Luis Ferrer',
|
||||
'2026-10-28T09:00:00Z',
|
||||
),
|
||||
story(
|
||||
4,
|
||||
'Spring newsletter',
|
||||
{ en: ['draft', 0] },
|
||||
{},
|
||||
'Marta Alves',
|
||||
'2026-10-27T09:00:00Z',
|
||||
true,
|
||||
),
|
||||
story(
|
||||
5,
|
||||
'Roof inspection checklist',
|
||||
{ en: ['pub', 0], de: ['draft', 0] },
|
||||
{ de: 'Checkliste für die Dachprüfung' },
|
||||
'Luis Ferrer',
|
||||
'2026-10-14T09:00:00Z',
|
||||
true,
|
||||
),
|
||||
story(
|
||||
6,
|
||||
'Why we reuse original floorboards',
|
||||
{ en: ['pub', 0], fr: ['draft', 0] },
|
||||
{ fr: "Pourquoi nous réutilisons les lames de parquet d'origine" },
|
||||
'Camille Roche',
|
||||
'2026-10-13T09:00:00Z',
|
||||
),
|
||||
story(
|
||||
7,
|
||||
'Our first passive-house retrofit',
|
||||
{ en: ['pub', 0], de: ['pub', 0] },
|
||||
{ de: 'Unsere erste Passivhaus-Sanierung' },
|
||||
'Marta Alves',
|
||||
'2026-10-12T09:00:00Z',
|
||||
),
|
||||
story(
|
||||
8,
|
||||
'Cómo elegir un contratista',
|
||||
{ es: ['pub', 0] },
|
||||
{},
|
||||
'Luis Ferrer',
|
||||
'2026-10-10T09:00:00Z',
|
||||
),
|
||||
]
|
||||
|
||||
const PRESS_STORIES = [
|
||||
story(
|
||||
11,
|
||||
'Opening hours over the holidays',
|
||||
{ en: ['pub', 0], fr: ['pub', 0] },
|
||||
{ fr: 'Horaires pendant les fêtes' },
|
||||
'Marta Alves',
|
||||
'2026-10-31T09:00:00Z',
|
||||
),
|
||||
story(
|
||||
12,
|
||||
'Meet the new site manager',
|
||||
{ en: ['pub', 0], fr: ['draft', 1] },
|
||||
{ fr: 'Rencontrez la nouvelle cheffe de chantier' },
|
||||
'Luis Ferrer',
|
||||
'2026-10-29T09:00:00Z',
|
||||
),
|
||||
story(
|
||||
13,
|
||||
'Annual report',
|
||||
{ en: ['draft', 0], fr: ['pub', 0] },
|
||||
{ fr: 'Rapport annuel' },
|
||||
'Marta Alves',
|
||||
'2026-10-14T09:00:00Z',
|
||||
),
|
||||
]
|
||||
|
||||
const ATELIER_STORIES = [
|
||||
story(
|
||||
21,
|
||||
'A complete guide to renovating a ground-floor kitchen and bathroom in a listed building, with every cost itemised for owners and tenants',
|
||||
{
|
||||
en: ['pub', 0],
|
||||
'en-GB': ['draft', 0],
|
||||
fr: ['draft', 1],
|
||||
de: ['pub', 0],
|
||||
'pt-BR': ['draft', 0],
|
||||
'zh-Hans': ['pub', 0],
|
||||
ja: ['pub', 0],
|
||||
ar: ['draft', 0],
|
||||
},
|
||||
{},
|
||||
'Marta Alves',
|
||||
'2026-10-30T10:00:00Z',
|
||||
true,
|
||||
),
|
||||
story(
|
||||
22,
|
||||
'Heat pumps for stone farmhouses',
|
||||
{ en: ['pub', 0], 'en-GB': ['pub', 0], fr: ['draft', 0], de: ['pub', 0], ja: ['draft', 0] },
|
||||
{},
|
||||
'Luis Ferrer',
|
||||
'2026-10-29T09:00:00Z',
|
||||
),
|
||||
story(
|
||||
23,
|
||||
'Sourcing reclaimed oak beams',
|
||||
{ en: ['draft', 0], 'en-GB': ['draft', 0] },
|
||||
{},
|
||||
'Camille Roche',
|
||||
'2026-10-14T09:00:00Z',
|
||||
true,
|
||||
),
|
||||
]
|
||||
|
||||
export const STORY_SITES: Record<string, StorySite> = {
|
||||
journal: {
|
||||
key: 'journal',
|
||||
id: 1,
|
||||
langs: ['en', 'fr', 'de', 'ar'],
|
||||
disabled: ['es', 'it'],
|
||||
def: 'en',
|
||||
stories: JOURNAL_STORIES,
|
||||
},
|
||||
press: {
|
||||
key: 'press',
|
||||
id: 6,
|
||||
langs: ['en', 'fr'],
|
||||
disabled: [],
|
||||
def: 'en',
|
||||
stories: PRESS_STORIES,
|
||||
},
|
||||
atelier: {
|
||||
key: 'atelier',
|
||||
id: 2,
|
||||
langs: ['en', 'en-GB', 'fr', 'de', 'pt-BR', 'zh-Hans', 'ja', 'ar'],
|
||||
disabled: [],
|
||||
def: 'en',
|
||||
stories: ATELIER_STORIES,
|
||||
},
|
||||
}
|
||||
|
||||
export const LIMITED_VIEWER: StoryViewer = {
|
||||
read: ['en'],
|
||||
create: false,
|
||||
scope: (entry) => ['Camille Roche', 'Luis Ferrer'].includes(entry.by),
|
||||
}
|
||||
|
||||
const FULL: ArticleCapability[] = ['read', 'edit', 'publish', 'delete', 'create_translation']
|
||||
const READ_ONLY: ArticleCapability[] = ['read']
|
||||
|
||||
export function requestOf(url: URL): StoryRequest {
|
||||
const params = url.searchParams
|
||||
return {
|
||||
site: params.get('site') ?? 'journal',
|
||||
lang: params.get('lang') ?? '',
|
||||
tab: params.get('tab') ?? '',
|
||||
status: params.get('status') ?? '',
|
||||
author: params.get('author') ?? '',
|
||||
seo: params.get('seo') ?? '',
|
||||
q: (params.get('q') ?? '').toLowerCase(),
|
||||
page: Number(params.get('page') ?? 1),
|
||||
size: Number(params.get('size') ?? 25),
|
||||
}
|
||||
}
|
||||
|
||||
function titleIn(entry: FixtureStory, lang: string) {
|
||||
return entry.titles[lang] ?? entry.title
|
||||
}
|
||||
|
||||
function idOf(entry: FixtureStory, lang: string) {
|
||||
return entry.n * 100 + Object.keys(entry.versions).indexOf(lang) + 1
|
||||
}
|
||||
|
||||
function representative(entry: FixtureStory, def: string) {
|
||||
return entry.versions[def] ? def : Object.keys(entry.versions).sort()[0]!
|
||||
}
|
||||
|
||||
function authorId(name: string) {
|
||||
return BYLINES[name]?.id ?? 0
|
||||
}
|
||||
|
||||
function visibleVersions(entry: FixtureStory, viewer: StoryViewer) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(entry.versions).filter(
|
||||
([lang]) => !viewer.read || viewer.read.includes(lang),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function coverageOf(entry: FixtureStory, site: StorySite, viewer: StoryViewer): CoverageEntry[] {
|
||||
const own = visibleVersions(entry, viewer)
|
||||
const ordered = [site.def, ...site.langs.filter((lang) => lang !== site.def)]
|
||||
const langs = [
|
||||
...ordered.filter((lang) => own[lang]),
|
||||
...Object.keys(own).filter((lang) => !site.langs.includes(lang)),
|
||||
]
|
||||
return langs.map((lang) => ({
|
||||
id: idOf(entry, lang),
|
||||
lang,
|
||||
title: titleIn(entry, lang),
|
||||
is_online: own[lang]![0] === 'pub',
|
||||
language_enabled: site.langs.includes(lang),
|
||||
capabilities: viewer.create === false ? READ_ONLY : FULL,
|
||||
}))
|
||||
}
|
||||
|
||||
function missingOf(entry: FixtureStory, site: StorySite, viewer: StoryViewer) {
|
||||
if (viewer.create === false) {
|
||||
return []
|
||||
}
|
||||
const own = visibleVersions(entry, viewer)
|
||||
return site.langs.filter((lang) => !own[lang])
|
||||
}
|
||||
|
||||
function itemOf(
|
||||
entry: FixtureStory,
|
||||
lang: string,
|
||||
site: StorySite,
|
||||
viewer: StoryViewer,
|
||||
): StoryItem {
|
||||
const own = visibleVersions(entry, viewer)
|
||||
const byline = BYLINES[entry.by]!
|
||||
return {
|
||||
id: idOf(entry, lang),
|
||||
title: titleIn(entry, lang),
|
||||
body: '',
|
||||
is_online: own[lang]![0] === 'pub',
|
||||
lang,
|
||||
site_id: site.id,
|
||||
story_id: entry.n,
|
||||
created_by: authorId(entry.by),
|
||||
authors: [byline],
|
||||
created_at: entry.updatedAt,
|
||||
updated_at: entry.updatedAt,
|
||||
story_updated_at: entry.updatedAt,
|
||||
story_authors: [byline],
|
||||
coverage: coverageOf(entry, site, viewer),
|
||||
missing_langs: missingOf(entry, site, viewer),
|
||||
capabilities: viewer.create === false ? READ_ONLY : FULL,
|
||||
}
|
||||
}
|
||||
|
||||
export function storyPage(request: StoryRequest, viewer: StoryViewer = {}): StoryPage {
|
||||
const site = STORY_SITES[request.site] ?? STORY_SITES.journal!
|
||||
const author = request.author ? Number(request.author) : 0
|
||||
const status = request.status === 'online' ? 'pub' : request.status
|
||||
const readable = site.stories
|
||||
.map((entry) => ({ entry, own: visibleVersions(entry, viewer) }))
|
||||
.filter(
|
||||
({ entry, own }) =>
|
||||
Object.keys(own).length > 0 && (!viewer.scope || viewer.scope(entry)),
|
||||
)
|
||||
const passes = (
|
||||
entry: FixtureStory,
|
||||
versions: VersionState[],
|
||||
langs: string[],
|
||||
useStatus: boolean,
|
||||
) =>
|
||||
(!useStatus || !status || versions.some((version) => version[0] === status)) &&
|
||||
(!author || authorId(entry.by) === author) &&
|
||||
(!request.seo || entry.seo) &&
|
||||
(!request.q || langs.some((lang) => titleIn(entry, lang).toLowerCase().includes(request.q)))
|
||||
const anyVersion = (
|
||||
entry: FixtureStory,
|
||||
own: Record<string, VersionState>,
|
||||
useStatus: boolean,
|
||||
) => passes(entry, Object.values(own), Object.keys(own), useStatus)
|
||||
const create = viewer.create !== false
|
||||
const rows = (useStatus: boolean) => {
|
||||
if (request.lang) {
|
||||
const lang = request.lang
|
||||
const inLang = readable.filter(
|
||||
({ entry, own }) => own[lang] && passes(entry, [own[lang]!], [lang], useStatus),
|
||||
)
|
||||
const missing = readable.filter(
|
||||
({ entry, own }) => !own[lang] && anyVersion(entry, own, useStatus),
|
||||
)
|
||||
return { inLang, missing }
|
||||
}
|
||||
const base = readable.filter(({ entry, own }) => anyVersion(entry, own, useStatus))
|
||||
const missing = base.filter(({ own }) => create && site.langs.some((lang) => !own[lang]))
|
||||
return { inLang: base, missing }
|
||||
}
|
||||
const listed = rows(true)
|
||||
const unfiltered = rows(false)
|
||||
const chosen = request.tab === 'missing' ? listed.missing : listed.inLang
|
||||
const start = (request.page - 1) * request.size
|
||||
const items = chosen.slice(start, start + request.size).map(({ entry, own }) => {
|
||||
const lang =
|
||||
request.lang && request.tab !== 'missing'
|
||||
? request.lang
|
||||
: representative(entry, site.def)
|
||||
return itemOf(entry, own[lang] ? lang : representative(entry, site.def), site, viewer)
|
||||
})
|
||||
const stateOf = (entry: FixtureStory, own: Record<string, VersionState>) =>
|
||||
request.lang ? [own[request.lang]!] : Object.values(own)
|
||||
const counts: StoryCounts = {
|
||||
all: unfiltered.inLang.length,
|
||||
online: unfiltered.inLang.filter(({ entry, own }) =>
|
||||
stateOf(entry, own).some((version) => version[0] === 'pub'),
|
||||
).length,
|
||||
draft: unfiltered.inLang.filter(({ entry, own }) =>
|
||||
stateOf(entry, own).some((version) => version[0] === 'draft'),
|
||||
).length,
|
||||
suggestions: site.stories.filter((entry) => entry.seo).length,
|
||||
missing: unfiltered.missing.length,
|
||||
}
|
||||
return { items, total: chosen.length, page: request.page, size: request.size, counts }
|
||||
}
|
||||
|
||||
export function storyPageFor(url: URL, viewer: StoryViewer = {}) {
|
||||
return storyPage(requestOf(url), viewer)
|
||||
}
|
||||
|
||||
export function siteLanguagesBody(siteKey: string) {
|
||||
const site = STORY_SITES[siteKey] ?? STORY_SITES.journal!
|
||||
const count = (lang: string) =>
|
||||
site.stories.filter((entry) => entry.versions[lang] !== undefined).length
|
||||
const published = (lang: string) =>
|
||||
site.stories.filter((entry) => entry.versions[lang]?.[0] === 'pub').length
|
||||
const row = (lang: string, enabled: boolean) => ({
|
||||
lang,
|
||||
enabled,
|
||||
is_default: lang === site.def,
|
||||
version_count: count(lang),
|
||||
published_count: published(lang),
|
||||
social_locale: null,
|
||||
social_locale_state: 'not_set',
|
||||
})
|
||||
return {
|
||||
revision: `${site.key}-1`,
|
||||
default: site.def,
|
||||
needs_setup: false,
|
||||
languages: [
|
||||
...site.langs.map((lang) => row(lang, true)),
|
||||
...site.disabled.map((lang) => row(lang, false)),
|
||||
],
|
||||
available: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function storyAuthorsBody() {
|
||||
const counts = [3, 2, 3]
|
||||
return {
|
||||
items: Object.values(BYLINES).map((byline, index) => ({
|
||||
...byline,
|
||||
bio: '',
|
||||
avatar_id: null,
|
||||
website: null,
|
||||
social_links: [],
|
||||
user_id: null,
|
||||
is_linked: false,
|
||||
article_count: counts[index],
|
||||
})),
|
||||
total: 3,
|
||||
page: 1,
|
||||
size: 100,
|
||||
}
|
||||
}
|
||||
+182
-62
@@ -1,39 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } 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 StoryTable from '@/components/StoryTable.vue'
|
||||
import StoryCardList from '@/components/StoryCardList.vue'
|
||||
import ArticleFilterBar from '@/components/ArticleFilterBar.vue'
|
||||
import ArticleFilterChips from '@/components/ArticleFilterChips.vue'
|
||||
import ArticleFiltersSheet from '@/components/ArticleFiltersSheet.vue'
|
||||
import ArticleListEmpty from '@/components/ArticleListEmpty.vue'
|
||||
import ArticleListFailure from '@/components/ArticleListFailure.vue'
|
||||
import ArticleListSkeleton from '@/components/ArticleListSkeleton.vue'
|
||||
import ArticleListNotice from '@/components/ArticleListNotice.vue'
|
||||
import ArticleListToolbar from '@/components/ArticleListToolbar.vue'
|
||||
import ArticleListTabs from '@/components/ArticleListTabs.vue'
|
||||
import EmptyState from '@/components/EmptyState.vue'
|
||||
import OwnScopeEmpty from '@/components/OwnScopeEmpty.vue'
|
||||
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
|
||||
import NewArticleModal from '@/components/NewArticleModal.vue'
|
||||
import AppPaginator from '@/components/AppPaginator.vue'
|
||||
import { useArticleFilterOptions } from '@/composables/useArticleFilterOptions'
|
||||
import { useArticleList } from '@/composables/useArticleList'
|
||||
import { useArticleRowActions } from '@/composables/useArticleRowActions'
|
||||
import { useArticleSites } from '@/composables/useArticleSites'
|
||||
import { useContentAccess } from '@/composables/useContentAccess'
|
||||
import { useIsDesktop } from '@/composables/useIsDesktop'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { effectiveSort } from '@/lib/articleListQuery'
|
||||
import { filterChips, type FilterChipKey } from '@/lib/articleFilterChips'
|
||||
import type { ArticleFilterFieldProps } from '@/lib/articleFilterFields'
|
||||
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
|
||||
|
||||
const DEFAULT_SKELETON_ROWS = 6
|
||||
import { activeTabKey, listMode, listTabs, unitLabel } from '@/lib/storyList'
|
||||
import type { StoryMenuHandlers } from '@/lib/storyMenu'
|
||||
|
||||
const router = useRouter()
|
||||
const desktop = useIsDesktop()
|
||||
const access = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
|
||||
const writerLine = computed(() =>
|
||||
access.readsOwnOnly.value
|
||||
? t('Online articles and the drafts you created.')
|
||||
: access.canCreate.value &&
|
||||
|
||||
const writerLine = computed(() => {
|
||||
if (access.readsOwnOnly.value) {
|
||||
return t('Online articles and the drafts you created.')
|
||||
}
|
||||
return access.canCreate.value &&
|
||||
access.updateScope.value === 'all' &&
|
||||
access.publishScope.value === 'none'
|
||||
? t('You can write and edit all articles. Publishing is done by an editor.')
|
||||
: t("Everything you've written, online and in draft."),
|
||||
)
|
||||
: ''
|
||||
})
|
||||
|
||||
const list = useArticleList({
|
||||
sitesSettled: () => siteList.sitesSettled.value,
|
||||
@@ -50,7 +63,6 @@ const {
|
||||
notice,
|
||||
showSkeleton,
|
||||
searchInput,
|
||||
needsAttentionCount,
|
||||
settled,
|
||||
skeletonActive,
|
||||
showPaginator,
|
||||
@@ -60,16 +72,58 @@ const {
|
||||
const siteList = useArticleSites(() => shown.value.site)
|
||||
const { sites, severalSites, siteName } = siteList
|
||||
const newArticleModalOpen = ref(false)
|
||||
const filtersOpen = ref(false)
|
||||
|
||||
const skeletonRows = computed(() => items.value.length || DEFAULT_SKELETON_ROWS)
|
||||
const optionsSiteKey = computed(
|
||||
() =>
|
||||
shown.value.site ||
|
||||
sites.value.find((site) => site.is_default)?.key ||
|
||||
sites.value[0]?.key ||
|
||||
'',
|
||||
)
|
||||
const { languages, languagesKnown, authors } = useArticleFilterOptions(() => optionsSiteKey.value)
|
||||
const defaultLang = computed(
|
||||
() => languages.value.find((language) => language.is_default)?.lang ?? '',
|
||||
)
|
||||
|
||||
watch([languagesKnown, hasLoaded], ([known, loaded]) => {
|
||||
if (known && loaded) {
|
||||
list.dropUnknownLanguage(languages.value.map((language) => language.lang))
|
||||
}
|
||||
})
|
||||
|
||||
const mode = computed(() => listMode(shown.value))
|
||||
const tabs = computed(() => listTabs(desired.value, counts.value))
|
||||
const activeTab = computed(() => activeTabKey(desired.value))
|
||||
const unit = computed(() => (settled.value ? unitLabel(shown.value, total.value) : ''))
|
||||
|
||||
const fields = computed<ArticleFilterFieldProps>(() => ({
|
||||
site: desired.value.site,
|
||||
sites: sites.value,
|
||||
severalSites: severalSites.value,
|
||||
lang: desired.value.lang,
|
||||
languages: languages.value,
|
||||
status: desired.value.status,
|
||||
author: desired.value.author,
|
||||
authors: authors.value,
|
||||
seo: desired.value.seo,
|
||||
suggestions: list.needsAttentionCount.value,
|
||||
}))
|
||||
const chips = computed(() => filterChips(desired.value, sites.value, authors.value))
|
||||
|
||||
const emptyVariant = computed(() => {
|
||||
if (!settled.value || skeletonActive.value || total.value > 0) {
|
||||
return null
|
||||
}
|
||||
if (shown.value.tab === 'missing') {
|
||||
return 'nothing-missing' as const
|
||||
}
|
||||
if (shown.value.q) {
|
||||
return 'no-results' as const
|
||||
}
|
||||
if (shown.value.author || shown.value.lang) {
|
||||
return 'no-matches' as const
|
||||
}
|
||||
if (shown.value.status === 'draft') {
|
||||
return 'no-drafts' as const
|
||||
}
|
||||
@@ -88,12 +142,27 @@ const emptyVariant = computed(() => {
|
||||
const { busyIds, handlePublish, handleUnpublish, handleCopyUrl, handleDelete } =
|
||||
useArticleRowActions({
|
||||
items,
|
||||
total,
|
||||
counts,
|
||||
shown,
|
||||
reload: list.load,
|
||||
})
|
||||
|
||||
function openVersion(id: number) {
|
||||
router.push(`/article/${id}/edit`)
|
||||
}
|
||||
|
||||
function addTranslation(item: Article) {
|
||||
openVersion(item.id)
|
||||
}
|
||||
|
||||
const menuHandlers: StoryMenuHandlers = {
|
||||
open: openVersion,
|
||||
addTranslation,
|
||||
publish: handlePublish,
|
||||
unpublish: handleUnpublish,
|
||||
copyUrl: handleCopyUrl,
|
||||
remove: handleDelete,
|
||||
}
|
||||
|
||||
function openNewArticleModal() {
|
||||
newArticleModalOpen.value = true
|
||||
}
|
||||
@@ -109,6 +178,20 @@ function clearSearch() {
|
||||
})
|
||||
}
|
||||
|
||||
function removeChip(key: FilterChipKey) {
|
||||
if (key === 'site') {
|
||||
list.changeSite('')
|
||||
} else if (key === 'lang') {
|
||||
list.changeLang('')
|
||||
} else if (key === 'status') {
|
||||
list.changeStatus('')
|
||||
} else if (key === 'author') {
|
||||
list.changeAuthor(0)
|
||||
} else {
|
||||
list.changeSeo(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onEmptyAction() {
|
||||
if (emptyVariant.value === 'needs-attention') {
|
||||
list.changeSeo(false)
|
||||
@@ -122,6 +205,10 @@ function onEmptyAction() {
|
||||
clearSearch()
|
||||
return
|
||||
}
|
||||
if (emptyVariant.value === 'no-matches') {
|
||||
list.clearFilters()
|
||||
return
|
||||
}
|
||||
if (emptyVariant.value === 'no-online') {
|
||||
list.changeStatus('draft')
|
||||
return
|
||||
@@ -136,9 +223,8 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<DashboardShell compact-padding>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
|
||||
<div class="flex flex-col gap-3 md:gap-4">
|
||||
<div class="flex min-h-9.5 items-baseline gap-3">
|
||||
<h1
|
||||
data-testid="articles-heading"
|
||||
tabindex="-1"
|
||||
@@ -146,18 +232,25 @@ onMounted(() => {
|
||||
>
|
||||
{{ t('Articles') }}
|
||||
</h1>
|
||||
<p class="text-muted">{{ writerLine }}</p>
|
||||
</div>
|
||||
<p
|
||||
aria-live="polite"
|
||||
data-testid="unit-label"
|
||||
class="min-w-0 flex-1 truncate font-medium text-muted"
|
||||
>
|
||||
{{ unit }}
|
||||
</p>
|
||||
<UButton
|
||||
v-if="access.canCreate.value"
|
||||
icon="i-lucide-plus"
|
||||
class="hidden h-8 px-3 sm:flex"
|
||||
class="size-11 justify-center md:h-8 md:w-auto md:px-3"
|
||||
@click="openNewArticleModal"
|
||||
>
|
||||
{{ t('New article') }}
|
||||
<span class="max-md:sr-only">{{ t('New article') }}</span>
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<p v-if="writerLine" class="-mt-1 text-muted">{{ writerLine }}</p>
|
||||
|
||||
<ReadOnlyBanner
|
||||
v-if="access.readsOwnOnly.value"
|
||||
icon="i-lucide-eye-off"
|
||||
@@ -168,21 +261,32 @@ onMounted(() => {
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<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"
|
||||
<ReadOnlyBanner
|
||||
v-else-if="!access.canCreate.value"
|
||||
:title="t('Showing only what you can read')"
|
||||
:description="
|
||||
t(
|
||||
'Languages, counts and missing-translation results reflect the versions you have permission to see. You can\'t create translations on this site.',
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<ArticleFilterBar
|
||||
v-model:search="searchInput"
|
||||
:fields="fields"
|
||||
:chip-count="chips.length"
|
||||
@update:site="list.changeSite"
|
||||
@update:lang="list.changeLang"
|
||||
@update:status="list.changeStatus"
|
||||
@update:author="list.changeAuthor"
|
||||
@update:seo="list.changeSeo"
|
||||
@open-filters="filtersOpen = true"
|
||||
/>
|
||||
|
||||
<ArticleFilterChips :chips="chips" @remove="removeChip" @clear="list.clearFilters" />
|
||||
|
||||
<ArticleListTabs :tabs="tabs" :active="activeTab" @select="list.changeTab" />
|
||||
|
||||
<ArticleListNotice
|
||||
v-if="notice"
|
||||
:kind="notice.kind"
|
||||
@@ -191,7 +295,8 @@ onMounted(() => {
|
||||
@retry="list.retryNotice"
|
||||
/>
|
||||
|
||||
<template v-if="hasLoaded || loadFailed || showSkeleton">
|
||||
<ArticleListFailure v-if="loadFailed" @retry="list.load(desired, 'replace')" />
|
||||
<template v-else-if="hasLoaded || showSkeleton">
|
||||
<OwnScopeEmpty
|
||||
v-if="emptyVariant === 'no-drafts' && access.readsOwnOnly.value"
|
||||
icon="i-lucide-file-text"
|
||||
@@ -204,35 +309,46 @@ onMounted(() => {
|
||||
:action="access.canCreate.value ? t('New article') : undefined"
|
||||
@action="openNewArticleModal"
|
||||
/>
|
||||
<ArticleListEmpty
|
||||
v-else-if="
|
||||
emptyVariant === 'no-results' ||
|
||||
emptyVariant === 'nothing-missing' ||
|
||||
emptyVariant === 'no-matches'
|
||||
"
|
||||
:variant="emptyVariant"
|
||||
:query="shown.q"
|
||||
:lang="shown.lang"
|
||||
@action="onEmptyAction"
|
||||
/>
|
||||
<EmptyState
|
||||
v-else-if="emptyVariant"
|
||||
:variant="emptyVariant"
|
||||
:query="emptyVariant === 'no-site' ? siteName : shown.q"
|
||||
@action="onEmptyAction"
|
||||
/>
|
||||
<ArticleListSkeleton v-else-if="skeletonActive" />
|
||||
<template v-else>
|
||||
<ArticleTable
|
||||
:articles="items"
|
||||
:loading="skeletonActive"
|
||||
:skeleton-rows="skeletonRows"
|
||||
<StoryTable
|
||||
v-if="desktop"
|
||||
:items="items"
|
||||
:mode="mode"
|
||||
:sort="effectiveSort(shown.sort)"
|
||||
:sites="severalSites ? sites : []"
|
||||
:default-lang="defaultLang"
|
||||
:busy-ids="busyIds"
|
||||
:handlers="menuHandlers"
|
||||
:lang="shown.lang"
|
||||
@sort="list.changeSort"
|
||||
@publish="handlePublish"
|
||||
@unpublish="handleUnpublish"
|
||||
@delete="handleDelete"
|
||||
@copy-url="handleCopyUrl"
|
||||
@add-translation="addTranslation"
|
||||
/>
|
||||
<ArticleCardList
|
||||
:articles="items"
|
||||
:loading="skeletonActive"
|
||||
:skeleton-rows="skeletonRows"
|
||||
<StoryCardList
|
||||
v-else
|
||||
:items="items"
|
||||
:mode="mode"
|
||||
:default-lang="defaultLang"
|
||||
:busy-ids="busyIds"
|
||||
@publish="handlePublish"
|
||||
@unpublish="handleUnpublish"
|
||||
@delete="handleDelete"
|
||||
@copy-url="handleCopyUrl"
|
||||
:handlers="menuHandlers"
|
||||
:lang="shown.lang"
|
||||
@add-translation="addTranslation"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -249,14 +365,18 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
v-if="access.canCreate.value"
|
||||
icon="i-lucide-plus"
|
||||
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover) sm:hidden"
|
||||
@click="openNewArticleModal"
|
||||
>
|
||||
{{ t('New article') }}
|
||||
</UButton>
|
||||
<ArticleFiltersSheet
|
||||
v-model:open="filtersOpen"
|
||||
:fields="fields"
|
||||
:total="total"
|
||||
:chip-count="chips.length"
|
||||
@update:site="list.changeSite"
|
||||
@update:lang="list.changeLang"
|
||||
@update:status="list.changeStatus"
|
||||
@update:author="list.changeAuthor"
|
||||
@update:seo="list.changeSeo"
|
||||
@clear="list.clearFilters"
|
||||
/>
|
||||
|
||||
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
|
||||
</DashboardShell>
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { customAccess } from '@/test/access'
|
||||
import {
|
||||
LIMITED_VIEWER,
|
||||
siteLanguagesBody,
|
||||
storyAuthorsBody,
|
||||
storyPageFor,
|
||||
type StoryViewer,
|
||||
} from '@/test/storyLists'
|
||||
import {
|
||||
callsTo,
|
||||
createMetaBackend,
|
||||
failWith,
|
||||
mountMeta,
|
||||
renewSites,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountMeta,
|
||||
type MetaBackend,
|
||||
} from './metaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const SEARCH_WAIT_MS = 320
|
||||
const SKELETON_WAIT_MS = 220
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
setLocale('en')
|
||||
await unmountMeta()
|
||||
})
|
||||
|
||||
function backendFor(viewer: StoryViewer = {}, reader = false): MetaBackend {
|
||||
const [first] = renewSites()
|
||||
const sites = ['journal', 'atelier', 'press'].map((key, index) => ({
|
||||
...first!,
|
||||
id: index + 1,
|
||||
key,
|
||||
name: key.charAt(0).toUpperCase() + key.slice(1),
|
||||
is_default: index === 0,
|
||||
}))
|
||||
const backend = createMetaBackend({ sites })
|
||||
if (reader) {
|
||||
backend.me.access = customAccess('content.read', 'Reader')
|
||||
}
|
||||
backend.responders['GET /v1/article/'] = (call) =>
|
||||
storyPageFor(new URL(call.raw, 'http://localhost'), viewer)
|
||||
backend.responders['GET /v1/site/journal/languages'] = () => siteLanguagesBody('journal')
|
||||
backend.responders['GET /v1/site/atelier/languages'] = () => siteLanguagesBody('atelier')
|
||||
backend.responders['GET /v1/author/'] = () => storyAuthorsBody()
|
||||
return backend
|
||||
}
|
||||
|
||||
function listCalls(backend: MetaBackend) {
|
||||
return callsTo(backend, 'GET', '/v1/article/')
|
||||
}
|
||||
|
||||
function lastCall(backend: MetaBackend) {
|
||||
return listCalls(backend).at(-1)!
|
||||
}
|
||||
|
||||
function unitLabel() {
|
||||
return textOf(document.body.querySelector('[data-testid="unit-label"]'))
|
||||
}
|
||||
|
||||
function tabs() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).map(textOf)
|
||||
}
|
||||
|
||||
function rowTitles() {
|
||||
return Array.from(document.body.querySelectorAll('[data-row-title]')).map(textOf)
|
||||
}
|
||||
|
||||
function coverageOf(title: string) {
|
||||
const row = Array.from(document.body.querySelectorAll<HTMLElement>('tbody tr, li')).find(
|
||||
(candidate) => textOf(candidate).includes(title),
|
||||
)!
|
||||
return Array.from(
|
||||
row.querySelectorAll<HTMLElement>('[data-testid="story-coverage"] [aria-label]'),
|
||||
).map((chip) => chip.getAttribute('aria-label'))
|
||||
}
|
||||
|
||||
function currentQuery() {
|
||||
return { ...router.currentRoute.value.query }
|
||||
}
|
||||
|
||||
function summary() {
|
||||
return document.body.querySelector('[data-testid="paginator-summary"]')?.textContent?.trim()
|
||||
}
|
||||
|
||||
async function openMenu(title: string) {
|
||||
document.body.querySelector<HTMLElement>(`button[aria-label="Actions for ${title}"]`)!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function menuLabels() {
|
||||
return Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)
|
||||
}
|
||||
|
||||
async function openSelect(label: string) {
|
||||
document.body.querySelector<HTMLElement>(`button[aria-label="${label}"]`)!.click()
|
||||
await flushPromises()
|
||||
return Array.from(document.body.querySelectorAll('[role="option"]')).map(textOf)
|
||||
}
|
||||
|
||||
async function chooseOption(label: string, option: string) {
|
||||
await openSelect(label)
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
.find((item) => textOf(item).startsWith(option))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function buttonNamed(label: string) {
|
||||
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('button')).find(
|
||||
(item) => textOf(item) === label || item.getAttribute('aria-label') === label,
|
||||
)
|
||||
}
|
||||
|
||||
describe('loc.l7.all, All languages, page 1', () => {
|
||||
it('shows 5 of 8 rows, the unit, two tabs and the pager', async () => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', '/article?site=journal&size=5', { backend })
|
||||
|
||||
expect(lastCall(backend).query.get('group')).toBe('story')
|
||||
expect(unitLabel()).toBe('8 articles')
|
||||
expect(tabs()).toEqual(['All articles 8', 'Needs translation 7'])
|
||||
expect(rowTitles()).toHaveLength(5)
|
||||
expect(summary()).toBe('Showing 1–5 of 8')
|
||||
})
|
||||
|
||||
it('renders the coverage chips in the order of the response, dashed for missing', async () => {
|
||||
await mountMeta('articles', '/article?site=journal&size=5', { backend: backendFor() })
|
||||
|
||||
expect(coverageOf('How we price a bathroom renovation')).toEqual([
|
||||
'English: published',
|
||||
'French: draft',
|
||||
'German: published',
|
||||
'Arabic: draft',
|
||||
])
|
||||
expect(coverageOf('Winter checklist for timber windows')).toEqual([
|
||||
'English: published',
|
||||
'French: published',
|
||||
'German: no version',
|
||||
'Arabic: no version',
|
||||
])
|
||||
})
|
||||
|
||||
it('names the language of a representative that is not the default', async () => {
|
||||
await mountMeta('articles', '/article?site=journal&page=2&size=5', {
|
||||
backend: backendFor(),
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(document.body)).toContain('Shown in Spanish · language disabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l7.page2, All languages, page 2', () => {
|
||||
it('shows the last 3 rows and keeps page in the URL', async () => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', '/article?site=journal&page=2&size=5', { backend })
|
||||
|
||||
expect(rowTitles()).toEqual([
|
||||
'Why we reuse original floorboards',
|
||||
'Our first passive-house retrofit',
|
||||
'Cómo elegir un contratista',
|
||||
])
|
||||
expect(summary()).toBe('Showing 6–8 of 8')
|
||||
expect(currentQuery()).toEqual({ site: 'journal', page: '2', size: '5' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l7.fr, French selected', () => {
|
||||
it('shows one row per French version, four tabs and the French unit', async () => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', '/article?site=journal&lang=fr&size=5', { backend })
|
||||
|
||||
expect(lastCall(backend).query.get('lang')).toBe('fr')
|
||||
expect(unitLabel()).toBe('4 French versions')
|
||||
expect(tabs()).toEqual(['All 4', 'Published 2', 'Drafts 2', 'Not in French 4'])
|
||||
expect(rowTitles()).toEqual([
|
||||
'Comment nous chiffrons une rénovation de salle de bain',
|
||||
"Guide de l'enduit à la chaux",
|
||||
"Liste d'hiver pour les fenêtres en bois",
|
||||
"Pourquoi nous réutilisons les lames de parquet d'origine",
|
||||
])
|
||||
expect(
|
||||
document.body.querySelector<HTMLInputElement>('input[type="search"]')!.placeholder,
|
||||
).toBe('Search French titles')
|
||||
})
|
||||
|
||||
it('asks for status=draft with the Drafts tab and drops page', async () => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', '/article?site=journal&lang=fr&size=5&page=1', { backend })
|
||||
|
||||
const tab = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).find(
|
||||
(item) => textOf(item).startsWith('Drafts'),
|
||||
)!
|
||||
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
tab.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(lastCall(backend).query.get('status')).toBe('draft')
|
||||
expect(unitLabel()).toBe('2 French drafts')
|
||||
expect(currentQuery()).toEqual({
|
||||
site: 'journal',
|
||||
lang: 'fr',
|
||||
size: '5',
|
||||
status: 'draft',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l7.frMissing, Not in French', () => {
|
||||
it('lists the articles without a French version with an Add French translation button', async () => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', '/article?site=journal&lang=fr&tab=missing&size=5', {
|
||||
backend,
|
||||
})
|
||||
|
||||
expect(lastCall(backend).query.get('tab')).toBe('missing')
|
||||
expect(unitLabel()).toBe('4 articles not in French')
|
||||
expect(rowTitles()).toEqual([
|
||||
'Spring newsletter',
|
||||
'Roof inspection checklist',
|
||||
'Our first passive-house retrofit',
|
||||
'Cómo elegir un contratista',
|
||||
])
|
||||
expect(textOf(document.body)).toContain('Opens English · draft')
|
||||
expect(textOf(document.body)).toContain('Opens Spanish · published · language disabled')
|
||||
expect(
|
||||
document.body.querySelectorAll('button[aria-label^="Add French translation to"]'),
|
||||
).toHaveLength(4)
|
||||
expect(tabs().at(-1)).toBe('Not in French 4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l7.emptyMissing, Nothing missing', () => {
|
||||
it('shows the nothing-missing state with no pager and no summary', async () => {
|
||||
await mountMeta('articles', '/article?site=press&lang=fr&tab=missing', {
|
||||
backend: backendFor(),
|
||||
})
|
||||
|
||||
expect(textOf(document.body)).toContain('Every article you can see has a French version')
|
||||
expect(textOf(document.body)).toContain(
|
||||
'Articles without a French version will appear here.',
|
||||
)
|
||||
expect(unitLabel()).toBe('0 articles not in French')
|
||||
expect(summary()).toBeUndefined()
|
||||
expect(document.body.querySelector('nav[aria-label="Pagination"]')).toBeNull()
|
||||
expect(document.body.querySelector('table')).toBeNull()
|
||||
})
|
||||
|
||||
it('names no language when none is selected', async () => {
|
||||
await mountMeta('articles', '/article?site=press&tab=missing', { backend: backendFor() })
|
||||
|
||||
expect(textOf(document.body)).toContain('Every article you can see is fully translated')
|
||||
expect(unitLabel()).toBe('0 articles need translation')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l7.noResults, No results', () => {
|
||||
it('shows the search empty state and every count at 0', async () => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', '/article?site=journal&q=zinc', { backend })
|
||||
|
||||
expect(textOf(document.body)).toContain('No articles match "zinc"')
|
||||
expect(textOf(document.body)).toContain(
|
||||
'Search covers titles in every language you can read.',
|
||||
)
|
||||
expect(tabs()).toEqual(['All articles 0', 'Needs translation 0'])
|
||||
expect(summary()).toBeUndefined()
|
||||
expect(lastCall(backend).query.get('q')).toBe('zinc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l7.frLoading, Loading', () => {
|
||||
it('shows the counts as a dash, skeleton rows and aria-busy while the request is pending', async () => {
|
||||
const backend = backendFor()
|
||||
backend.responders['GET /v1/article/'] = () => new Promise(() => undefined)
|
||||
await mountMeta('articles', '/article?site=journal&lang=fr', { backend })
|
||||
await new Promise((resolve) => setTimeout(resolve, SKELETON_WAIT_MS))
|
||||
await flushPromises()
|
||||
|
||||
expect(tabs()).toEqual(['All –', 'Published –', 'Drafts –', 'Not in French –'])
|
||||
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||
expect(unitLabel()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l7.listFail, Failed', () => {
|
||||
it('shows the failure with the filters kept and retries the same request', async () => {
|
||||
const backend = backendFor()
|
||||
backend.responders['GET /v1/article/'] = failWith(500)
|
||||
await mountMeta('articles', '/article?site=journal&lang=fr&q=chaux', { backend })
|
||||
|
||||
expect(textOf(document.body)).toContain("Couldn't load articles")
|
||||
expect(textOf(document.body)).toContain('Your filters and page are kept. Try again.')
|
||||
expect(tabs()).toEqual(['All –', 'Published –', 'Drafts –', 'Not in French –'])
|
||||
expect(document.body.querySelector<HTMLInputElement>('input[type="search"]')!.value).toBe(
|
||||
'chaux',
|
||||
)
|
||||
expect(currentQuery()).toEqual({ site: 'journal', lang: 'fr', q: 'chaux' })
|
||||
|
||||
backend.responders['GET /v1/article/'] = (call) =>
|
||||
storyPageFor(new URL(call.raw, 'http://localhost'))
|
||||
buttonNamed('Try again')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(textOf(document.body)).not.toContain("Couldn't load articles")
|
||||
expect(lastCall(backend).query.get('q')).toBe('chaux')
|
||||
expect(rowTitles()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l7.limited, Limited access', () => {
|
||||
it('shows no dashed chips, no Add translation item and the limited-access banner', async () => {
|
||||
const backend = backendFor(LIMITED_VIEWER, true)
|
||||
await mountMeta('articles', '/article?site=journal', { backend })
|
||||
|
||||
expect(textOf(document.body)).toContain('Showing only what you can read')
|
||||
expect(
|
||||
document.body.querySelectorAll('[data-testid="story-coverage"] button'),
|
||||
).toHaveLength(0)
|
||||
const chips = Array.from(
|
||||
document.body.querySelectorAll('[data-testid="story-coverage"] [aria-label]'),
|
||||
).map((chip) => chip.getAttribute('aria-label') ?? '')
|
||||
expect(chips.some((label) => label.includes('no version'))).toBe(false)
|
||||
await openMenu(rowTitles()[0]!)
|
||||
expect(menuLabels().some((label) => label.startsWith('Add translation'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l7.author, Author filter', () => {
|
||||
it('narrows to Marta Alves, shows the chip and clears everything with Clear all', async () => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', '/article?site=journal&author=1', { backend })
|
||||
|
||||
expect(lastCall(backend).query.get('author')).toBe('1')
|
||||
expect(unitLabel()).toBe('3 articles')
|
||||
const chips = Array.from(
|
||||
document.body.querySelectorAll('[data-testid="article-filter-chip"]'),
|
||||
).map(textOf)
|
||||
expect(chips).toEqual(['Site: Journal', 'Author: Marta Alves'])
|
||||
|
||||
buttonNamed('Clear all')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(currentQuery()).toEqual({})
|
||||
expect(lastCall(backend).query.has('author')).toBe(false)
|
||||
expect(lastCall(backend).query.has('site')).toBe(false)
|
||||
expect(document.body.querySelector('[data-testid="article-filter-chip"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('removes one chip and keeps the others', async () => {
|
||||
await mountMeta('articles', '/article?site=journal&author=1&status=draft', {
|
||||
backend: backendFor(),
|
||||
})
|
||||
|
||||
buttonNamed('Remove filter Author: Marta Alves')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(currentQuery()).toEqual({ site: 'journal', status: 'draft' })
|
||||
})
|
||||
|
||||
it('picks an author from the Author select with the counts of articles', async () => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', '/article?site=journal', { backend })
|
||||
|
||||
expect(await openSelect('Author')).toEqual([
|
||||
'Any author',
|
||||
'Marta Alves 3',
|
||||
'Camille Roche 2',
|
||||
'Luis Ferrer 3',
|
||||
])
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
.find((item) => textOf(item).startsWith('Marta'))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(currentQuery().author).toBe('1')
|
||||
expect(lastCall(backend).query.get('page')).toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l7.langFilter, Language filter', () => {
|
||||
it('groups the Enabled and Disabled languages with their version counts', async () => {
|
||||
await mountMeta('articles', '/article?site=journal', { backend: backendFor() })
|
||||
|
||||
const options = await openSelect('Language')
|
||||
|
||||
expect(options).toEqual([
|
||||
'All languages',
|
||||
'English 7 versions',
|
||||
'French 4 versions',
|
||||
'German 4 versions',
|
||||
'Arabic 1 version',
|
||||
'Spanish 1 version',
|
||||
'Italian 0 versions',
|
||||
])
|
||||
expect(textOf(document.body)).toContain('Enabled')
|
||||
expect(textOf(document.body)).toContain('Disabled')
|
||||
expect(textOf(document.body)).toContain(
|
||||
'Disabled languages keep their versions. They can’t get new ones.',
|
||||
)
|
||||
})
|
||||
|
||||
it('selects a language, drops the page and shows the French tabs', async () => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', '/article?site=journal&page=2&size=5', { backend })
|
||||
|
||||
await chooseOption('Language', 'French')
|
||||
|
||||
expect(currentQuery()).toEqual({ site: 'journal', size: '5', lang: 'fr' })
|
||||
expect(lastCall(backend).query.get('page')).toBe('1')
|
||||
expect(tabs()).toEqual(['All 4', 'Published 2', 'Drafts 2', 'Not in French 4'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l7.rowMenu, Row menu', () => {
|
||||
const expected = [
|
||||
'Open English',
|
||||
'Open another language…',
|
||||
'Add translation…',
|
||||
'Unpublish English',
|
||||
'Copy API URL',
|
||||
'Delete English version',
|
||||
]
|
||||
|
||||
it('lists the same actions on desktop', async () => {
|
||||
await mountMeta('articles', '/article?site=journal&size=5', { backend: backendFor() })
|
||||
await openMenu('Winter checklist for timber windows')
|
||||
|
||||
expect(menuLabels()).toEqual(expected)
|
||||
})
|
||||
|
||||
it('lists the same actions on mobile', async () => {
|
||||
await mountMeta('articles', '/article?site=journal&size=5', {
|
||||
width: MOBILE_WIDTH,
|
||||
backend: backendFor(),
|
||||
})
|
||||
await openMenu('Winter checklist for timber windows')
|
||||
|
||||
expect(menuLabels()).toEqual(expected)
|
||||
})
|
||||
|
||||
it('hides Add translation on an article that has every language', async () => {
|
||||
await mountMeta('articles', '/article?site=journal&size=5', { backend: backendFor() })
|
||||
await openMenu('How we price a bathroom renovation')
|
||||
|
||||
expect(menuLabels()).not.toContain('Add translation…')
|
||||
expect(menuLabels()).toContain('Open another language…')
|
||||
})
|
||||
|
||||
it('gives every row a menu button', async () => {
|
||||
await mountMeta('articles', '/article?site=journal&lang=fr&tab=missing&size=5', {
|
||||
backend: backendFor(),
|
||||
})
|
||||
|
||||
expect(document.body.querySelectorAll('button[aria-label^="Actions for"]')).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l7.filters, Mobile filter sheet', () => {
|
||||
it('opens a sheet with Journal, French and Draft and shows the result count', async () => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', '/article?site=journal&lang=fr&status=draft', {
|
||||
width: MOBILE_WIDTH,
|
||||
backend,
|
||||
})
|
||||
|
||||
const trigger = document.body.querySelector<HTMLElement>(
|
||||
'button[aria-label="Filters, 3 active"]',
|
||||
)!
|
||||
expect(textOf(trigger)).toBe('Filters · 3')
|
||||
expect(document.body.querySelector('button[aria-label="Language"]')).toBeNull()
|
||||
trigger.click()
|
||||
await flushPromises()
|
||||
|
||||
const sheet = document.body.querySelector<HTMLElement>(
|
||||
'[data-testid="article-filters-sheet"]',
|
||||
)!
|
||||
expect(
|
||||
['Site', 'Language', 'Status', 'Author', 'SEO'].every((label) =>
|
||||
textOf(sheet).includes(label),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(sheet.querySelector('button[aria-label="Language"]')!.textContent).toContain(
|
||||
'French',
|
||||
)
|
||||
expect(sheet.querySelector('button[aria-label="Status"]')!.textContent).toContain('Draft')
|
||||
expect(buttonNamed('Clear all')).toBeDefined()
|
||||
expect(buttonNamed('Show 2 results')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l7.tabsScroll, Tabs scrolled', () => {
|
||||
it('keeps Not in French reachable with the scroll buttons beside the tabs', async () => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', '/article?site=journal&lang=fr', {
|
||||
width: MOBILE_WIDTH,
|
||||
backend,
|
||||
})
|
||||
|
||||
expect(buttonNamed('Scroll tabs left')).toBeDefined()
|
||||
expect(buttonNamed('Scroll tabs right')).toBeDefined()
|
||||
const tab = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).find(
|
||||
(item) => textOf(item).startsWith('Not in French'),
|
||||
)!
|
||||
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
tab.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(lastCall(backend).query.get('tab')).toBe('missing')
|
||||
expect(currentQuery()).toEqual({ site: 'journal', lang: 'fr', tab: 'missing' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('loc.l7.long, Eight languages', () => {
|
||||
it('collapses the chips to +3 on desktop and +4 on mobile with named overflow chips', async () => {
|
||||
await mountMeta('articles', '/article?site=atelier&status=draft&seo=suggestions', {
|
||||
backend: backendFor(),
|
||||
})
|
||||
const desktopOverflow = document.body.querySelector<HTMLElement>(
|
||||
'[data-testid="story-coverage"] [aria-label^="3 more languages"]',
|
||||
)!
|
||||
expect(textOf(desktopOverflow)).toBe('+3')
|
||||
expect(desktopOverflow.getAttribute('aria-label')).toBe(
|
||||
'3 more languages: Chinese (Simplified), Japanese, Arabic',
|
||||
)
|
||||
await unmountMeta()
|
||||
|
||||
await mountMeta('articles', '/article?site=atelier&status=draft&seo=suggestions', {
|
||||
width: MOBILE_WIDTH,
|
||||
backend: backendFor(),
|
||||
})
|
||||
const mobileOverflow = document.body.querySelector<HTMLElement>(
|
||||
'[data-testid="story-coverage"] [aria-label$="Arabic"]',
|
||||
)!
|
||||
expect(textOf(mobileOverflow)).toBe('+4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL state', () => {
|
||||
it.each([
|
||||
{ query: 'lang=english', expected: {} },
|
||||
{ query: 'tab=review', expected: {} },
|
||||
{ query: 'author=abc', expected: {} },
|
||||
{ query: 'page=abc', expected: {} },
|
||||
{ query: 'lang=fr&tab=missing&status=draft', expected: { lang: 'fr', tab: 'missing' } },
|
||||
])('falls back from ?$query and replaces the URL', async ({ query, expected }) => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', `/article?site=journal&${query}`, { backend })
|
||||
|
||||
expect(currentQuery()).toEqual({ site: 'journal', ...expected })
|
||||
const first = listCalls(backend)[0]!.query
|
||||
expect(first.get('group')).toBe('story')
|
||||
expect(first.get('tab')).toBe(expected.tab ?? null)
|
||||
})
|
||||
|
||||
it('drops a language the site does not have once the languages are known', async () => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', '/article?site=journal&lang=pt', { backend })
|
||||
await flushPromises()
|
||||
|
||||
expect(currentQuery()).toEqual({ site: 'journal' })
|
||||
expect(lastCall(backend).query.has('lang')).toBe(false)
|
||||
})
|
||||
|
||||
it('drops page on every filter change', async () => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', '/article?site=journal&page=2&size=5', { backend })
|
||||
|
||||
await chooseOption('Status', 'Draft')
|
||||
expect(currentQuery().page).toBeUndefined()
|
||||
expect(lastCall(backend).query.get('page')).toBe('1')
|
||||
|
||||
await chooseOption('Language', 'French')
|
||||
expect(currentQuery().page).toBeUndefined()
|
||||
|
||||
await typeInto(
|
||||
document.body.querySelector<HTMLInputElement>('input[type="search"]')!,
|
||||
'chaux',
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS))
|
||||
await flushPromises()
|
||||
expect(currentQuery()).toMatchObject({ q: 'chaux', lang: 'fr', status: 'draft' })
|
||||
expect(currentQuery().page).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads the language names in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountMeta('articles', '/article?site=journal&lang=fr&tab=missing&size=5', {
|
||||
backend: backendFor(),
|
||||
})
|
||||
|
||||
expect(unitLabel()).toBe('4 articles sans version en français')
|
||||
expect(tabs().at(-1)).toBe('Pas en français 4')
|
||||
})
|
||||
|
||||
it('sends group=story on the very first request and nothing for the hidden review tab', async () => {
|
||||
const backend = backendFor()
|
||||
await mountMeta('articles', '/article?tab=review', { backend })
|
||||
|
||||
expect(tabs().some((label) => label.startsWith('Needs review'))).toBe(false)
|
||||
expect(listCalls(backend)[0]!.query.get('tab')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { customAccess, writerAccess } from '@/test/access'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
import { writerAccess } from '@/test/access'
|
||||
import type { ArticleCapability } from '@/composables/useArticles'
|
||||
import {
|
||||
articleOf,
|
||||
createMetaBackend,
|
||||
@@ -27,18 +26,17 @@ beforeEach(() => {
|
||||
afterEach(unmountMeta)
|
||||
|
||||
const SESSION_USER = 1
|
||||
const EVERYTHING: ArticleCapability[] = ['read', 'edit', 'publish', 'delete', 'create_translation']
|
||||
|
||||
function grants(publish: string, remove: string, update: string): Access {
|
||||
return customAccess(
|
||||
`content.read:article=all, content.create:article=all, content.publish:article=${publish}, content.delete:article=${remove}, content.update:article=${update}`,
|
||||
'Contributor',
|
||||
)
|
||||
}
|
||||
|
||||
async function mountArticles(access: Access, articles = [articleOf(1, 'Article 1')]) {
|
||||
async function mountArticles(
|
||||
capabilitiesFor: MetaBackend['capabilitiesFor'],
|
||||
articles = [articleOf(1, 'Article 1')],
|
||||
configure: (backend: MetaBackend) => void = () => undefined,
|
||||
) {
|
||||
const backend: MetaBackend = createMetaBackend()
|
||||
backend.articles = articles
|
||||
backend.me.access = access
|
||||
backend.capabilitiesFor = capabilitiesFor
|
||||
configure(backend)
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
return backend
|
||||
}
|
||||
@@ -48,103 +46,79 @@ async function openMenu(title: string) {
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function menuItem(label: string) {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(
|
||||
(item) => textOf(item).startsWith(label),
|
||||
function menuLabels() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')).map(
|
||||
(item) => textOf(item),
|
||||
)
|
||||
}
|
||||
|
||||
function isDisabled(item: HTMLElement | undefined) {
|
||||
return item?.getAttribute('aria-disabled') === 'true' || item?.hasAttribute('data-disabled')
|
||||
}
|
||||
|
||||
describe('Article controls under all and none', () => {
|
||||
it('offers every control to a role that holds all of them', async () => {
|
||||
await mountArticles(grants('all', 'all', 'all'))
|
||||
describe('Row actions follow the capabilities of the response', () => {
|
||||
it('offers every action when the row carries every capability', async () => {
|
||||
await mountArticles(() => EVERYTHING)
|
||||
await openMenu('Article 1')
|
||||
|
||||
expect(menuItem('Publish')).toBeDefined()
|
||||
expect(isDisabled(menuItem('Delete'))).toBe(false)
|
||||
expect(isDisabled(menuItem('Edit'))).toBe(false)
|
||||
expect(menuLabels()).toEqual(['Edit', 'Publish', 'Copy API URL', 'Delete…'])
|
||||
expect(textOf(document.body)).not.toContain('Ask an editor to publish.')
|
||||
})
|
||||
|
||||
it('hides Publish, keeps the Draft status with the editor hint and disables Delete', async () => {
|
||||
await mountArticles(grants('none', 'none', 'all'))
|
||||
it('hides Publish and Delete and keeps the editor hint on a draft without those capabilities', async () => {
|
||||
await mountArticles(() => ['read', 'edit'])
|
||||
|
||||
expect(document.body.querySelector('[data-testid="publish-hint"]')?.textContent).toBe(
|
||||
'Ask an editor to publish.',
|
||||
)
|
||||
await openMenu('Article 1')
|
||||
expect(menuItem('Publish')).toBeUndefined()
|
||||
expect(isDisabled(menuItem('Delete'))).toBe(true)
|
||||
expect(textOf(document.body)).toContain("Your role can't delete articles.")
|
||||
expect(isDisabled(menuItem('Edit'))).toBe(false)
|
||||
expect(menuLabels()).toEqual(['Edit', 'Copy API URL'])
|
||||
})
|
||||
|
||||
it('disables Edit as well when the role cannot update', async () => {
|
||||
await mountArticles(grants('none', 'none', 'none'))
|
||||
it('keeps only Open and Copy API URL on a read-only row', async () => {
|
||||
await mountArticles(() => ['read'])
|
||||
await openMenu('Article 1')
|
||||
|
||||
expect(isDisabled(menuItem('Edit'))).toBe(true)
|
||||
expect(textOf(document.body)).toContain("Your role can't edit articles.")
|
||||
expect(menuLabels()).toEqual(['Edit', 'Copy API URL'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('The Writer built-in Articles list', () => {
|
||||
it('reads the Writer header, enables Edit and disables Delete with its reason', async () => {
|
||||
await mountArticles(writerAccess())
|
||||
it('reads the Writer header and the server decides the row actions', async () => {
|
||||
await mountArticles(
|
||||
() => ['read', 'edit', 'create_translation'],
|
||||
undefined,
|
||||
(backend) => {
|
||||
backend.me.access = writerAccess()
|
||||
},
|
||||
)
|
||||
|
||||
expect(textOf(document.body)).toContain(
|
||||
'You can write and edit all articles. Publishing is done by an editor.',
|
||||
)
|
||||
await openMenu('Article 1')
|
||||
expect(isDisabled(menuItem('Edit'))).toBe(false)
|
||||
expect(isDisabled(menuItem('Delete'))).toBe(true)
|
||||
expect(textOf(document.body)).toContain("Your role can't delete articles.")
|
||||
expect(menuItem('Publish')).toBeUndefined()
|
||||
expect(menuLabels()).toEqual(['Edit', 'Copy API URL'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Article controls under own scope', () => {
|
||||
const OWN = grants('own', 'own', 'own')
|
||||
describe('Article actions on the rows of other people', () => {
|
||||
const articles = [
|
||||
articleOf(1, 'Article 1', { created_by: SESSION_USER }),
|
||||
articleOf(2, 'Article 2', { created_by: 9 }),
|
||||
]
|
||||
const own = (article: { created_by?: number }): ArticleCapability[] =>
|
||||
article.created_by === SESSION_USER ? EVERYTHING : ['read']
|
||||
|
||||
it('makes Edit, Delete and Publish available on the article the person created', async () => {
|
||||
await mountArticles(OWN, articles)
|
||||
it('shows the actions the response grants on the article the person created', async () => {
|
||||
await mountArticles(own, articles)
|
||||
await openMenu('Article 1')
|
||||
|
||||
expect(isDisabled(menuItem('Edit'))).toBe(false)
|
||||
expect(isDisabled(menuItem('Delete'))).toBe(false)
|
||||
expect(menuItem('Publish')).toBeDefined()
|
||||
expect(menuLabels()).toEqual(['Edit', 'Publish', 'Copy API URL', 'Delete…'])
|
||||
})
|
||||
|
||||
it('disables Edit and Delete and hides Publish on an article by someone else', async () => {
|
||||
await mountArticles(OWN, articles)
|
||||
it('hides Publish and Delete on an article by someone else', async () => {
|
||||
await mountArticles(own, articles)
|
||||
await openMenu('Article 2')
|
||||
|
||||
expect(isDisabled(menuItem('Edit'))).toBe(true)
|
||||
expect(isDisabled(menuItem('Delete'))).toBe(true)
|
||||
expect(textOf(document.body)).toContain('You can only edit content you created.')
|
||||
expect(menuItem('Publish')).toBeUndefined()
|
||||
expect(menuLabels()).toEqual(['Edit', 'Copy API URL'])
|
||||
expect(document.body.querySelector('tr [data-testid="publish-hint"]')?.textContent).toBe(
|
||||
'Ask an editor to publish.',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('A stale menu', () => {
|
||||
it('disables Delete with its reason once the session loses the permission', async () => {
|
||||
await mountArticles(grants('all', 'all', 'all'))
|
||||
const session = useSessionStore()
|
||||
session.session = { ...session.session!, access: grants('all', 'none', 'all') }
|
||||
await flushPromises()
|
||||
await openMenu('Article 1')
|
||||
|
||||
expect(isDisabled(menuItem('Delete'))).toBe(true)
|
||||
expect(textOf(document.body)).toContain("Your role can't delete articles.")
|
||||
})
|
||||
})
|
||||
@@ -7,8 +7,9 @@ import { useSessionStore } from '@/stores/session'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { articleEnvelope, articleListRoute } from './articleEnvelope'
|
||||
import { storyEnvelope, storyListRoute } from './articleEnvelope'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
|
||||
const NOW = new Date('2026-08-26T10:00:00Z')
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
@@ -133,7 +134,10 @@ function sixArticleFixture(): Article[] {
|
||||
|
||||
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
|
||||
async function mountArticles(handlers: Record<string, () => Response | Promise<Response>>) {
|
||||
async function mountArticles(
|
||||
handlers: Record<string, () => Response | Promise<Response>>,
|
||||
path = '/article',
|
||||
) {
|
||||
setActivePinia(createPinia())
|
||||
overlayMock = createOverlayMock()
|
||||
toastAdd = vi.fn()
|
||||
@@ -143,7 +147,7 @@ async function mountArticles(handlers: Record<string, () => Response | Promise<R
|
||||
global.fetch = fetchRouter({
|
||||
...handlers,
|
||||
}) as unknown as typeof fetch
|
||||
await router.push('/article')
|
||||
await router.push(path)
|
||||
const wrapper = mount(ArticleView, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
@@ -159,21 +163,6 @@ function findButton(wrapper: Awaited<ReturnType<typeof mount>>, label: string) {
|
||||
return wrapper.findAll('button').find((button) => button.text().trim() === label)
|
||||
}
|
||||
|
||||
async function clickTab(label: string) {
|
||||
const button = Array.from(document.body.querySelectorAll('button')).find(
|
||||
(el) => el.textContent?.trim() === label,
|
||||
) as HTMLButtonElement | undefined
|
||||
button?.focus()
|
||||
button?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function openRowMenu(wrapper: Awaited<ReturnType<typeof mount>>, title: string) {
|
||||
const button = document.body.querySelector<HTMLButtonElement>(
|
||||
`button[aria-label="Actions for ${title}"]`,
|
||||
@@ -191,6 +180,7 @@ async function chooseMenuItem(label: string) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
@@ -205,12 +195,12 @@ afterEach(async () => {
|
||||
describe('Default — the list loads sorted by last updated, newest first', () => {
|
||||
it('shows the breadcrumb, tab counts and every row in fixture order', async () => {
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('All 6')
|
||||
expect(wrapper.text()).toContain('Online 4')
|
||||
expect(wrapper.text()).toContain('Drafts 2')
|
||||
expect(wrapper.text()).toContain('All articles 6')
|
||||
expect(wrapper.text()).toContain('Needs translation 0')
|
||||
expect(wrapper.find('[data-testid="unit-label"]').text()).toBe('6 articles')
|
||||
|
||||
const titles = sixArticleFixture().map((a) => a.title)
|
||||
const rendered = wrapper.text()
|
||||
@@ -231,10 +221,10 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
|
||||
it('shows skeleton rows and aria-busy, then replaces them once resolved', async () => {
|
||||
let resolveList: (() => void) | null = null
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () =>
|
||||
[storyListRoute()]: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveList = () =>
|
||||
resolve(mockResponse(200, articleEnvelope(sixArticleFixture())))
|
||||
resolve(mockResponse(200, storyEnvelope(sixArticleFixture())))
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -253,7 +243,7 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
|
||||
describe('Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…', () => {
|
||||
it('opens a menu named "Actions for Winter checklist for timber windows"', async () => {
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
|
||||
})
|
||||
|
||||
await openRowMenu(wrapper, 'Winter checklist for timber windows')
|
||||
@@ -274,7 +264,7 @@ describe('Copy API URL succeeds — the absolute URL is copied and confirmed', (
|
||||
})
|
||||
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
|
||||
})
|
||||
|
||||
await openRowMenu(wrapper, 'Winter checklist for timber windows')
|
||||
@@ -301,7 +291,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
|
||||
})
|
||||
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
|
||||
})
|
||||
|
||||
await openRowMenu(wrapper, 'Winter checklist for timber windows')
|
||||
@@ -319,12 +309,10 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
|
||||
describe('Empty — zero articles', () => {
|
||||
it('shows the no-articles empty state and opens NewArticleModal', async () => {
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope([])),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope([])),
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('All 0')
|
||||
expect(wrapper.text()).toContain('Online 0')
|
||||
expect(wrapper.text()).toContain('Drafts 0')
|
||||
expect(wrapper.text()).toContain('All articles 0')
|
||||
expect(wrapper.text()).toContain('No articles yet')
|
||||
expect(wrapper.text()).toContain(
|
||||
'Write your first one. It stays a draft until you publish it.',
|
||||
@@ -340,20 +328,20 @@ describe('Empty — zero articles', () => {
|
||||
describe('Initial load failure — an error must not render the no-articles empty state', () => {
|
||||
it('does not show "No articles yet" when GET /v1/article/ fails', async () => {
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(500),
|
||||
[storyListRoute()]: () => mockResponse(500),
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('No articles yet')
|
||||
})
|
||||
})
|
||||
|
||||
describe('No results — a search matches nothing, but the tab counts still show the full list', () => {
|
||||
describe('No results — a search matches nothing and the counts follow the search', () => {
|
||||
it('shows the no-results empty state, then clears the search', async () => {
|
||||
const all = sixArticleFixture()
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(all)),
|
||||
[articleListRoute({ q: 'tiles' })]: () =>
|
||||
mockResponse(200, articleEnvelope(all, { q: 'tiles' })),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope(all)),
|
||||
[storyListRoute({ q: 'tiles' })]: () =>
|
||||
mockResponse(200, storyEnvelope(all, { q: 'tiles' })),
|
||||
})
|
||||
|
||||
const searchInput = wrapper.find('input[type="search"]')
|
||||
@@ -362,38 +350,32 @@ describe('No results — a search matches nothing, but the tab counts still show
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('No articles match "tiles"')
|
||||
expect(wrapper.text()).toContain(
|
||||
'Check the spelling, or search for a shorter part of the title.',
|
||||
)
|
||||
expect(wrapper.text()).toContain('All 6')
|
||||
expect(wrapper.text()).toContain('Online 4')
|
||||
expect(wrapper.text()).toContain('Drafts 2')
|
||||
expect(wrapper.text()).toContain('Search covers titles in every language you can read.')
|
||||
expect(wrapper.text()).toContain('All articles 0')
|
||||
|
||||
await findButton(wrapper, 'Clear search')?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('How we price a bathroom renovation')
|
||||
const inputEl = document.querySelector('input[placeholder="Search by title"]')
|
||||
const inputEl = document.querySelector('input[type="search"]')
|
||||
expect(inputEl).toBe(document.activeElement)
|
||||
})
|
||||
})
|
||||
|
||||
describe('An empty tab shows its own EmptyState, while the other tab still counts correctly', () => {
|
||||
describe('An empty status filter shows its own EmptyState and the counts stay', () => {
|
||||
it('shows No drafts when every article is online', async () => {
|
||||
const onlineOnly = sixArticleFixture()
|
||||
.slice(0, 4)
|
||||
.map((a) => ({ ...a, is_online: true }))
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(onlineOnly)),
|
||||
[articleListRoute({ status: 'draft' })]: () =>
|
||||
mockResponse(200, articleEnvelope(onlineOnly, { status: 'draft' })),
|
||||
})
|
||||
const wrapper = await mountArticles(
|
||||
{
|
||||
[storyListRoute({ status: 'draft' })]: () =>
|
||||
mockResponse(200, storyEnvelope(onlineOnly, { status: 'draft' })),
|
||||
},
|
||||
'/article?status=draft',
|
||||
)
|
||||
|
||||
await clickTab('Drafts 0')
|
||||
|
||||
expect(wrapper.text()).toContain('All 4')
|
||||
expect(wrapper.text()).toContain('Online 4')
|
||||
expect(wrapper.text()).toContain('Drafts 0')
|
||||
expect(wrapper.text()).toContain('All articles 4')
|
||||
expect(wrapper.text()).toContain('No drafts')
|
||||
expect(wrapper.text()).toContain("Everything you've written is online.")
|
||||
})
|
||||
@@ -402,17 +384,15 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
|
||||
const draftsOnly = sixArticleFixture()
|
||||
.slice(0, 4)
|
||||
.map((a) => ({ ...a, is_online: false }))
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(draftsOnly)),
|
||||
[articleListRoute({ status: 'online' })]: () =>
|
||||
mockResponse(200, articleEnvelope(draftsOnly, { status: 'online' })),
|
||||
})
|
||||
const wrapper = await mountArticles(
|
||||
{
|
||||
[storyListRoute({ status: 'online' })]: () =>
|
||||
mockResponse(200, storyEnvelope(draftsOnly, { status: 'online' })),
|
||||
},
|
||||
'/article?status=online',
|
||||
)
|
||||
|
||||
await clickTab('Online 0')
|
||||
|
||||
expect(wrapper.text()).toContain('All 4')
|
||||
expect(wrapper.text()).toContain('Online 0')
|
||||
expect(wrapper.text()).toContain('Drafts 4')
|
||||
expect(wrapper.text()).toContain('All articles 4')
|
||||
expect(wrapper.text()).toContain('Nothing is online yet')
|
||||
expect(wrapper.text()).toContain('Publish a draft and it will appear on your website.')
|
||||
})
|
||||
@@ -421,7 +401,7 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
|
||||
describe('Action in progress — publishing a row disables only that row', () => {
|
||||
it('sends the PATCH and shows the busy label for that row alone', async () => {
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
|
||||
'PATCH /v1/article/4/publish?format=html': () => new Promise(() => {}),
|
||||
})
|
||||
|
||||
@@ -441,7 +421,7 @@ describe('Published — a 200 updates the row in place and raises a toast', () =
|
||||
updated_at: iso(0),
|
||||
}
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
|
||||
'PATCH /v1/article/4/publish?format=html': () => mockResponse(200, updated),
|
||||
})
|
||||
|
||||
@@ -469,7 +449,7 @@ describe('Action failed, publish — a 500 or a network error leaves the row unc
|
||||
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
|
||||
const patchCall = vi.fn(handler)
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
|
||||
'PATCH /v1/article/4/publish?format=html': patchCall,
|
||||
})
|
||||
|
||||
@@ -508,7 +488,7 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
|
||||
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
|
||||
const patchCall = vi.fn(handler)
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
|
||||
'PATCH /v1/article/3/unpublish?format=html': patchCall,
|
||||
})
|
||||
|
||||
@@ -537,9 +517,13 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
|
||||
|
||||
describe('Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast', () => {
|
||||
it('removes the row on a 404 publish response', async () => {
|
||||
let current = sixArticleFixture()
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
|
||||
'PATCH /v1/article/2/publish?format=html': () => mockResponse(404),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope(current)),
|
||||
'PATCH /v1/article/2/publish?format=html': () => {
|
||||
current = current.filter((article) => article.id !== 2)
|
||||
return mockResponse(404)
|
||||
},
|
||||
})
|
||||
|
||||
await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls')
|
||||
@@ -547,9 +531,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Choosing lime plaster for old stone walls')
|
||||
expect(wrapper.text()).toContain('All 5')
|
||||
expect(wrapper.text()).toContain('Online 4')
|
||||
expect(wrapper.text()).toContain('Drafts 1')
|
||||
expect(wrapper.text()).toContain('All articles 5')
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
id: 'article-not-found-2',
|
||||
color: 'info',
|
||||
@@ -560,9 +542,13 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
|
||||
})
|
||||
|
||||
it('removes the row when the delete confirmation resolves 404', async () => {
|
||||
let current = sixArticleFixture()
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
|
||||
'DELETE /v1/article/2': () => mockResponse(404),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope(current)),
|
||||
'DELETE /v1/article/2': () => {
|
||||
current = current.filter((article) => article.id !== 2)
|
||||
return mockResponse(404)
|
||||
},
|
||||
})
|
||||
|
||||
await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls')
|
||||
@@ -571,6 +557,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
|
||||
|
||||
const props = overlayMock.getProps() as { action: () => Promise<void> }
|
||||
await props.action()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Choosing lime plaster for old stone walls')
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
@@ -585,9 +572,13 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
|
||||
|
||||
describe('Row vanished, online row — a 404 on unpublish removes the row and shows an info toast', () => {
|
||||
it('removes the row and updates the tab counts', async () => {
|
||||
let current = sixArticleFixture()
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
|
||||
'PATCH /v1/article/3/unpublish?format=html': () => mockResponse(404),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope(current)),
|
||||
'PATCH /v1/article/3/unpublish?format=html': () => {
|
||||
current = current.filter((article) => article.id !== 3)
|
||||
return mockResponse(404)
|
||||
},
|
||||
})
|
||||
|
||||
await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen')
|
||||
@@ -595,9 +586,7 @@ describe('Row vanished, online row — a 404 on unpublish removes the row and sh
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Before and after: the Rue Verte kitchen')
|
||||
expect(wrapper.text()).toContain('All 5')
|
||||
expect(wrapper.text()).toContain('Online 3')
|
||||
expect(wrapper.text()).toContain('Drafts 2')
|
||||
expect(wrapper.text()).toContain('All articles 5')
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
id: 'article-not-found-3',
|
||||
color: 'info',
|
||||
@@ -611,7 +600,7 @@ describe('Row vanished, online row — a 404 on unpublish removes the row and sh
|
||||
describe('Delete confirm — opens the dialog with the row title and image count', () => {
|
||||
it('opens ConfirmDialog with the title and 3 images', async () => {
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
|
||||
})
|
||||
|
||||
await openRowMenu(wrapper, 'Winter checklist for timber windows')
|
||||
@@ -631,9 +620,13 @@ describe('Delete confirm — opens the dialog with the row title and image count
|
||||
|
||||
describe('Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row', () => {
|
||||
it('removes the row, recomputes tabs and raises the success toast', async () => {
|
||||
let current = sixArticleFixture()
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
|
||||
'DELETE /v1/article/4': () => mockResponse(204),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope(current)),
|
||||
'DELETE /v1/article/4': () => {
|
||||
current = current.filter((article) => article.id !== 4)
|
||||
return mockResponse(204)
|
||||
},
|
||||
})
|
||||
|
||||
await openRowMenu(wrapper, 'Winter checklist for timber windows')
|
||||
@@ -645,9 +638,7 @@ describe('Deleted — a 204 closes the dialog, removes the row, and moves focus
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Winter checklist for timber windows')
|
||||
expect(wrapper.text()).toContain('All 5')
|
||||
expect(wrapper.text()).toContain('Online 4')
|
||||
expect(wrapper.text()).toContain('Drafts 1')
|
||||
expect(wrapper.text()).toContain('All articles 5')
|
||||
expect(toastAdd).toHaveBeenCalledWith({
|
||||
id: 'article-deleted-4',
|
||||
color: 'success',
|
||||
@@ -661,7 +652,7 @@ describe('Deleted — a 204 closes the dialog, removes the row, and moves focus
|
||||
describe('Delete failed — a 500 keeps the article and rejects with the inline message', () => {
|
||||
it('throws the inline error message and leaves the article in the list', async () => {
|
||||
const wrapper = await mountArticles({
|
||||
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
|
||||
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
|
||||
'DELETE /v1/article/4': () => mockResponse(500),
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +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, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
import { storyEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
const KEY = 'renewcms-page-size-articles'
|
||||
@@ -53,7 +53,7 @@ function installFetch() {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: () => null },
|
||||
json: async () => articleEnvelope(articles, params),
|
||||
json: async () => storyEnvelope(articles, params),
|
||||
} as unknown as Response)
|
||||
}
|
||||
throw new Error(`Unexpected fetch call: ${method} ${url}`)
|
||||
|
||||
@@ -5,11 +5,11 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import type { Article, ArticlePage } from '@/composables/useArticles'
|
||||
import type { Article, StoryPage } from '@/composables/useArticles'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
import { storyEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
const TOTAL = 312
|
||||
@@ -77,7 +77,7 @@ let deleteHandler: (id: number) => Response | Promise<Response>
|
||||
let wrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
|
||||
function respondFromFixture(params: EnvelopeParams) {
|
||||
return mockResponse(200, articleEnvelope(articles, params))
|
||||
return mockResponse(200, storyEnvelope(articles, params))
|
||||
}
|
||||
|
||||
function installFetch() {
|
||||
@@ -256,7 +256,7 @@ describe('3 Last page', () => {
|
||||
})
|
||||
|
||||
describe('4 Single page', () => {
|
||||
it('keeps the summary and the page-size select, hides the paginator and reads the tab counts', async () => {
|
||||
it('keeps the summary and the page-size select, hides the paginator and reads the tab count', async () => {
|
||||
articles = buildArticles(6).map((article, index) => ({
|
||||
...article,
|
||||
is_online: index < 4,
|
||||
@@ -266,9 +266,7 @@ describe('4 Single page', () => {
|
||||
expect(summary()).toBe('Showing 1–6 of 6')
|
||||
expect(document.body.querySelector('nav[aria-label="Pagination"]')).toBeNull()
|
||||
expect(pageSizeTrigger()).not.toBeNull()
|
||||
expect(wrapper.text()).toContain('All 6')
|
||||
expect(wrapper.text()).toContain('Online 4')
|
||||
expect(wrapper.text()).toContain('Drafts 2')
|
||||
expect(wrapper.text()).toContain('All articles 6')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -351,7 +349,7 @@ describe('7a A shrinking server cannot loop the last-page redirect', () => {
|
||||
listHandler = (params) => {
|
||||
calls += 1
|
||||
return mockResponse(200, {
|
||||
...articleEnvelope(articles, params),
|
||||
...storyEnvelope(articles, params),
|
||||
items: [],
|
||||
total: 25 * (30 - calls),
|
||||
})
|
||||
@@ -375,7 +373,7 @@ describe('7b Out of range with total 0 shows the empty state, no redirect', () =
|
||||
})
|
||||
|
||||
describe('8 Search resets to page 1', () => {
|
||||
it('sends q with page 1, updates the URL on success and leaves the tab counts alone', async () => {
|
||||
it('sends q with page 1, updates the URL on success and follows the search in the tab count', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const wrapper = await mountAt('/article?page=7')
|
||||
listCalls = []
|
||||
@@ -389,14 +387,12 @@ describe('8 Search resets to page 1', () => {
|
||||
expect(listCalls).toEqual([{ page: 1, size: 25, q: 'plaster', status: '', sort: '' }])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/article?q=plaster')
|
||||
expect(summary()).toBe('Showing 1–15 of 15')
|
||||
expect(wrapper.text()).toContain('All 312')
|
||||
expect(wrapper.text()).toContain('Online 208')
|
||||
expect(wrapper.text()).toContain('Drafts 104')
|
||||
expect(wrapper.text()).toContain('All articles 15')
|
||||
})
|
||||
})
|
||||
|
||||
describe('9 Empty after search', () => {
|
||||
it('hides the paginator and the summary and keeps the tab counts', async () => {
|
||||
it('hides the paginator and the summary and shows every count at 0', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const wrapper = await mountAt('/article')
|
||||
|
||||
@@ -405,15 +401,11 @@ describe('9 Empty after search', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('No articles match "tiles"')
|
||||
expect(wrapper.text()).toContain(
|
||||
'Check the spelling, or search for a shorter part of the title.',
|
||||
)
|
||||
expect(wrapper.text()).toContain('Search covers titles in every language you can read.')
|
||||
expect(wrapper.text()).toContain('Clear search')
|
||||
expect(summary()).toBeUndefined()
|
||||
expect(document.body.querySelector('nav[aria-label="Pagination"]')).toBeNull()
|
||||
expect(wrapper.text()).toContain('All 312')
|
||||
expect(wrapper.text()).toContain('Online 208')
|
||||
expect(wrapper.text()).toContain('Drafts 104')
|
||||
expect(wrapper.text()).toContain('All articles 0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -494,12 +486,12 @@ describe('Keyboard, the page-size list', () => {
|
||||
})
|
||||
|
||||
describe('Tabs filter on the server', () => {
|
||||
it('sends status=draft with page 1, updates the URL on success and keeps the counts', async () => {
|
||||
it('sends tab=missing with page 1 and updates the URL on success', async () => {
|
||||
const wrapper = await mountAt('/article?page=3')
|
||||
listCalls = []
|
||||
|
||||
const tab = Array.from(document.body.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.trim().startsWith('Drafts'),
|
||||
button.textContent?.trim().startsWith('Needs translation'),
|
||||
)
|
||||
tab?.focus()
|
||||
tab?.dispatchEvent(
|
||||
@@ -511,11 +503,11 @@ describe('Tabs filter on the server', () => {
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: 'draft', sort: '' }])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/article?status=draft')
|
||||
expect(wrapper.text()).toContain('All 312')
|
||||
expect(wrapper.text()).toContain('Online 208')
|
||||
expect(wrapper.text()).toContain('Drafts 104')
|
||||
expect(listCalls).toEqual([
|
||||
{ page: 1, size: 25, q: '', status: '', sort: '', tab: 'missing' },
|
||||
])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/article?tab=missing')
|
||||
expect(wrapper.text()).toContain('All articles 312')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -559,13 +551,13 @@ describe('Deleting the last row of the last page', () => {
|
||||
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/article')
|
||||
expect(summary()).toBe('Showing 1–25 of 25')
|
||||
expect(document.body.textContent).toContain('All 25')
|
||||
expect(document.body.textContent).toContain('All articles 25')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The envelope is the only shape read', () => {
|
||||
it('renders rows from items', async () => {
|
||||
const page: ArticlePage = articleEnvelope(buildArticles(3))
|
||||
const page: StoryPage = storyEnvelope(buildArticles(3))
|
||||
listHandler = () => mockResponse(200, page)
|
||||
const wrapper = await mountAt('/article')
|
||||
|
||||
@@ -591,9 +583,7 @@ function headerArrow(label: string) {
|
||||
|
||||
describe('8a Sort headers, server-side, reset to page 1', () => {
|
||||
it.each([
|
||||
['Title', 'title:asc'],
|
||||
['Status', 'status:asc'],
|
||||
['Created', 'created_at:asc'],
|
||||
['Article', 'title:asc'],
|
||||
['Updated', 'updated_at:asc'],
|
||||
])('the first click on %s sends sort=%s and page 1', async (label, sort) => {
|
||||
const wrapper = await mountAt('/article?page=7')
|
||||
@@ -604,14 +594,14 @@ describe('8a Sort headers, server-side, reset to page 1', () => {
|
||||
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort }])
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/article?sort=${sort}`)
|
||||
expect(headerArrow(label)).toBe('up')
|
||||
expect(wrapper.text()).toContain('All 312')
|
||||
expect(wrapper.text()).toContain('All articles 312')
|
||||
})
|
||||
|
||||
it('keeps the URL unchanged until the response arrives', async () => {
|
||||
await mountAt('/article?page=7')
|
||||
listHandler = () => new Promise(() => undefined)
|
||||
|
||||
await click(headerButton('Title'))
|
||||
await click(headerButton('Article'))
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/article?page=7')
|
||||
})
|
||||
@@ -622,10 +612,10 @@ describe('8b Toggling the sorted header', () => {
|
||||
await mountAt('/article?sort=title:asc')
|
||||
listCalls = []
|
||||
|
||||
await click(headerButton('Title'))
|
||||
await click(headerButton('Article'))
|
||||
expect(listCalls.at(-1)).toMatchObject({ sort: 'title:desc', page: 1 })
|
||||
expect(router.currentRoute.value.fullPath).toBe('/article?sort=title:desc')
|
||||
expect(headerArrow('Title')).toBe('down')
|
||||
expect(headerArrow('Article')).toBe('down')
|
||||
|
||||
await click(headerButton('Updated'))
|
||||
await click(headerButton('Updated'))
|
||||
@@ -665,13 +655,11 @@ describe('8c Sort is kept across paging, page size and search', () => {
|
||||
})
|
||||
|
||||
describe('8d A sorted URL on load', () => {
|
||||
it('sends the sort and shows the Created arrow-down', async () => {
|
||||
await mountAt('/article?sort=created_at:desc&page=2')
|
||||
it('sends the sort and shows the Article arrow-down', async () => {
|
||||
await mountAt('/article?sort=title:desc&page=2')
|
||||
|
||||
expect(listCalls).toEqual([
|
||||
{ page: 2, size: 25, q: '', status: '', sort: 'created_at:desc' },
|
||||
])
|
||||
expect(headerArrow('Created')).toBe('down')
|
||||
expect(listCalls).toEqual([{ page: 2, size: 25, q: '', status: '', sort: 'title:desc' }])
|
||||
expect(headerArrow('Article')).toBe('down')
|
||||
})
|
||||
|
||||
it('falls back to the default for an invalid sort and replaces the URL', async () => {
|
||||
@@ -686,7 +674,7 @@ describe('8d A sorted URL on load', () => {
|
||||
describe('8e No client-side re-sorting', () => {
|
||||
it('renders the rows in exactly the order the server returned', async () => {
|
||||
listHandler = (params) => {
|
||||
const page = articleEnvelope(articles, params)
|
||||
const page = storyEnvelope(articles, params)
|
||||
return mockResponse(200, {
|
||||
...page,
|
||||
items: [...page.items].reverse(),
|
||||
@@ -697,7 +685,7 @@ describe('8e No client-side re-sorting', () => {
|
||||
const rendered = Array.from(document.body.querySelectorAll('table [data-row-title]')).map(
|
||||
(row) => Number(row.getAttribute('data-row-title')),
|
||||
)
|
||||
const expected = articleEnvelope(articles, { sort: 'title:asc' })
|
||||
const expected = storyEnvelope(articles, { sort: 'title:asc' })
|
||||
.items.map((article) => article.id)
|
||||
.reverse()
|
||||
expect(rendered).toEqual(expected)
|
||||
@@ -711,12 +699,12 @@ describe('8f Caption follows the sort', () => {
|
||||
'Articles, sorted by last updated, newest first',
|
||||
)
|
||||
|
||||
await click(headerButton('Title'))
|
||||
await click(headerButton('Article'))
|
||||
expect(document.body.querySelector('caption')?.textContent?.trim()).toBe(
|
||||
'Articles, sorted by title, ascending',
|
||||
)
|
||||
expect(document.body.querySelector('th[aria-sort="ascending"]')?.textContent).toContain(
|
||||
'Title',
|
||||
'Article',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -730,7 +718,7 @@ describe('8g Mobile card list', () => {
|
||||
)
|
||||
expect(
|
||||
Array.from(document.body.querySelectorAll('ul button')).filter((button) =>
|
||||
/^(Title|Status|Created|Updated)$/.test(button.textContent?.trim() ?? ''),
|
||||
/^(Article|Updated)$/.test(button.textContent?.trim() ?? ''),
|
||||
),
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('Articles list: Read = Own with drafts of their own', () => {
|
||||
expect(page()).toContain(
|
||||
'Your role reads online articles and the drafts you created. The counts follow the same rule.',
|
||||
)
|
||||
expect(tabs()).toEqual(expect.arrayContaining(['All 5', 'Online 3', 'Drafts 2']))
|
||||
expect(tabs()).toEqual(['All articles 5', 'Needs translation 0'])
|
||||
})
|
||||
|
||||
it('keeps New article enabled when the role can create', async () => {
|
||||
@@ -83,16 +83,16 @@ describe('Articles list: Read = Own with drafts of their own', () => {
|
||||
total: 2,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 2, online: 2, draft: 0, suggestions: 0 },
|
||||
counts: { all: 2, online: 2, draft: 0, suggestions: 0, missing: 0 },
|
||||
})
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(tabs()).toEqual(expect.arrayContaining(['All 2', 'Online 2', 'Drafts 0']))
|
||||
expect(tabs()).toEqual(['All articles 2', 'Needs translation 0'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Articles list: no drafts of their own', () => {
|
||||
it('shows the own-drafts empty state on the Draft tab with the counts 3, 3, 0', async () => {
|
||||
it('shows the own-drafts empty state on the Draft tab with the count 3', async () => {
|
||||
await mountMeta('articles', '/article?status=draft', {
|
||||
backend: backendFor(READ_OWN, BOARD_ARTICLES.slice(0, 3)),
|
||||
})
|
||||
@@ -101,7 +101,7 @@ describe('Articles list: no drafts of their own', () => {
|
||||
expect(page()).toContain(
|
||||
"Drafts you create appear here. Your role can't see drafts by other people.",
|
||||
)
|
||||
expect(tabs()).toEqual(expect.arrayContaining(['All 3', 'Online 3', 'Drafts 0']))
|
||||
expect(tabs()).toEqual(['All articles 3', 'Needs translation 0'])
|
||||
const action = Array.from(
|
||||
document.body.querySelectorAll('main button, #dashboard-content button'),
|
||||
).find((candidate) => textOf(candidate) === 'New article')
|
||||
|
||||
@@ -81,44 +81,22 @@ function currentQuery() {
|
||||
return { ...router.currentRoute.value.query }
|
||||
}
|
||||
|
||||
describe('Articles: the Authors column on every frame', () => {
|
||||
describe('Articles: the Author column on every frame', () => {
|
||||
it.each([
|
||||
{ authors: 'no authors', names: [], avatars: 0, more: '', text: '—' },
|
||||
{
|
||||
authors: 'one author',
|
||||
names: FIVE.slice(0, 1),
|
||||
avatars: 1,
|
||||
more: '',
|
||||
text: '',
|
||||
},
|
||||
{
|
||||
authors: 'three',
|
||||
names: FIVE.slice(0, 3),
|
||||
avatars: 3,
|
||||
more: '',
|
||||
text: '',
|
||||
},
|
||||
{ authors: 'five', names: FIVE, avatars: 3, more: '+2', text: '' },
|
||||
])('$authors: $avatars avatar(s) and "$more"', async ({ names, avatars, more, text }) => {
|
||||
{ authors: 'no authors', names: [], text: 'No authors' },
|
||||
{ authors: 'one author', names: FIVE.slice(0, 1), text: 'Inès Duarte' },
|
||||
{ authors: 'three', names: FIVE.slice(0, 3), text: 'Inès Duarte +2' },
|
||||
{ authors: 'five', names: FIVE, text: 'Inès Duarte +4' },
|
||||
])('$authors: reads "$text"', async ({ names, text }) => {
|
||||
await mountMeta('articles', '/article', {
|
||||
backend: backendWith('one', [withAuthors(1, names)]),
|
||||
})
|
||||
|
||||
const authorsCell = cell(bodyRows()[0]!, 1)
|
||||
const stack = authorsCell.querySelector('[data-testid="author-stack"]')
|
||||
expect(stack?.querySelectorAll('[aria-hidden="true"]').length ?? 0).toBe(avatars)
|
||||
expect(
|
||||
authorsCell.querySelector('[data-testid="author-stack-more"]')?.textContent ?? '',
|
||||
).toBe(more)
|
||||
if (text) {
|
||||
expect(textOf(authorsCell)).toBe(text)
|
||||
expect(authorsCell.querySelector('[aria-label="No authors"]')).not.toBeNull()
|
||||
}
|
||||
expect(headers()[1]).toBe('Authors')
|
||||
expect(authorsCell.querySelector('[title]')?.getAttribute('title')).toBe(names.join(', '))
|
||||
expect(textOf(cell(bodyRows()[0]!, 2))).toBe(text)
|
||||
expect(headers()[2]).toBe('Author')
|
||||
})
|
||||
|
||||
it('draws avatars with the first name and no site on the mobile cards', async () => {
|
||||
it('reads the first name and no site on the mobile cards', async () => {
|
||||
await mountMeta('articles', '/article', {
|
||||
width: MOBILE_WIDTH,
|
||||
backend: backendWith('several', [
|
||||
@@ -131,27 +109,24 @@ describe('Articles: the Authors column on every frame', () => {
|
||||
document.body.querySelectorAll<HTMLElement>('li [data-row-title]'),
|
||||
).map((title) => title.closest('li') as HTMLElement)
|
||||
expect(textOf(cards[0])).toContain('Inès Duarte +2')
|
||||
expect(
|
||||
cards[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
|
||||
).toHaveLength(3)
|
||||
expect(textOf(cards[1])).toContain('No authors')
|
||||
expect(textOf(cards[0])).not.toContain('Journal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Articles: the Site column and the Site filter', () => {
|
||||
it('has neither with one site', async () => {
|
||||
describe('Articles: the Site filter', () => {
|
||||
it('has no Site filter with one site', async () => {
|
||||
await mountMeta('articles', '/article', {
|
||||
backend: backendWith('one', [withAuthors(1, ['Inès Duarte'])]),
|
||||
})
|
||||
|
||||
expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', ''])
|
||||
expect(headers()).toEqual(['Article', 'Languages', 'Author', 'Updated', 'Actions'])
|
||||
expect(siteSelect()).toBeNull()
|
||||
expect(textOf(document.body)).not.toContain('All sites')
|
||||
})
|
||||
|
||||
it.each(['owner', 'admin', 'verified'] as const)(
|
||||
'shows a read-only Site column and a Site select for a %s with several sites',
|
||||
'shows a Site select for a %s with several sites',
|
||||
async (role) => {
|
||||
const backend = backendWith('several', [
|
||||
withAuthors(1, ['Inès Duarte'], { site_id: 2 }),
|
||||
@@ -160,17 +135,7 @@ describe('Articles: the Site column and the Site filter', () => {
|
||||
backend.me.access = accessFor(role)
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(headers()).toEqual([
|
||||
'Title',
|
||||
'Authors',
|
||||
'Site',
|
||||
'Status',
|
||||
'Created',
|
||||
'Updated',
|
||||
'',
|
||||
])
|
||||
expect(bodyRows().map((row) => textOf(cell(row, 2)))).toEqual(['Journal', 'Renew'])
|
||||
expect(cell(bodyRows()[0]!, 2).querySelector('button, a, input')).toBeNull()
|
||||
expect(headers()).toEqual(['Article', 'Languages', 'Author', 'Updated', 'Actions'])
|
||||
siteSelect()!.click()
|
||||
await flushPromises()
|
||||
expect(
|
||||
@@ -179,12 +144,11 @@ describe('Articles: the Site column and the Site filter', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('drops the Site column and filter when the sites cannot be loaded', async () => {
|
||||
it('drops the Site filter when the sites cannot be loaded', async () => {
|
||||
const backend = backendWith('several', [withAuthors(1, ['Inès Duarte'])])
|
||||
backend.responders['GET /v1/site/'] = failWith(500)
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', ''])
|
||||
expect(siteSelect()).toBeNull()
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
@@ -205,8 +169,6 @@ describe('Articles: choosing a site', () => {
|
||||
await mountMeta('articles', '/article?page=3&size=5&status=online&sort=title:asc', {
|
||||
backend,
|
||||
})
|
||||
const before = textOf(document.body.querySelector('[role="tablist"]'))
|
||||
|
||||
await chooseSite('Journal')
|
||||
|
||||
expect(currentQuery()).toEqual({
|
||||
@@ -220,7 +182,9 @@ describe('Articles: choosing a site', () => {
|
||||
expect(last.query.get('page')).toBe('1')
|
||||
expect(last.query.get('status')).toBe('online')
|
||||
expect(last.query.get('sort')).toBe('title:asc')
|
||||
expect(textOf(document.body.querySelector('[role="tablist"]'))).toBe(before)
|
||||
expect(textOf(document.body.querySelector('[role="tablist"]'))).toBe(
|
||||
'All articles 20 Needs translation 0',
|
||||
)
|
||||
expect(siteSelect()!.textContent).toContain('Journal')
|
||||
})
|
||||
|
||||
@@ -294,7 +258,7 @@ describe('Articles: choosing a site', () => {
|
||||
act: async () => {
|
||||
const tab = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="tab"]'),
|
||||
).find((item) => textOf(item).startsWith('Drafts'))!
|
||||
).find((item) => textOf(item).startsWith('Needs translation'))!
|
||||
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
tab.click()
|
||||
await flushPromises()
|
||||
@@ -304,7 +268,7 @@ describe('Articles: choosing a site', () => {
|
||||
action: 'changes the sort',
|
||||
act: async () => {
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('thead button'))
|
||||
.find((item) => textOf(item).startsWith('Title'))!
|
||||
.find((item) => textOf(item).startsWith('Article'))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import {
|
||||
articleOf,
|
||||
@@ -66,12 +65,24 @@ function backendWith(articles: Article[], sites: 'one' | 'several' = 'one'): Met
|
||||
return backend
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
return document.body.querySelector<HTMLElement>('[data-testid="needs-attention-toggle"]')!
|
||||
function seoSelect() {
|
||||
return document.body.querySelector<HTMLElement>('button[aria-label="SEO"]')!
|
||||
}
|
||||
|
||||
function chips(root: ParentNode = document.body) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="seo-status-chip"]'))
|
||||
async function openSeoOptions() {
|
||||
seoSelect().click()
|
||||
await flushPromises()
|
||||
return Array.from(document.body.querySelectorAll('[role="option"]')).map((option) =>
|
||||
textOf(option),
|
||||
)
|
||||
}
|
||||
|
||||
async function chooseSeo(label: string) {
|
||||
await openSeoOptions()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
.find((option) => textOf(option).startsWith(label))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function bodyRows() {
|
||||
@@ -90,135 +101,19 @@ function currentQuery() {
|
||||
return { ...router.currentRoute.value.query }
|
||||
}
|
||||
|
||||
async function clickToggle() {
|
||||
toggle().click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function search(value: string) {
|
||||
await typeInto(document.body.querySelector<HTMLInputElement>('input[type="search"]')!, value)
|
||||
await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function clickTab(label: string) {
|
||||
const tab = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).find(
|
||||
(item) => textOf(item).startsWith(label),
|
||||
)!
|
||||
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
tab.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('SeoStatusChip: status table', () => {
|
||||
it.each([
|
||||
{ status: 'ready', count: 0, text: '', label: '' },
|
||||
{ status: 'suggestions', count: 2, text: '2', label: '2 suggestions' },
|
||||
{ status: 'suggestions', count: 1, text: '1', label: '1 suggestion' },
|
||||
{ status: 'suggestions', count: 3, text: '3', label: '3 suggestions' },
|
||||
{ status: 'hidden', count: 0, text: 'Hidden', label: 'Hidden from search engines' },
|
||||
{ status: 'hidden', count: 2, text: 'Hidden', label: 'Hidden from search engines' },
|
||||
])('$status with $count shows "$text"', async ({ status, count, text, label }) => {
|
||||
const backend = backendWith([withSeo(1, status, count)])
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
const found = chips(bodyRows()[0])
|
||||
if (!label) {
|
||||
expect(found).toHaveLength(0)
|
||||
return
|
||||
}
|
||||
expect(found).toHaveLength(1)
|
||||
const chip = found[0]!
|
||||
expect(textOf(chip)).toBe(text)
|
||||
expect(chip.getAttribute('title')).toBe(label)
|
||||
expect(chip.getAttribute('aria-label')).toBe(label)
|
||||
expect(chip.getAttribute('role')).toBe('img')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ item: 'without seo_status', article: withSeo(1, undefined, undefined) },
|
||||
{ item: 'with suggestions and a count of 0', article: withSeo(1, 'suggestions', 0) },
|
||||
{ item: 'with an unknown status', article: withSeo(1, 'surprise', 4) },
|
||||
])('shows nothing for an item $item', async ({ article }) => {
|
||||
await mountMeta('articles', '/article', { backend: backendWith([article]) })
|
||||
|
||||
expect(chips()).toHaveLength(0)
|
||||
expect(bodyRows()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sits in the Status cell next to the badge on desktop and in the meta row on mobile', async () => {
|
||||
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||
const statusCell = bodyRows()[1]!.children[2] as HTMLElement
|
||||
expect(chips(statusCell)).toHaveLength(1)
|
||||
expect(textOf(statusCell)).toContain('Online')
|
||||
expect(chips(document.body.querySelector('tbody')!)).toHaveLength(4)
|
||||
await unmountMeta()
|
||||
|
||||
await mountMeta('articles', '/article', {
|
||||
width: MOBILE_WIDTH,
|
||||
backend: backendWith(boardArticles()),
|
||||
})
|
||||
const cards = Array.from(document.body.querySelectorAll<HTMLElement>('li'))
|
||||
const second = cards.find((card) => textOf(card).includes('Article 2'))!
|
||||
expect(chips(second)).toHaveLength(1)
|
||||
expect(chips(second)[0]!.className).toContain('h-5')
|
||||
})
|
||||
|
||||
it('reads the chip in French', async () => {
|
||||
setLocale('fr')
|
||||
describe('The SEO filter', () => {
|
||||
it('offers Any SEO state and Needs attention with the count of the response', async () => {
|
||||
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||
|
||||
const labels = chips().map((chip) => chip.getAttribute('aria-label'))
|
||||
expect(labels).toContain('2 suggestions')
|
||||
expect(labels).toContain('1 suggestion')
|
||||
expect(labels).toContain('Masqué des moteurs de recherche')
|
||||
expect(textOf(toggle())).toContain('À traiter')
|
||||
})
|
||||
|
||||
it('keeps the chip when a row is published', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
document.body
|
||||
.querySelector<HTMLElement>('button[aria-label="Actions for Article 3"]')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find((item) => textOf(item) === 'Publish')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(chips(bodyRows()[2])).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Needs attention toggle', () => {
|
||||
it('is a switch with the board count, left of the search on desktop', async () => {
|
||||
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||
|
||||
expect(toggle().getAttribute('role')).toBe('switch')
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('false')
|
||||
expect(textOf(toggle())).toBe('Needs attention 3')
|
||||
expect(toggle().className).toContain('md:-order-1')
|
||||
expect(toggle().className).toContain('md:h-8')
|
||||
})
|
||||
|
||||
it('is 44 px high on mobile', async () => {
|
||||
await mountMeta('articles', '/article', {
|
||||
width: MOBILE_WIDTH,
|
||||
backend: backendWith(boardArticles()),
|
||||
})
|
||||
|
||||
expect(toggle().className).toContain('h-11')
|
||||
})
|
||||
|
||||
it('uses the sand fill when on', async () => {
|
||||
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||
await clickToggle()
|
||||
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('true')
|
||||
expect(toggle().className).toContain('bg-(--rc-sand)')
|
||||
expect(toggle().className).toContain('text-(--rc-on-sand)')
|
||||
expect(seoSelect().textContent).toContain('Any SEO state')
|
||||
const options = await openSeoOptions()
|
||||
expect(options).toEqual(['Any SEO state', 'Needs attention 3'])
|
||||
})
|
||||
|
||||
it('turns on keeping q, status, sort, site and size, and resets the page', async () => {
|
||||
@@ -228,7 +123,7 @@ describe('Needs attention toggle', () => {
|
||||
'/article?page=1&size=50&q=Article&status=online&sort=title:asc&site=renew',
|
||||
{ backend },
|
||||
)
|
||||
await clickToggle()
|
||||
await chooseSeo('Needs attention')
|
||||
|
||||
expect(currentQuery()).toEqual({
|
||||
size: '50',
|
||||
@@ -249,21 +144,22 @@ describe('Needs attention toggle', () => {
|
||||
it('turns off, drops seo, resets the page and requests the list without seo', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', '/article?seo=suggestions&page=2&size=5', { backend })
|
||||
await clickToggle()
|
||||
await chooseSeo('Any SEO state')
|
||||
|
||||
expect(currentQuery()).toEqual({ size: '5' })
|
||||
expect(mainCalls(backend).at(-1)!.query.has('seo')).toBe(false)
|
||||
expect(mainCalls(backend).at(-1)!.query.get('page')).toBe('1')
|
||||
})
|
||||
|
||||
it('opens a URL with the filter already on', async () => {
|
||||
it('opens a URL with the filter already on and shows its chip', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', '/article?seo=suggestions&page=1', { backend })
|
||||
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('true')
|
||||
expect(seoSelect().textContent).toContain('Needs attention')
|
||||
expect(mainCalls(backend)[0]!.query.get('seo')).toBe('suggestions')
|
||||
expect(bodyRows()).toHaveLength(3)
|
||||
expect(textOf(document.body.querySelector('[role="tablist"]'))).toContain('All 5')
|
||||
expect(textOf(document.body.querySelector('[role="tablist"]'))).toContain('All articles 3')
|
||||
expect(textOf(document.body)).toContain('SEO: Needs attention')
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -278,19 +174,16 @@ describe('Needs attention toggle', () => {
|
||||
await mountMeta('articles', `/article${query}`, { backend })
|
||||
|
||||
expect(currentQuery()).toEqual({})
|
||||
expect(toggle().getAttribute('aria-checked')).toBe('false')
|
||||
expect(seoSelect().textContent).toContain('Any SEO state')
|
||||
expect(mainCalls(backend).every((call) => !call.query.has('seo'))).toBe(true)
|
||||
expect(document.body.querySelector('[role="alert"], [role="status"]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ action: 'searches', act: () => search('Article') },
|
||||
{ action: 'changes the tab', act: () => clickTab('Drafts') },
|
||||
])('keeps ?seo=suggestions and resets the page when the user $action', async ({ act }) => {
|
||||
it('keeps ?seo=suggestions and resets the page when the user searches', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
await mountMeta('articles', '/article?seo=suggestions&page=2&size=5', { backend })
|
||||
|
||||
await act()
|
||||
await search('Article')
|
||||
|
||||
expect(currentQuery().seo).toBe('suggestions')
|
||||
expect(currentQuery().page).toBeUndefined()
|
||||
@@ -302,13 +195,20 @@ describe('Needs attention toggle', () => {
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(document.body.querySelector('button[aria-label="Site"]')).toBeNull()
|
||||
await clickToggle()
|
||||
await chooseSeo('Needs attention')
|
||||
expect(currentQuery()).toEqual({ seo: 'suggestions' })
|
||||
expect(bodyRows()).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('reads the options in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
|
||||
|
||||
expect(await openSeoOptions()).toEqual(['Tout état SEO', 'À traiter 3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Needs attention count', () => {
|
||||
describe('The Needs attention count', () => {
|
||||
function envelope(counts: Record<string, number>) {
|
||||
return { items: [], total: 0, page: 1, size: 25, counts }
|
||||
}
|
||||
@@ -317,12 +217,8 @@ describe('Needs attention count', () => {
|
||||
const backend = backendWith(boardArticles(), 'several')
|
||||
await mountMeta('articles', '/article?q=Article&status=online&site=renew', { backend })
|
||||
|
||||
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)
|
||||
expect((await openSeoOptions())[1]).toBe('Needs attention 3')
|
||||
expect(countCalls(backend)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -331,18 +227,19 @@ describe('Needs attention count', () => {
|
||||
])('shows $label', async ({ suggestions, label }) => {
|
||||
const backend = backendWith(boardArticles())
|
||||
backend.responders['GET /v1/article/'] = () =>
|
||||
envelope({ all: 5, online: 2, draft: 3, suggestions })
|
||||
envelope({ all: 5, online: 2, draft: 3, suggestions, missing: 0 })
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(textOf(toggle())).toBe(label)
|
||||
expect((await openSeoOptions())[1]).toBe(label)
|
||||
})
|
||||
|
||||
it('shows no count when counts.suggestions is absent', async () => {
|
||||
const backend = backendWith(boardArticles())
|
||||
backend.responders['GET /v1/article/'] = () => envelope({ all: 5, online: 2, draft: 3 })
|
||||
backend.responders['GET /v1/article/'] = () =>
|
||||
envelope({ all: 5, online: 2, draft: 3, missing: 0 })
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(textOf(toggle())).toBe('Needs attention')
|
||||
expect((await openSeoOptions())[1]).toBe('Needs attention')
|
||||
})
|
||||
|
||||
it('shows no count before the first response arrives', async () => {
|
||||
@@ -350,7 +247,7 @@ describe('Needs attention count', () => {
|
||||
backend.responders['GET /v1/article/'] = () => new Promise(() => {})
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(textOf(toggle())).toBe('Needs attention')
|
||||
expect((await openSeoOptions())[1]).toBe('Needs attention')
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -361,45 +258,9 @@ describe('Needs attention count', () => {
|
||||
backend.responders['GET /v1/article/'] = responder
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(textOf(toggle())).toBe('Needs attention')
|
||||
expect((await openSeoOptions())[1]).toBe('Needs attention')
|
||||
expect(currentQuery()).toEqual({})
|
||||
})
|
||||
|
||||
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(textOf(toggle())).toBe('Needs attention 30')
|
||||
|
||||
document.body.querySelector<HTMLElement>('button[aria-label="Next page"]')!.click()
|
||||
await flushPromises()
|
||||
await clickTab('Drafts')
|
||||
await search('Article 1')
|
||||
|
||||
expect(textOf(toggle())).toBe('Needs attention 30')
|
||||
expect(countCalls(backend)).toHaveLength(0)
|
||||
})
|
||||
|
||||
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 })
|
||||
|
||||
expect(countCalls(backend)).toHaveLength(0)
|
||||
expect(textOf(toggle())).toBe('Needs attention 3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Needs attention: empty states', () => {
|
||||
|
||||
@@ -97,12 +97,10 @@ describe('Sites and authors across Editor, Articles and Profile', () => {
|
||||
expect(useSessionStore().session?.author).toMatchObject({ id: 200 })
|
||||
|
||||
await navigate('/article')
|
||||
expect(
|
||||
stackRows()[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
|
||||
).toHaveLength(1)
|
||||
expect(textOf(stackRows()[0]!.children[2])).toBe('Zoe Lambert')
|
||||
})
|
||||
|
||||
it('a verified account creates its profile from the hint, saves, and the Authors column shows the avatar', async () => {
|
||||
it('a verified account creates its profile from the hint, saves, and the Author column shows the name', async () => {
|
||||
const backend = createMetaBackend({
|
||||
sites: renewSites().slice(0, 1),
|
||||
access: accessFor('verified'),
|
||||
@@ -118,9 +116,7 @@ describe('Sites and authors across Editor, Articles and Profile', () => {
|
||||
expect(patchCalls(backend)[0]!.body).toEqual({ author_ids: [300] })
|
||||
|
||||
await navigate('/article')
|
||||
expect(
|
||||
stackRows()[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
|
||||
).toHaveLength(1)
|
||||
expect(textOf(stackRows()[0]!.children[2])).toBe('Marta Alves')
|
||||
await navigate('/profile')
|
||||
expect(textOf(document.body)).toContain('marta-alves')
|
||||
})
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { Article, ArticlePage, ArticleStatus } from '@/composables/useArticles'
|
||||
import type {
|
||||
Article,
|
||||
ArticleCapability,
|
||||
ArticlePage,
|
||||
ArticleStatus,
|
||||
StoryItem,
|
||||
StoryPage,
|
||||
} from '@/composables/useArticles'
|
||||
|
||||
export interface EnvelopeParams {
|
||||
page?: number
|
||||
@@ -8,6 +15,28 @@ export interface EnvelopeParams {
|
||||
sort?: string
|
||||
site?: string
|
||||
seo?: '' | 'suggestions'
|
||||
lang?: string
|
||||
tab?: '' | 'missing'
|
||||
author?: number
|
||||
}
|
||||
|
||||
export const ALL_CAPABILITIES: ArticleCapability[] = [
|
||||
'read',
|
||||
'edit',
|
||||
'publish',
|
||||
'delete',
|
||||
'create_translation',
|
||||
]
|
||||
|
||||
export function storyOf(article: Article, capabilities = ALL_CAPABILITIES): StoryItem {
|
||||
return {
|
||||
...article,
|
||||
story_updated_at: article.updated_at,
|
||||
story_authors: article.authors ?? [],
|
||||
coverage: [],
|
||||
missing_langs: [],
|
||||
capabilities,
|
||||
}
|
||||
}
|
||||
|
||||
function fold(value: string) {
|
||||
@@ -42,6 +71,41 @@ export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): Ar
|
||||
}
|
||||
}
|
||||
|
||||
export function storyEnvelope(all: Article[], params: EnvelopeParams = {}): StoryPage {
|
||||
const { page = 1, size = 25, q = '', status = '', sort = '', seo = '' } = params
|
||||
const matching = all
|
||||
.filter(
|
||||
(article) =>
|
||||
!seo ||
|
||||
(article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0),
|
||||
)
|
||||
.filter((article) => !q || fold(article.title).includes(fold(q)))
|
||||
const filtered = matching.filter(
|
||||
(article) => !status || article.is_online === (status === 'online'),
|
||||
)
|
||||
if (sort) {
|
||||
const [name, direction] = sort.split(':') as [string, string]
|
||||
const field = (name === 'status' ? 'is_online' : name) as keyof Article
|
||||
filtered.sort((left, right) => {
|
||||
const order = String(left[field]).localeCompare(String(right[field]))
|
||||
return direction === 'desc' ? -order : order
|
||||
})
|
||||
}
|
||||
const online = matching.filter((article) => article.is_online).length
|
||||
return {
|
||||
items: filtered.slice((page - 1) * size, page * size).map((article) => storyOf(article)),
|
||||
total: filtered.length,
|
||||
page,
|
||||
size,
|
||||
counts: {
|
||||
all: matching.length,
|
||||
online,
|
||||
draft: matching.length - online,
|
||||
missing: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function articleListUrl(params: EnvelopeParams = {}) {
|
||||
const { page = 1, size = 25, q = '', status = '', sort = '', site = '', seo = '' } = params
|
||||
const query = new URLSearchParams({
|
||||
@@ -81,5 +145,58 @@ export function parseListUrl(url: string): EnvelopeParams {
|
||||
sort: query.get('sort') ?? '',
|
||||
...(query.get('site') ? { site: query.get('site') ?? '' } : {}),
|
||||
...(query.get('seo') ? { seo: 'suggestions' as const } : {}),
|
||||
...(query.get('lang') ? { lang: query.get('lang') ?? '' } : {}),
|
||||
...(query.get('tab') ? { tab: 'missing' as const } : {}),
|
||||
...(query.get('author') ? { author: Number(query.get('author')) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function storyListUrl(params: EnvelopeParams = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
size = 25,
|
||||
q = '',
|
||||
status = '',
|
||||
sort = '',
|
||||
site = '',
|
||||
seo = '',
|
||||
lang = '',
|
||||
tab = '',
|
||||
author = 0,
|
||||
} = params
|
||||
const query = new URLSearchParams({
|
||||
format: 'html',
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
})
|
||||
if (q) {
|
||||
query.set('q', q)
|
||||
}
|
||||
if (status) {
|
||||
query.set('status', status)
|
||||
}
|
||||
if (sort) {
|
||||
query.set('sort', sort)
|
||||
}
|
||||
if (site) {
|
||||
query.set('site', site)
|
||||
}
|
||||
if (seo) {
|
||||
query.set('seo', seo)
|
||||
}
|
||||
query.set('group', 'story')
|
||||
if (lang) {
|
||||
query.set('lang', lang)
|
||||
}
|
||||
if (tab) {
|
||||
query.set('tab', tab)
|
||||
}
|
||||
if (author) {
|
||||
query.set('author', String(author))
|
||||
}
|
||||
return `/v1/article/?${query.toString()}`
|
||||
}
|
||||
|
||||
export function storyListRoute(params: EnvelopeParams = {}) {
|
||||
return `GET ${storyListUrl(params)}`
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import App from '@/App.vue'
|
||||
import router from '@/router'
|
||||
import { ApiError, useApi, type MeAuthor, type MeResult } from '@/composables/useApi'
|
||||
import type { Article, Byline } from '@/composables/useArticles'
|
||||
import type { Article, ArticleCapability, Byline } from '@/composables/useArticles'
|
||||
import { storyOf } from './articleEnvelope'
|
||||
import type { Author } from '@/composables/useAuthors'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
@@ -39,6 +40,7 @@ export type MetaRole = 'owner' | 'admin' | 'verified'
|
||||
export interface MetaBackend extends Backend {
|
||||
article: Article
|
||||
articles: Article[]
|
||||
capabilitiesFor?: (article: Article) => ArticleCapability[]
|
||||
me: MeResult
|
||||
meAuthorSlug?: string
|
||||
meSlugAdjusted?: boolean
|
||||
@@ -169,31 +171,41 @@ function listArticles(backend: MetaBackend, query: URLSearchParams) {
|
||||
const page = Number(query.get('page') ?? 1)
|
||||
const siteKey = query.get('site')
|
||||
const status = query.get('status')
|
||||
const grouped = query.get('group') === 'story'
|
||||
const q = (query.get('q') ?? '').toLowerCase()
|
||||
const siteId = backend.sites.find((site) => site.key === siteKey)?.id
|
||||
const matching = backend.articles
|
||||
const base = backend.articles
|
||||
.filter((article) => !siteKey || article.site_id === siteId)
|
||||
.filter((article) => !status || article.is_online === (status === 'online'))
|
||||
.filter((article) => !q || article.title.toLowerCase().includes(q))
|
||||
.filter(
|
||||
(article) =>
|
||||
query.get('seo') !== 'suggestions' ||
|
||||
(article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0),
|
||||
)
|
||||
const online = backend.articles.filter((article) => article.is_online).length
|
||||
const matching = base.filter(
|
||||
(article) => !status || article.is_online === (status === 'online'),
|
||||
)
|
||||
const countable = grouped ? base : backend.articles
|
||||
const online = countable.filter((article) => article.is_online).length
|
||||
const rows = matching.slice((page - 1) * size, page * size)
|
||||
return {
|
||||
items: structuredClone(matching.slice((page - 1) * size, page * size)),
|
||||
items: structuredClone(
|
||||
grouped
|
||||
? rows.map((article) => storyOf(article, backend.capabilitiesFor?.(article)))
|
||||
: rows,
|
||||
),
|
||||
total: matching.length,
|
||||
page,
|
||||
size,
|
||||
counts: {
|
||||
all: backend.articles.length,
|
||||
all: countable.length,
|
||||
online,
|
||||
draft: backend.articles.length - online,
|
||||
draft: countable.length - online,
|
||||
suggestions: backend.articles.filter(
|
||||
(article) =>
|
||||
article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0,
|
||||
).length,
|
||||
...(grouped ? { missing: 0 } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,6 @@ describe('Every state has a setup', () => {
|
||||
describe('Existing screens are unaffected', () => {
|
||||
it('reports the parser as the only discovery for localization screens', () => {
|
||||
const names = Object.keys(visualStates).filter((name) => name.startsWith('localization-'))
|
||||
expect(names).toEqual(['localization-site-languages'])
|
||||
expect(names).toEqual(['localization-site-languages', 'localization-list'])
|
||||
})
|
||||
})
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user