feat: editorial article list

This commit is contained in:
Florian Sylvain committed 2026-11-02 07:28:44 +01:00
1 parent a0b3c58c52
commit a671b81f80
103 files changed
+6493 -1805

No files matched your search

+390
View File
@@ -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 |
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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
View File
@@ -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.
+6 -5
View 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.