260 lines
17 KiB
Gherkin
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."
|