Files
RenewCMS/features/articles_list.feature
T

260 lines
17 KiB
Gherkin

Feature: Articles list screen
As a verified RenewCMS owner
I want /article to list every article with its status, let me search and filter it, and let me
publish, unpublish or delete a row without leaving the page
So that I can manage my whole set of articles from one place, with the list always reflecting
what the server actually holds
Background:
Given /article is a verified route (meta.auth "verified") rendered as ArticlesPage inside
DashboardShell, per docs/design/routes.md
And ArticlesPage calls GET /v1/article/ once per navigation to load the full list (all
articles, online and draft, because the request carries the jwt cookie), and every row
action goes over PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish or
DELETE /v1/article/{id}; all three endpoints already exist and this slice wires the SPA to
them unchanged, except for one persistence-layer change: ArticleRepository.FindByFilters
(which backs GET /v1/article/) gains a .Preload("Images") call, mirroring the one
ArticleRepository.Get already has, so every article in the list response carries its real
Images array instead of coming back empty; no domain or use-case change is needed, since
article.Article.Images already exists
And the PageHeader shows breadcrumb "Articles" (mobile title "Articles")
And search and the active tab live in the URL query string (?q=, ?status=), so the filtered
view can be linked and survives a reload; the list is always sorted by last updated, newest
first, with no interactive sort control in this slice; search is case-insensitive, ignores
accents, matches anywhere in the title, and is debounced 120 ms; tab counts (All, Online,
Drafts) are always computed from the full list fetched by GET /v1/article/, so they never
change while the user is only searching
And the list is rendered as ArticleTable (desktop, md and up) or ArticleCardList (below md),
both fed by the same articles array, loading flag and per-row busy state, and both routed
through the same row actions
And each row shows its title (a real link to /article/:id/edit), an ArticleStatusBadge (online
or draft), and its updated date (relative within 7 days, e.g. "5 days ago"; an absolute date
beyond that, e.g. "18 Aug 2026", with the full timestamp in a title attribute)
And clicking anywhere on a row except its menu button navigates to /article/:id/edit
And each row's ellipsis button (or Enter while it is focused) opens that row's menu, named
"Actions for {title}", listing Edit (to /article/:id/edit), Publish or Unpublish (whichever
applies to that row's current status), Copy API URL, and, below a separator, "Delete…" in
brick red, always last
And "Copy API URL" writes the absolute URL "{origin}/v1/article/{id}" to the clipboard; on
success it raises a success toast (icon circle-check, id "article-link-copied-{id}"): title
"Link copied" text "The API URL for \"{title}\" is on your clipboard."; if the clipboard
write itself fails, it raises an error toast instead (icon circle-x, id
"article-link-copy-failed-{id}"): title "Couldn't copy the link", with no text line
And row actions are never optimistic: a row only changes once the server responds; while a
publish or unpublish request for a row is in flight, that row alone is disabled and shows a
busy label ("Publishing…" or "Unpublishing…"), and every other row stays interactive
And a successful publish or unpublish (200) updates that row's ArticleStatusBadge and updated
date in place and raises a success toast (icon circle-check, id
"article-published-{id}" or "article-unpublished-{id}" per components.md's action-plus-id
toast convention): title "Article published" text "\"{title}\" is now online." for a
publish, or title "Article unpublished" text "\"{title}\" is now offline." for an unpublish
And a 500 or network failure on a publish or unpublish leaves that row exactly as it was
before the request and raises an error toast (icon circle-x, duration 0 so it stays until
dismissed or retried, id "article-publish-failed-{id}" or "article-unpublish-failed-{id}"):
title "Couldn't publish" or "Couldn't unpublish", text "The server didn't respond. Nothing
changed.", with an action "Retry" that re-sends the same request
And a 404 on any row action (publish, unpublish or delete) closes any open row menu or delete
dialog for that row, removes the row from the list, recomputes the tab counts from the
remaining articles, and raises an info toast (icon info, id "article-not-found-{id}"): title
"That article no longer exists" text "It was deleted somewhere else, so we removed it from
the list."
And "Delete…" opens ConfirmDialog: title "Delete this article?" text "\"{title}\" will be
deleted. Its images stay in your library. This can't be undone." (the second sentence is
present only when the article body holds at least one image),
"Cancel" and "Delete article" (danger); while the DELETE request is in flight the dialog
cannot be dismissed by Cancel, Esc or a backdrop click, both buttons are disabled, the
confirm button reads "Deleting…" with a loading indicator, and aria-busy is set
And a 500 on the delete request keeps the dialog open and instead shows an inline alert inside
the dialog: "Couldn't delete the article, so it's still there. Try again in a moment.",
re-enabling "Cancel" and "Delete article" so the user can retry or back out; the article is
left untouched
And a successful delete (204) closes the dialog, removes the row, moves focus to the next row
(or the page heading if the deleted row was last), recomputes the tab counts, and raises a
success toast (icon circle-check, id "article-deleted-{id}"): title "Article deleted" text
"\"{title}\" is gone."; this is also where a delete initiated from the editor
ends up
And the list, sorted by last updated with the most recently updated article first, holds
these 6 articles by default: id 1 "How we price a bathroom renovation" (online, updated "2
hours ago"), id 2 "Choosing lime plaster for old stone walls" (draft, updated "Yesterday"),
id 3 "Before and after: the Rue Verte kitchen" (online, updated "3 days ago"), id 4 "Winter
checklist for timber windows" (draft, updated "5 days ago"), id 5 "Why we reuse original
floorboards" (online, updated "18 Aug 2026"), id 6 "Our first passive-house retrofit"
(online, updated "2 Jul 2026") — 6 total, 4 online, 2 drafts
Scenario: Default — the list loads sorted by last updated, newest first
Given GET /v1/article/ resolves 200 with the 6-article fixture above
When the user navigates to /article
Then the breadcrumb reads "Articles" (mobile title "Articles")
And the tabs read "All 6" "Online 4" "Drafts 2"
And the rows appear in exactly the fixture's order, each with its title link, its
ArticleStatusBadge and its updated date
And the table (desktop) carries a visually hidden caption "Articles, sorted by last updated,
newest first"
Scenario: Loading — skeleton rows while the first request, or a retry, is in flight
Given GET /v1/article/ has not yet resolved
When the user navigates to /article
Then 5 skeleton rows replace the table or card list, and the list container is aria-busy="true"
When GET /v1/article/ then resolves 200 with the 6-article fixture
Then the skeleton rows are replaced by the Default state's rows
Scenario: Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…
Given the Default state, with the row for id 4 "Winter checklist for timber windows" (draft,
updated "5 days ago")
When the user activates that row's ellipsis button
Then that row's menu opens, named "Actions for Winter checklist for timber windows", listing
Edit, Publish, Copy API URL, and, below a separator, "Delete…" in brick red
And every other row is unaffected
Scenario: Copy API URL succeeds — the absolute URL is copied and confirmed
Given the Default state, with the row for id 4 "Winter checklist for timber windows"
When the user chooses "Copy API URL" from id 4's row menu
Then "{origin}/v1/article/4" is written to the clipboard
And a success toast appears: icon circle-check, id "article-link-copied-4", title "Link
copied", text "The API URL for \"Winter checklist for timber windows\" is on your
clipboard."
Scenario: Copy API URL fails — the clipboard write itself is rejected
Given the Default state, with the row for id 4 "Winter checklist for timber windows"
When the user chooses "Copy API URL" from id 4's row menu, and the clipboard write fails
Then an error toast appears: icon circle-x, id "article-link-copy-failed-4", title "Couldn't
copy the link", with no text line
Scenario: Empty — zero articles
Given GET /v1/article/ resolves 200 with zero articles
When the user navigates to /article
Then the tabs read "All 0" "Online 0" "Drafts 0"
And an EmptyState (variant no-articles) replaces the list: icon file-text, title "No articles
yet", text "Write your first one. It stays a draft until you publish it.", and a primary
action "New article"
When the user activates "New article"
Then NewArticleModal opens
Scenario: No results — a search matches nothing, but the tab counts still show the full list
Given the Default state has loaded
When the user types "tiles" into the search field
Then, after the 120 ms debounce, an EmptyState (variant no-results) replaces the list: icon
search, title "No articles match \"tiles\"", text "Check the spelling, or search for a
shorter part of the title.", and an action "Clear search"
And the tabs still read "All 6" "Online 4" "Drafts 2"
When the user activates "Clear search"
Then the search field is cleared, the Default state's rows reappear, and focus returns to the
search field
Scenario Outline: An empty tab shows its own EmptyState, while the other tab still counts correctly
Given GET /v1/article/ resolves 200 with <fixture>
When the user navigates to /article and selects the "<tab>" tab
Then the tabs read "<counts>"
And an EmptyState replaces the list: title "<title>" text "<text>" action "<action>"
Examples:
| fixture | tab | counts | title | text | action |
| 4 articles, all online, none drafts | Drafts | All 4, Online 4, Drafts 0 | No drafts | Everything you've written is online. | New article |
| 4 articles, all drafts, none online | Online | All 4, Online 0, Drafts 4 | Nothing is online yet | Publish a draft and it will appear on your website. | Show drafts |
Scenario: Action in progress — publishing a row disables only that row
Given the Default state has loaded
When the user chooses "Publish" from id 4 "Winter checklist for timber windows"'s row menu
Then PATCH /v1/article/4/publish is sent
And that row alone is disabled and shows the busy label "Publishing…", while the other 5 rows
stay interactive
Scenario: Published — a 200 updates the row in place and raises a toast
Given the user chose "Publish" from id 4 "Winter checklist for timber windows"'s row menu, and
the request is in flight
When PATCH /v1/article/4/publish resolves 200 with the article, is_online true, updated "Just
now"
Then id 4's row shows an online ArticleStatusBadge and updated date "Just now", no longer busy
And a success toast appears: icon circle-check, id "article-published-4", title "Article
published", text "\"Winter checklist for timber windows\" is now online."
Scenario Outline: Action failed, publish — a 500 or a network error leaves the row unchanged and offers Retry
Given the user chose "Publish" from id 4 "Winter checklist for timber windows"'s row menu, and
the request is in flight
When PATCH /v1/article/4/publish <failure>
Then id 4's row stays unchanged (draft, updated "5 days ago", no longer busy), because a row
never changes before the server responds
And an error toast appears, staying until dismissed or retried: icon circle-x, id
"article-publish-failed-4", title "Couldn't publish", text "The server didn't respond.
Nothing changed.", action "Retry"
When the user activates "Retry"
Then PATCH /v1/article/4/publish is sent again
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario Outline: Action failed, unpublish — a 500 or a network error leaves the row unchanged and offers Retry
Given the user chose "Unpublish" from id 3 "Before and after: the Rue Verte kitchen"'s row
menu, and the request is in flight
When PATCH /v1/article/3/unpublish <failure>
Then id 3's row stays unchanged (online, updated "3 days ago", no longer busy), because a row
never changes before the server responds
And an error toast appears, staying until dismissed or retried: icon circle-x, id
"article-unpublish-failed-3", title "Couldn't unpublish", text "The server didn't respond.
Nothing changed.", action "Retry"
When the user activates "Retry"
Then PATCH /v1/article/3/unpublish is sent again
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario Outline: Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast
Given the Default state has loaded
When the user <trigger> for id 2 "Choosing lime plaster for old stone walls"
And the request resolves 404
Then id 2's row is removed from the list (and any open menu or dialog for it closes)
And the tabs read "All 5" "Online 4" "Drafts 1"
And an info toast appears: icon info, id "article-not-found-2", title "That article no longer
exists", text "It was deleted somewhere else, so we removed it from the list."
Examples:
| trigger |
| chooses "Publish" from its row menu |
| confirms "Delete article" in its ConfirmDialog |
Scenario: Row vanished, online row — a 404 on unpublish removes the row and shows an info toast
Given the Default state has loaded
When the user chooses "Unpublish" from id 3 "Before and after: the Rue Verte kitchen"'s row
menu
And the request resolves 404
Then id 3's row is removed from the list (and any open menu closes)
And the tabs read "All 5" "Online 3" "Drafts 2"
And an info toast appears: icon info, id "article-not-found-3", title "That article no longer
exists", text "It was deleted somewhere else, so we removed it from the list."
Scenario: Delete confirm — opens the dialog with the row's title and image count
Given the Default state has loaded, and id 4 "Winter checklist for timber windows" has 3 images
When the user chooses "Delete…" from id 4's row menu
Then ConfirmDialog opens: title "Delete this article?" text "\"Winter checklist for timber
windows\" will be deleted. Its images stay in your library. This can't be undone.", "Cancel" and "Delete
article" (danger)
Scenario: Delete in progress — the dialog cannot be dismissed while the request is in flight
Given ConfirmDialog is open for id 4 "Winter checklist for timber windows"
When the user activates "Delete article"
Then DELETE /v1/article/4 is sent
And both "Cancel" and the confirm button are disabled, the confirm button reads "Deleting…"
with a loading indicator, aria-busy is set, and Esc and a backdrop click do not close it
Scenario: Delete failed — a 500 shows an inline alert inside the dialog and keeps the article
Given ConfirmDialog is open for id 4 "Winter checklist for timber windows", and "Delete
article" has been activated
When DELETE /v1/article/4 resolves 500
Then the dialog stays open, showing the alert "Couldn't delete the article, so it's still
there. Try again in a moment.", with "Cancel" and "Delete article" re-enabled
And id 4's row is untouched in the list behind the dialog
Scenario: Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row
Given ConfirmDialog is open for id 4 "Winter checklist for timber windows", and "Delete
article" has been activated
When DELETE /v1/article/4 resolves 204
Then the dialog closes, id 4's row is removed from the list, and focus moves to the next row
And the tabs read "All 5" "Online 4" "Drafts 1"
And a success toast appears: icon circle-check, id "article-deleted-4", title "Article
deleted", text "\"Winter checklist for timber windows\" is gone."