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,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