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."
|
||||
@@ -0,0 +1,176 @@
|
||||
# QA: Articles list screen
|
||||
|
||||
## Implementation note for coder (informational, not a QA assertion)
|
||||
|
||||
`internal/infrastructure/persistence/repositories/articleRepository.go`'s `FindByFilters` gains
|
||||
`.Preload("Images")`, mirroring `Get`, so `GET /v1/article/` carries real `Images` arrays for the
|
||||
delete dialog's count. This is a persistence-layer change asserted by the existing
|
||||
`var _ domain.Repository = &ArticleRepository{}`; it needs no domain or use-case change, since
|
||||
`article.Article.Images` already exists.
|
||||
|
||||
Toast ids follow components.md's action-plus-id convention: "article-published-{id}",
|
||||
"article-unpublished-{id}", "article-publish-failed-{id}", "article-unpublish-failed-{id}",
|
||||
"article-not-found-{id}", "article-deleted-{id}", "article-link-copied-{id}",
|
||||
"article-link-copy-failed-{id}".
|
||||
|
||||
## HTTP-level (Go, `test/`, `task test`)
|
||||
|
||||
Build `article.NewHandler(...)` over the real use cases wired to a fake `article.Repository` (in
|
||||
the style of `test/article_test.go`'s `mockArticleRepo`), mounted on a fresh `chi.NewRouter()`
|
||||
mirroring `NewArticleRouter` in `api/router.go` (the outer group, no auth, for `GET /`). Call
|
||||
`validator.InitValidator()` first.
|
||||
|
||||
1. **GET /v1/article/ returns each article's images array populated** — seed the fake repository
|
||||
with an article whose `Images` slice holds two `image.Image` entries (ids 10 and 11), send
|
||||
`GET /v1/article/` with `httptest`, decode the JSON array, and assert the matching entry's
|
||||
`images` field is a 2-element array containing those ids (not `null` and not empty), proving
|
||||
the handler's response reflects `FindByFilters`'s `Images` data. Because the fake repository's
|
||||
`FindByFilters` is written by this test rather than GORM, it can't exercise the real
|
||||
`.Preload("Images")` SQL by itself — this test only proves the handler passes the repository's
|
||||
`Images` data through to JSON unchanged. The `.Preload` call itself against a real SQLite file is
|
||||
covered under Manual verification below.
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi`'s calls for `GET /v1/article/`, `PATCH /v1/article/{id}/publish`,
|
||||
`PATCH /v1/article/{id}/unpublish`, `DELETE /v1/article/{id}`, and the clipboard write (e.g.
|
||||
`navigator.clipboard.writeText`), per case; no real backend, no httptest server. Mount
|
||||
ArticlesPage inside DashboardShell with a resolved verified session and a test router at
|
||||
/article. Sample data throughout is the Background's 6-article fixture (ids 1-6, titles and dates
|
||||
as listed), unless a scenario's Examples row substitutes a smaller fixture.
|
||||
|
||||
1. **Default** — mock `GET /v1/article/` resolving 200 with the 6-article fixture; assert
|
||||
breadcrumb "Articles"; assert tabs "All 6" "Online 4" "Drafts 2"; assert the rows render in
|
||||
fixture order with title link, ArticleStatusBadge and updated date; assert the table's visually
|
||||
hidden caption reads "Articles, sorted by last updated, newest first".
|
||||
2. **Loading** — mock `GET /v1/article/` with an unresolved promise; assert 5 skeleton rows and
|
||||
`aria-busy="true"` on the list container; resolve 200 with the fixture; assert the Default
|
||||
state's rows replace the skeletons.
|
||||
3. **Row menu open** — from Default, click id 4's ellipsis button; assert the menu opens named
|
||||
"Actions for Winter checklist for timber windows" with items Edit, Publish, Copy API URL, and a
|
||||
separator then "Delete…"; assert no other row is affected.
|
||||
4. **Copy API URL succeeds** — from Default, mock the clipboard write to resolve, choose "Copy API
|
||||
URL" from id 4's menu; assert the clipboard write was called with
|
||||
`"{origin}/v1/article/4"` (using the test's `window.location.origin`); assert a toast was
|
||||
raised with id "article-link-copied-4", icon circle-check, title "Link copied", text "The API
|
||||
URL for \"Winter checklist for timber windows\" is on your clipboard."
|
||||
5. **Copy API URL fails** — from Default, mock the clipboard write to reject, choose "Copy API
|
||||
URL" from id 4's menu; assert a toast was raised with id "article-link-copy-failed-4", icon
|
||||
circle-x, title "Couldn't copy the link", and no description text.
|
||||
6. **Empty** — mock `GET /v1/article/` resolving 200 with `[]`; assert tabs "All 0" "Online 0"
|
||||
"Drafts 0"; assert the no-articles EmptyState (icon file-text, title "No articles yet", text,
|
||||
action "New article"); click it; assert NewArticleModal opens.
|
||||
7. **No results** — from Default, type "tiles" into the search field; advance past the 120 ms
|
||||
debounce (fake timers); assert the no-results EmptyState (icon search, title "No articles match
|
||||
\"tiles\"", text, action "Clear search"); assert tabs still read "All 6" "Online 4" "Drafts 2";
|
||||
click "Clear search"; assert the Default rows reappear and the search field has focus.
|
||||
8. **Empty tab, Examples-driven** — (a) mock `GET /v1/article/` with 4 online-only articles, select
|
||||
the Drafts tab, assert tabs "All 4" "Online 4" "Drafts 0" and the no-drafts EmptyState (icon
|
||||
pencil-line, title "No drafts", text "Everything you've written is online.", action "New
|
||||
article"); (b) mock 4 draft-only articles, select the Online tab, assert tabs "All 4" "Online 0"
|
||||
"Drafts 4" and the EmptyState titled "Nothing is online yet", text "Publish a draft and it will
|
||||
appear on your website.", action "Show drafts" (icon per the board, confirmed in the visual
|
||||
check below).
|
||||
9. **Action in progress** — from Default, choose "Publish" from id 4's menu; assert
|
||||
`PATCH /v1/article/4/publish` is called; assert id 4's row is disabled with busy label
|
||||
"Publishing…" and the other 5 rows stay enabled.
|
||||
10. **Published** — continuing from 9, resolve the PATCH 200 with `is_online: true`, updated "Just
|
||||
now"; assert id 4's row shows the online badge and "Just now", no longer busy; assert a toast
|
||||
was raised with id "article-published-4", icon circle-check, title "Article published", text
|
||||
"\"Winter checklist for timber windows\" is now online."
|
||||
11. **Action failed, publish, Examples-driven** — for each of a 500 response and a network
|
||||
rejection: choose "Publish" from id 4's menu, resolve/reject the mocked PATCH accordingly;
|
||||
assert id 4's row stays exactly unchanged (draft, "5 days ago", not busy) throughout — the row
|
||||
is never touched before the (failed) response, so there is nothing to revert; assert a toast
|
||||
with id "article-publish-failed-4", icon circle-x, title "Couldn't publish", text "The server
|
||||
didn't respond. Nothing changed.", action "Retry"; click "Retry"; assert the same PATCH is
|
||||
called again.
|
||||
12. **Action failed, unpublish, Examples-driven** — for each of a 500 response and a network
|
||||
rejection: choose "Unpublish" from id 3's menu (an online row), resolve/reject the mocked
|
||||
PATCH accordingly; assert id 3's row stays exactly unchanged (online, "3 days ago", not busy);
|
||||
assert a toast with id "article-unpublish-failed-3", icon circle-x, title "Couldn't
|
||||
unpublish", text "The server didn't respond. Nothing changed.", action "Retry"; click "Retry";
|
||||
assert the same PATCH is called again.
|
||||
13. **Row vanished, draft row, Examples-driven** — for each of (publish 404), (delete-confirm
|
||||
404) on id 2 (a draft row): trigger the action, resolve the mocked call 404; assert id 2's row
|
||||
is removed; assert tabs read "All 5" "Online 4" "Drafts 1"; assert a toast with id
|
||||
"article-not-found-2", icon info, title "That article no longer exists", text "It was deleted
|
||||
somewhere else, so we removed it from the list."
|
||||
14. **Row vanished, online row** — choose "Unpublish" from id 3's menu, resolve the mocked PATCH
|
||||
404; assert id 3's row is removed; assert tabs read "All 5" "Online 3" "Drafts 2"; assert a
|
||||
toast with id "article-not-found-3", icon info, title "That article no longer exists", text
|
||||
"It was deleted somewhere else, so we removed it from the list."
|
||||
15. **Delete confirm** — from Default (with id 4 carrying 3 images in the mocked
|
||||
`GET /v1/article/` response), choose "Delete…" from id 4's menu; assert ConfirmDialog opens
|
||||
with 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".
|
||||
16. **Delete in progress** — from the open dialog, click "Delete article"; assert
|
||||
`DELETE /v1/article/4` is called; assert both buttons are disabled, the confirm button reads
|
||||
"Deleting…", `aria-busy` is set, and Esc / backdrop click do not close it.
|
||||
17. **Delete failed** — continuing from 16, resolve the DELETE 500; assert the dialog stays open
|
||||
with the inline alert "Couldn't delete the article, so it's still there. Try again in a
|
||||
moment.", both buttons re-enabled; assert id 4's row is unchanged in the list behind the
|
||||
dialog.
|
||||
18. **Deleted** — continuing from 16 (a fresh attempt), resolve the DELETE 204; assert the dialog
|
||||
closes, id 4's row is removed, focus moves to the next row; assert tabs read "All 5" "Online
|
||||
4" "Drafts 1"; assert a toast with id "article-deleted-4", icon circle-check, title "Article
|
||||
deleted", text "\"Winter checklist for timber windows\" and its images are gone."
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/screens/articles.md` and `docs/design/source/RenewCMS Screens
|
||||
Back-office.dc.html`, group "Articles". Run `task visual:check -- articles` for each state below
|
||||
and compare against the board frame; add each state's mock setup to `web/visual/states.ts` first,
|
||||
reusing the 6-article fixture (ids/titles/dates above) as sample data. This screen shows relative
|
||||
dates ("2 hours ago", "5 days ago"), so fix `now` to the value the board's own script assumes for
|
||||
those relative strings to render identically (derive it from "2 hours ago" against "12 Mar 2026"
|
||||
and the absolute dates shown, e.g. "18 Aug 2026", per the board's `base` fixture in the source
|
||||
file).
|
||||
|
||||
- [ ] Default: tabs "All 6/Online 4/Drafts 2", 6 rows in fixture order, title/status/updated
|
||||
columns (confirm against the board frame directly whether a Created column is also shown —
|
||||
components.md's prose and the rendered board table disagree on this).
|
||||
- [ ] Row menu open: id 4's menu open with Edit/Publish/Copy API URL/Delete…, matching the board's
|
||||
dropdown placement and the brick-red Delete… treatment.
|
||||
- [ ] Loading: 5 skeleton rows shaped like the real layout.
|
||||
- [ ] Empty: EmptyState icon file-text, title, text, single "New article" action.
|
||||
- [ ] No results: EmptyState icon search, title with the quoted search term, text, "Clear search".
|
||||
- [ ] No drafts / Nothing online: both empty-tab EmptyStates, including the "Nothing is online
|
||||
yet" state's icon (confirm it directly against the board frame, since it isn't given in the
|
||||
prose extraction read for this slice).
|
||||
- [ ] Action in progress: id 4's row at 50% opacity with a spinner in the status cell and
|
||||
"aria-busy".
|
||||
- [ ] Published / Action failed toasts: colors, icons and the "Retry" action match the board.
|
||||
- [ ] Copy API URL: no board state depicts its toasts; verify their copy and icons directly in the
|
||||
app against this spec rather than a board frame.
|
||||
- [ ] Row vanished: the info toast and the row's removal match the board.
|
||||
- [ ] Delete confirm / in progress / failed: the dialog's three states match the board exactly,
|
||||
including the inline alert's placement in the failed state.
|
||||
- [ ] Deleted: toast copy matches, and focus visibly lands on the next row.
|
||||
- [ ] Keyboard/focus: roving tabindex across the tab list (Tab enters on the selected tab, ←/→
|
||||
move and activate); each row's title link is reachable and Enter opens the editor; each
|
||||
row's menu opens with Enter/Space/↓, arrow keys and type-ahead navigate it, Home/End jump to
|
||||
the ends, Esc closes it and returns focus to the ellipsis button; ConfirmDialog traps focus,
|
||||
starts on Cancel, and Enter does not confirm by default; on a successful delete, focus lands
|
||||
on the next row (or the page heading if none).
|
||||
- [ ] Motion: skeleton pulse and modal open/close follow the standard timings and stop under
|
||||
reduced motion.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real GET/PATCH/DELETE round trips against a running server** — Vitest only mocks `useApi`;
|
||||
confirm the real 200/404/500 responses, and the real `updated_at`-based ordering, against
|
||||
`task dev` with a live SQLite file, since these automated tests never exercise the actual Go
|
||||
handlers or use cases.
|
||||
- **Real .Preload("Images") behavior against a live SQLite file** — confirm `GET /v1/article/`
|
||||
returns accurate, non-empty `images` arrays for articles with real uploaded images, and `[]` for
|
||||
articles with none, against a running server, since the HTTP-level Go test above only proves the
|
||||
handler forwards whatever the (fake) repository returns, not that the real GORM query populates
|
||||
it correctly.
|
||||
- **Real image-count accuracy in the delete dialog** — confirm the delete dialog's count matches
|
||||
an article's actual attached images end to end (upload images, then delete), since Vitest only
|
||||
ever supplies a mocked count.
|
||||
- **Toast placement and stacking on a live app, including the three-toast cap** — confirm against
|
||||
a running app, since Vitest only asserts `useToast().add` was called with the right arguments.
|
||||
- **Real clipboard write on a live app** — confirm `navigator.clipboard.writeText` actually places
|
||||
the URL on the system clipboard in a real browser, since Vitest only mocks the clipboard API.
|
||||
Reference in new issue
Block a user