feat: articles list screen
This commit is contained in:
1 parent
ca3fe20519
commit
f6d0d01092
14 files changed
+2353
-25
No files matched your search
@@ -0,0 +1,259 @@
|
||||
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}\" and its
|
||||
{n} images will be deleted. This can't be undone." (a components.md convention: the row's
|
||||
title plus its image count, "and its image" for exactly 1 and no image clause at all for 0),
|
||||
"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}\" and its images are 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\" and its 3 images will be deleted. 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\" and its images are gone."
|
||||
Reference in new issue
Block a user