feat: articles list screen

This commit is contained in:
fsylvain committed 2026-09-27 17:12:23 +02:00
1 parent ca3fe20519
commit f6d0d01092
14 files changed
+2353 -25

No files matched your search

+259
View File
@@ -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."
+176
View File
@@ -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.
@@ -18,7 +18,7 @@ func NewArticleRepository(db *gorm.DB) *ArticleRepository {
}
func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Article, error) {
query := a.db.Model(&entity.Article{})
query := a.db.Model(&entity.Article{}).Preload("Images")
if filters.IsOnline != nil {
query = query.Where("is_online = ?", *filters.IsOnline)
+92
View File
@@ -0,0 +1,92 @@
package test
import (
"RenewCMS/api/handlers/article"
domainArticle "RenewCMS/internal/domain/article"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
validator "RenewCMS/api/pkg/validator"
)
func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
userRepo := newVerifiedUserRepo()
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo),
useCases.NewGetArticleUseCase(articleRepo),
useCases.NewListArticlesUseCase(articleRepo),
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
useCases.NewUpdateArticleUseCase(articleRepo),
getUserUseCase,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Get("/", articleHandler.ListArticles)
})
})
return r
}
func TestArticlesListHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
t.Run("GET /v1/article/ returns each article's images array populated", func(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[4] = domainArticle.Article{
ID: 4,
Title: "Winter checklist for timber windows",
Images: []*domainImage.Image{
{ID: 10, ArticleID: 4},
{ID: 11, ArticleID: 4},
},
}
router := newArticlesListRouter(repo, token)
req := httptest.NewRequest(http.MethodGet, "/v1/article/", nil)
setJwtCookie(t, req, token, 1)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
var got []domainArticle.Article
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
var found *domainArticle.Article
for i := range got {
if got[i].ID == 4 {
found = &got[i]
}
}
if found == nil {
t.Fatalf("expected article id 4 in response, got %v", got)
}
if len(found.Images) != 2 {
t.Fatalf("expected 2 images, got %d", len(found.Images))
}
ids := map[uint32]bool{}
for _, img := range found.Images {
ids[img.ID] = true
}
if !ids[10] || !ids[11] {
t.Errorf("expected images 10 and 11, got %v", found.Images)
}
})
}
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
const props = defineProps<{
articles: Article[]
loading: boolean
busyIds: Set<number>
now?: Date
}>()
const emit = defineEmits<{
publish: [id: number]
unpublish: [id: number]
delete: [article: Article]
'copy-url': [id: number]
}>()
const router = useRouter()
function editPath(article: Article) {
return `/article/${article.id}/edit`
}
function isBusy(article: Article) {
return props.busyIds.has(article.id)
}
function pendingFor(article: Article) {
if (!isBusy(article)) {
return undefined
}
return article.is_online ? ('unpublish' as const) : ('publish' as const)
}
function actionsFor(article: Article) {
return [
[
{
label: 'Edit',
icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)),
},
{
label: article.is_online ? 'Unpublish' : 'Publish',
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article),
onSelect: () => {
if (article.is_online) {
emit('unpublish', article.id)
} else {
emit('publish', article.id)
}
},
},
{
label: 'Copy API URL',
icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id),
},
],
[
{
label: 'Delete…',
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete', article),
},
],
]
}
function onRowClick(article: Article, event: MouseEvent) {
if ((event.target as HTMLElement).closest('[data-row-menu]')) {
return
}
router.push(editPath(article))
}
</script>
<template>
<ul
class="-mx-4 border-y border-default bg-default md:hidden"
:aria-busy="loading ? 'true' : undefined"
>
<template v-if="loading">
<li
v-for="n in 5"
:key="n"
class="flex min-h-17 flex-col justify-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
>
<USkeleton class="h-3.5 w-3/4 rounded-none bg-accented" />
<USkeleton class="h-3 w-2/5 rounded-none bg-accented" />
</li>
</template>
<template v-else>
<li v-if="articles.length === 0">
<slot name="empty" />
</li>
<li
v-for="article in articles"
:key="article.id"
class="relative flex min-h-17 items-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
:class="{ 'opacity-55': isBusy(article) }"
@click="onRowClick(article, $event)"
>
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<RouterLink
:to="editPath(article)"
:data-row-title="article.id"
class="truncate text-[0.9375rem]/[21px] font-medium text-highlighted"
@click.stop
>
{{ article.title }}
</RouterLink>
<div class="flex items-center gap-2.5 text-xs text-muted">
<ArticleStatusBadge
:online="article.is_online"
size="sm"
:pending="pendingFor(article)"
/>
<span :title="article.updated_at"
>Updated {{ formatRelativeDate(article.updated_at, now) }}</span
>
</div>
</div>
<UDropdownMenu
:items="actionsFor(article)"
:content="{ align: 'end' }"
data-row-menu
>
<UButton
icon="i-lucide-ellipsis-vertical"
color="neutral"
variant="ghost"
:aria-label="`Actions for ${article.title}`"
class="size-11 justify-center text-toned"
@click.stop
/>
</UDropdownMenu>
</li>
</template>
</ul>
</template>
+9 -3
View File
@@ -3,6 +3,7 @@ withDefaults(
defineProps<{
online: boolean
size?: 'sm' | 'md' | 'lg'
pending?: 'publish' | 'unpublish'
}>(),
{
size: 'md',
@@ -12,12 +13,17 @@ withDefaults(
<template>
<UBadge
v-if="online"
v-if="pending"
color="neutral"
variant="solid"
icon="i-lucide-globe"
variant="outline"
icon="i-lucide-loader-circle"
:size="size"
:class="size === 'lg' ? undefined : 'px-2'"
:ui="{ leadingIcon: 'animate-spin' }"
>
{{ pending === 'publish' ? 'Publishing…' : 'Unpublishing…' }}
</UBadge>
<UBadge v-else-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
Online
</UBadge>
<UBadge
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
const props = defineProps<{
articles: Article[]
loading: boolean
busyIds: Set<number>
now?: Date
}>()
const emit = defineEmits<{
publish: [id: number]
unpublish: [id: number]
delete: [article: Article]
'copy-url': [id: number]
}>()
const router = useRouter()
const columnWidths =
'[&:nth-child(2)]:w-27.5 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-35 [&:nth-child(5)]:w-11'
const columns = [
{ accessorKey: 'title', header: 'Title' },
{ accessorKey: 'is_online', header: 'Status', size: 110 },
{ accessorKey: 'created_at', header: 'Created', size: 130 },
{ accessorKey: 'updated_at', header: 'Updated', size: 140 },
{ id: 'actions', header: '', size: 44 },
]
function editPath(article: Article) {
return `/article/${article.id}/edit`
}
function isBusy(article: Article) {
return props.busyIds.has(article.id)
}
function pendingFor(article: Article) {
if (!isBusy(article)) {
return undefined
}
return article.is_online ? ('unpublish' as const) : ('publish' as const)
}
function actionsFor(article: Article) {
return [
[
{
label: 'Edit',
icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)),
},
{
label: article.is_online ? 'Unpublish' : 'Publish',
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article),
onSelect: () => {
if (article.is_online) {
emit('unpublish', article.id)
} else {
emit('publish', article.id)
}
},
},
{
label: 'Copy API URL',
icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id),
},
],
[
{
label: 'Delete…',
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete', article),
},
],
]
}
function onSelect(row: { original: Article }) {
router.push(editPath(row.original))
}
</script>
<template>
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
<span role="status" class="sr-only">Loading articles</span>
<div
v-for="n in 5"
:key="n"
class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0"
>
<USkeleton class="h-3 max-w-[55%] flex-1 rounded-none bg-accented" />
<USkeleton class="h-5 w-15 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
</div>
</div>
<div v-else class="hidden border border-default bg-default md:block">
<UTable
:data="articles"
:columns="columns"
:ui="{
base: 'table-fixed',
tr: 'cursor-pointer',
th: `px-3 ${columnWidths}`,
td: `h-11 px-3 py-0 ${columnWidths}`,
}"
@select="onSelect"
>
<template #caption>
<caption class="sr-only">
Articles, sorted by last updated, newest first
</caption>
</template>
<template #title-cell="{ row }">
<RouterLink
:to="editPath(row.original)"
:data-row-title="row.original.id"
class="font-medium text-highlighted"
@click.stop
>
{{ row.original.title }}
</RouterLink>
</template>
<template #is_online-cell="{ row }">
<ArticleStatusBadge
:online="row.original.is_online"
size="sm"
:pending="pendingFor(row.original)"
:aria-busy="isBusy(row.original) ? 'true' : undefined"
/>
</template>
<template #updated_at-header>
<span class="inline-flex items-center gap-1 text-highlighted">
Updated
<UIcon name="i-lucide-arrow-down" class="size-3" />
</span>
</template>
<template #created_at-cell="{ row }">
<span class="text-muted">{{
formatAbsoluteDateFromIso(row.original.created_at)
}}</span>
</template>
<template #updated_at-cell="{ row }">
<span class="text-muted" :title="row.original.updated_at">{{
formatRelativeDate(row.original.updated_at, now)
}}</span>
</template>
<template #actions-cell="{ row }">
<UDropdownMenu :items="actionsFor(row.original)" :content="{ align: 'end' }">
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
:aria-label="`Actions for ${row.original.title}`"
class="size-8 justify-center text-toned"
@click.stop
/>
</UDropdownMenu>
</template>
<template #empty>
<slot name="empty" />
</template>
</UTable>
</div>
</template>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
const props = defineProps<{
variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online'
query?: string
}>()
const emit = defineEmits<{ action: [] }>()
const truncatedQuery = computed(() => {
const value = props.query ?? ''
return value.length > QUERY_MAX_LENGTH ? value.slice(0, QUERY_MAX_LENGTH) : value
})
const variants = computed(() => ({
'no-articles': {
icon: 'i-lucide-file-text',
title: 'No articles yet',
description: 'Write your first one. It stays a draft until you publish it.',
action: 'New article',
primary: true,
},
'no-results': {
icon: 'i-lucide-search',
title: `No articles match "${truncatedQuery.value}"`,
description: 'Check the spelling, or search for a shorter part of the title.',
action: 'Clear search',
primary: false,
},
'no-drafts': {
icon: 'i-lucide-pencil-line',
title: 'No drafts',
description: "Everything you've written is online.",
action: 'New article',
primary: false,
},
'no-online': {
icon: 'i-lucide-globe',
title: 'Nothing is online yet',
description: 'Publish a draft and it will appear on your website.',
action: 'Show drafts',
primary: false,
},
}))
const current = computed(() => variants.value[props.variant])
</script>
<template>
<UEmpty
variant="naked"
:icon="current.icon"
:title="current.title"
:description="current.description"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-[0.9375rem] font-semibold',
description: 'max-w-70 text-muted text-pretty',
}"
>
<template #actions>
<UButton
:color="current.primary ? 'primary' : 'neutral'"
:variant="current.primary ? 'solid' : 'outline'"
class="h-8 px-3"
@click="emit('action')"
>
{{ current.action }}
</UButton>
</template>
</UEmpty>
</template>
+10 -19
View File
@@ -61,15 +61,8 @@ if (typeof window !== 'undefined') {
}
export function useApi() {
async function request<T>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const {
onUnauthorized = 'redirect',
onForbidden = 'redirect',
...init
} = options
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
let response: Response
try {
@@ -99,10 +92,7 @@ export function useApi() {
if (response.status === 403) {
const session = useSessionStore()
if (
onForbidden !== 'passthrough' &&
session.session?.verified === false
) {
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
router.push('/register/pending')
}
throw new ApiError(403)
@@ -115,16 +105,17 @@ export function useApi() {
throw new ApiError(response.status)
}
if (response.status === 204) {
return undefined as T
}
return response.json()
}
async function getMe(): Promise<MeResult> {
const body = await request<{ is_verified: boolean; username?: string }>(
'/v1/auth/me',
{
onUnauthorized: 'passthrough',
},
)
const body = await request<{ is_verified: boolean; username?: string }>('/v1/auth/me', {
onUnauthorized: 'passthrough',
})
return { verified: Boolean(body.is_verified), username: body.username }
}
+4
View File
@@ -22,6 +22,10 @@ function formatAbsoluteDate(date: Date) {
return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`
}
export function formatAbsoluteDateFromIso(iso: string) {
return formatAbsoluteDate(new Date(iso))
}
export function formatRelativeDate(iso: string, now: Date = new Date()) {
const date = new Date(iso)
const diffMs = now.getTime() - date.getTime()
+406 -1
View File
@@ -1,9 +1,414 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import DashboardShell from '@/components/DashboardShell.vue'
import ArticleTable from '@/components/ArticleTable.vue'
import ArticleCardList from '@/components/ArticleCardList.vue'
import EmptyState from '@/components/EmptyState.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { useArticles, type Article } from '@/composables/useArticles'
const SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120
const route = useRoute()
const router = useRouter()
const { request } = useApi()
const { listArticles } = useArticles()
const overlay = useOverlay()
const toast = useToast()
const articles = ref<Article[]>([])
const loading = ref(true)
const loadFailed = ref(false)
const showSkeleton = ref(false)
const busyIds = ref<Set<number>>(new Set())
const newArticleModalOpen = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
const searchInput = ref(String(route.query.q ?? ''))
const debouncedSearch = ref(searchInput.value)
function normalize(value: string) {
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
watch(searchInput, (value) => {
if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer)
}
searchDebounceTimer = setTimeout(() => {
debouncedSearch.value = value
router.replace({
query: { ...route.query, q: value || undefined },
})
}, SEARCH_DEBOUNCE_MS)
})
const activeTab = computed<'' | 'online' | 'draft'>({
get: () =>
route.query.status === 'online' || route.query.status === 'draft' ? route.query.status : '',
set: (value) => {
router.replace({ query: { ...route.query, status: value || undefined } })
},
})
const counts = computed(() => {
const online = articles.value.filter((article) => article.is_online).length
return {
all: articles.value.length,
online,
drafts: articles.value.length - online,
}
})
const tabItems = computed(() => [
{ label: `All ${counts.value.all}`, value: '' },
{ label: `Online ${counts.value.online}`, value: 'online' },
{ label: `Drafts ${counts.value.drafts}`, value: 'draft' },
])
const statusFilteredArticles = computed(() => {
if (activeTab.value === 'online') {
return articles.value.filter((article) => article.is_online)
}
if (activeTab.value === 'draft') {
return articles.value.filter((article) => !article.is_online)
}
return articles.value
})
const displayedArticles = computed(() => {
const search = normalize(debouncedSearch.value.trim())
if (!search) {
return statusFilteredArticles.value
}
return statusFilteredArticles.value.filter((article) =>
normalize(article.title).includes(search),
)
})
const emptyVariant = computed(() => {
if (loading.value || loadFailed.value) {
return null
}
if (articles.value.length === 0) {
return 'no-articles' as const
}
if (debouncedSearch.value.trim() && displayedArticles.value.length === 0) {
return 'no-results' as const
}
if (statusFilteredArticles.value.length === 0) {
return activeTab.value === 'draft' ? ('no-drafts' as const) : ('no-online' as const)
}
return null
})
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function loadArticles() {
loading.value = true
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
loadFailed.value = false
try {
articles.value = await listArticles()
} catch {
articles.value = []
loadFailed.value = true
} finally {
loading.value = false
clearSkeletonTimer()
showSkeleton.value = false
}
}
function setBusy(id: number, busy: boolean) {
const next = new Set(busyIds.value)
if (busy) {
next.add(id)
} else {
next.delete(id)
}
busyIds.value = next
}
function removeArticle(id: number) {
articles.value = articles.value.filter((article) => article.id !== id)
}
function notFoundToast(article: Article) {
return {
id: `article-not-found-${article.id}`,
color: 'info' as const,
icon: 'i-lucide-info',
title: 'That article no longer exists',
description: 'It was deleted somewhere else, so we removed it from the list.',
}
}
async function setOnline(article: Article, makeOnline: boolean) {
setBusy(article.id, true)
try {
const updated = await request<Article>(
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}`,
{ method: 'PATCH' },
)
articles.value = articles.value.map((existing) =>
existing.id === updated.id ? updated : existing,
)
toast.add({
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: makeOnline ? 'Article published' : 'Article unpublished',
description: makeOnline
? `"${article.title}" is now online.`
: `"${article.title}" is now offline.`,
})
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id)
toast.add(notFoundToast(article))
return
}
toast.add({
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${article.id}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: makeOnline ? "Couldn't publish" : "Couldn't unpublish",
description: "The server didn't respond. Nothing changed.",
duration: 0,
actions: [{ label: 'Retry', onClick: () => setOnline(article, makeOnline) }],
})
} finally {
setBusy(article.id, false)
}
}
function handlePublish(id: number) {
const article = articles.value.find((existing) => existing.id === id)
if (article) {
setOnline(article, true)
}
}
function handleUnpublish(id: number) {
const article = articles.value.find((existing) => existing.id === id)
if (article) {
setOnline(article, false)
}
}
async function handleCopyUrl(id: number) {
const article = articles.value.find((existing) => existing.id === id)
if (!article) {
return
}
const url = `${window.location.origin}/v1/article/${id}`
try {
await navigator.clipboard.writeText(url)
toast.add({
id: `article-link-copied-${id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Link copied',
description: `The API URL for "${article.title}" is on your clipboard.`,
})
} catch {
toast.add({
id: `article-link-copy-failed-${id}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't copy the link",
})
}
}
function deleteDescription(article: Article) {
const count = article.images.length
const imagesPhrase =
count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images`
return `"${article.title}"${imagesPhrase} will be deleted. This can't be undone.`
}
async function performDelete(article: Article) {
try {
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
removeArticle(article.id)
toast.add({
id: `article-deleted-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: `"${article.title}" and its images are gone.`,
})
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id)
toast.add(notFoundToast(article))
return
}
throw new Error("Couldn't delete the article, so it's still there. Try again in a moment.")
}
}
async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) {
await nextTick()
const index = idsAtOpen.indexOf(deletedId)
const nextId = idsAtOpen[index + 1] ?? idsAtOpen.slice(0, index).reverse()[0]
if (nextId != null) {
document.querySelector<HTMLElement>(`[data-row-title="${nextId}"]`)?.focus()
return
}
document.querySelector<HTMLElement>('[data-testid="articles-heading"]')?.focus()
}
async function handleDelete(article: Article) {
const idsAtOpen = displayedArticles.value.map((existing) => existing.id)
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
title: 'Delete this article?',
description: deleteDescription(article),
confirmLabel: 'Delete article',
cancelLabel: 'Cancel',
pendingLabel: 'Deleting…',
tone: 'danger',
action: () => performDelete(article),
},
})
.open()
if (confirmed) {
focusRowOrHeading(idsAtOpen, article.id)
}
}
function openNewArticleModal() {
newArticleModalOpen.value = true
}
function onArticleCreated(article: Article) {
router.push(`/article/${article.id}/edit`)
}
function clearSearch() {
searchInput.value = ''
debouncedSearch.value = ''
router.replace({ query: { ...route.query, q: undefined } })
nextTick(() => {
document.querySelector<HTMLInputElement>('input[placeholder="Search by title"]')?.focus()
})
}
function onEmptyAction() {
if (emptyVariant.value === 'no-results') {
clearSearch()
return
}
if (emptyVariant.value === 'no-online') {
activeTab.value = 'draft'
return
}
openNewArticleModal()
}
onMounted(loadArticles)
</script>
<template>
<DashboardShell>
<p>Articles placeholder.</p>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1
data-testid="articles-heading"
tabindex="-1"
class="text-xl font-semibold text-highlighted"
>
Articles
</h1>
<p class="text-muted">Everything you've written, online and in draft.</p>
</div>
<UButton
icon="i-lucide-plus"
class="hidden h-8 px-3 sm:flex"
@click="openNewArticleModal"
>
New article
</UButton>
</div>
<div class="flex flex-col-reverse gap-4 sm:flex-row sm:items-end sm:justify-between">
<UTabs
v-model="activeTab"
:items="tabItems"
variant="link"
color="neutral"
:content="false"
:ui="{ list: 'gap-5', trigger: 'px-0' }"
/>
<UInput
v-model="searchInput"
type="search"
icon="i-lucide-search"
placeholder="Search by title"
class="w-full sm:w-65"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
/>
</div>
<template v-if="!loading || showSkeleton">
<EmptyState
v-if="emptyVariant"
:variant="emptyVariant"
:query="debouncedSearch"
@action="onEmptyAction"
/>
<template v-else>
<ArticleTable
:articles="displayedArticles"
:loading="loading && showSkeleton"
:busy-ids="busyIds"
@publish="handlePublish"
@unpublish="handleUnpublish"
@delete="handleDelete"
@copy-url="handleCopyUrl"
/>
<ArticleCardList
:articles="displayedArticles"
:loading="loading && showSkeleton"
:busy-ids="busyIds"
@publish="handlePublish"
@unpublish="handleUnpublish"
@delete="handleDelete"
@copy-url="handleCopyUrl"
/>
</template>
</template>
</div>
<UButton
icon="i-lucide-plus"
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover) sm:hidden"
@click="openNewArticleModal"
>
New article
</UButton>
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
</DashboardShell>
</template>
+667
View File
@@ -0,0 +1,667 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleView from '../ArticleView.vue'
const NOW = new Date('2026-08-26T10:00:00Z')
const HOUR_MS = 60 * 60 * 1000
const DAY_MS = 24 * HOUR_MS
function createOverlayMock() {
let props: Record<string, unknown> | null = null
let resolveOpen: ((value: unknown) => void) | null = null
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
})
return {
create,
getProps: () => props,
resolveOpen: (value: unknown) => resolveOpen?.(value),
}
}
let overlayMock: ReturnType<typeof createOverlayMock>
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useOverlay: () => overlayMock,
useToast: () => ({ add: toastAdd }),
}
})
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
function routeFor(url: string, init?: RequestInit) {
const method = init?.method ?? 'GET'
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
}
function iso(offsetMs: number) {
return new Date(NOW.getTime() - offsetMs).toISOString()
}
function sixArticleFixture(): Article[] {
return [
{
id: 1,
title: 'How we price a bathroom renovation',
body: '',
images: [],
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: iso(2 * HOUR_MS),
},
{
id: 2,
title: 'Choosing lime plaster for old stone walls',
body: '',
images: [],
is_online: false,
created_at: '2026-09-09T09:00:00Z',
updated_at: iso(1 * DAY_MS),
},
{
id: 3,
title: 'Before and after: the Rue Verte kitchen',
body: '',
images: [],
is_online: true,
created_at: '2026-08-28T09:00:00Z',
updated_at: iso(3 * DAY_MS),
},
{
id: 4,
title: 'Winter checklist for timber windows',
body: '',
images: [
{ id: 10, url: '/i/10.png' },
{ id: 11, url: '/i/11.png' },
{ id: 12, url: '/i/12.png' },
],
is_online: false,
created_at: '2026-09-02T09:00:00Z',
updated_at: iso(5 * DAY_MS),
},
{
id: 5,
title: 'Why we reuse original floorboards',
body: '',
images: [],
is_online: true,
created_at: '2026-06-14T09:00:00Z',
updated_at: '2026-08-18T09:00:00Z',
},
{
id: 6,
title: 'Our first passive-house retrofit',
body: '',
images: [],
is_online: true,
created_at: '2026-04-03T09:00:00Z',
updated_at: '2026-07-02T09:00:00Z',
},
]
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountArticles(handlers: Record<string, () => Response | Promise<Response>>) {
setActivePinia(createPinia())
overlayMock = createOverlayMock()
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
await router.push('/article')
const wrapper = mount(ArticleView, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
function findButton(wrapper: Awaited<ReturnType<typeof mount>>, label: string) {
return wrapper.findAll('button').find((button) => button.text().trim() === label)
}
async function clickTab(label: string) {
const button = Array.from(document.body.querySelectorAll('button')).find(
(el) => el.textContent?.trim() === label,
) as HTMLButtonElement | undefined
button?.focus()
button?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
await flushPromises()
}
async function openRowMenu(wrapper: Awaited<ReturnType<typeof mount>>, title: string) {
const button = document.body.querySelector<HTMLButtonElement>(
`button[aria-label="Actions for ${title}"]`,
)
button?.click()
await flushPromises()
}
async function chooseMenuItem(label: string) {
const item = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(el) => el.textContent?.trim() === label,
)
;(item as HTMLElement | undefined)?.click()
await flushPromises()
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
vi.useRealTimers()
await router.push('/login')
})
describe('Default — the list loads sorted by last updated, newest first', () => {
it('shows the breadcrumb, tab counts and every row in fixture order', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
})
expect(wrapper.text()).toContain('All 6')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 2')
const titles = sixArticleFixture().map((a) => a.title)
const rendered = wrapper.text()
for (const title of titles) {
expect(rendered).toContain(title)
}
expect(wrapper.find('caption').text()).toBe(
'Articles, sorted by last updated, newest first',
)
const firstLink = wrapper.find('[data-row-title="1"]')
expect(firstLink.attributes('href')).toBe('/article/1/edit')
})
})
describe('Loading — skeleton rows while the first request is in flight', () => {
it('shows skeleton rows and aria-busy, then replaces them once resolved', async () => {
let resolveList: (() => void) | null = null
const wrapper = await mountArticles({
'GET /v1/article/': () =>
new Promise((resolve) => {
resolveList = () => resolve(mockResponse(200, sixArticleFixture()))
}),
})
await vi.advanceTimersByTimeAsync(150)
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
resolveList?.()
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.text()).toContain('How we price a bathroom renovation')
})
})
describe('Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…', () => {
it('opens a menu named "Actions for Winter checklist for timber windows"', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
const items = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map((el) =>
el.textContent?.trim(),
)
expect(items).toEqual(['Edit', 'Publish', 'Copy API URL', 'Delete…'])
})
})
describe('Copy API URL succeeds — the absolute URL is copied and confirmed', () => {
it('writes the URL to the clipboard and raises a success toast', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Copy API URL')
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/v1/article/4`)
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-link-copied-4',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Link copied',
description:
'The API URL for "Winter checklist for timber windows" is on your clipboard.',
})
})
})
describe('Copy API URL fails — the clipboard write itself is rejected', () => {
it('raises an error toast with no text line', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Copy API URL')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-link-copy-failed-4',
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't copy the link",
})
})
})
describe('Empty — zero articles', () => {
it('shows the no-articles empty state and opens NewArticleModal', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, []),
})
expect(wrapper.text()).toContain('All 0')
expect(wrapper.text()).toContain('Online 0')
expect(wrapper.text()).toContain('Drafts 0')
expect(wrapper.text()).toContain('No articles yet')
expect(wrapper.text()).toContain(
'Write your first one. It stays a draft until you publish it.',
)
await findButton(wrapper, 'New article')?.trigger('click')
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).toBeTruthy()
})
})
describe('Initial load failure — an error must not render the no-articles empty state', () => {
it('does not show "No articles yet" when GET /v1/article/ fails', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(500),
})
expect(wrapper.text()).not.toContain('No articles yet')
})
})
describe('No results — a search matches nothing, but the tab counts still show the full list', () => {
it('shows the no-results empty state, then clears the search', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
})
const searchInput = wrapper.find('input[type="search"]')
await searchInput.setValue('tiles')
await vi.advanceTimersByTimeAsync(120)
await flushPromises()
expect(wrapper.text()).toContain('No articles match "tiles"')
expect(wrapper.text()).toContain(
'Check the spelling, or search for a shorter part of the title.',
)
expect(wrapper.text()).toContain('All 6')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 2')
await findButton(wrapper, 'Clear search')?.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('How we price a bathroom renovation')
const inputEl = document.querySelector('input[placeholder="Search by title"]')
expect(inputEl).toBe(document.activeElement)
})
})
describe('An empty tab shows its own EmptyState, while the other tab still counts correctly', () => {
it('shows No drafts when every article is online', async () => {
const onlineOnly = sixArticleFixture()
.slice(0, 4)
.map((a) => ({ ...a, is_online: true }))
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, onlineOnly),
})
await clickTab('Drafts 0')
expect(wrapper.text()).toContain('All 4')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 0')
expect(wrapper.text()).toContain('No drafts')
expect(wrapper.text()).toContain("Everything you've written is online.")
})
it('shows Nothing is online yet when every article is a draft', async () => {
const draftsOnly = sixArticleFixture()
.slice(0, 4)
.map((a) => ({ ...a, is_online: false }))
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, draftsOnly),
})
await clickTab('Online 0')
expect(wrapper.text()).toContain('All 4')
expect(wrapper.text()).toContain('Online 0')
expect(wrapper.text()).toContain('Drafts 4')
expect(wrapper.text()).toContain('Nothing is online yet')
expect(wrapper.text()).toContain('Publish a draft and it will appear on your website.')
})
})
describe('Action in progress — publishing a row disables only that row', () => {
it('sends the PATCH and shows the busy label for that row alone', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/4/publish': () => new Promise(() => {}),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Publish')
expect(wrapper.text()).toContain('Publishing…')
expect(wrapper.text()).toContain('Choosing lime plaster for old stone walls')
})
})
describe('Published — a 200 updates the row in place and raises a toast', () => {
it('shows the online badge, "Just now" and the success toast', async () => {
const updated = {
...sixArticleFixture()[3],
is_online: true,
updated_at: iso(0),
}
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/4/publish': () => mockResponse(200, updated),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Publish')
await flushPromises()
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-published-4',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article published',
description: '"Winter checklist for timber windows" is now online.',
})
})
})
describe('Action failed, publish — a 500 or a network error leaves the row unchanged and offers Retry', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/4/publish': patchCall,
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Publish')
await flushPromises()
expect(wrapper.text()).toContain('Winter checklist for timber windows')
expect(wrapper.text()).not.toContain('Publishing…')
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
id: 'article-publish-failed-4',
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't publish",
description: "The server didn't respond. Nothing changed.",
duration: 0,
}),
)
const call = toastAdd.mock.calls.find(([arg]) => arg.id === 'article-publish-failed-4')
expect(patchCall).toHaveBeenCalledTimes(1)
call?.[0].actions[0].onClick()
await flushPromises()
expect(patchCall).toHaveBeenCalledTimes(2)
})
})
describe('Action failed, unpublish — a 500 or a network error leaves the row unchanged and offers Retry', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/3/unpublish': patchCall,
})
await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen')
await chooseMenuItem('Unpublish')
await flushPromises()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
id: 'article-unpublish-failed-3',
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't unpublish",
description: "The server didn't respond. Nothing changed.",
duration: 0,
}),
)
const call = toastAdd.mock.calls.find(([arg]) => arg.id === 'article-unpublish-failed-3')
expect(patchCall).toHaveBeenCalledTimes(1)
call?.[0].actions[0].onClick()
await flushPromises()
expect(patchCall).toHaveBeenCalledTimes(2)
})
})
describe('Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast', () => {
it('removes the row on a 404 publish response', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/2/publish': () => mockResponse(404),
})
await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls')
await chooseMenuItem('Publish')
await flushPromises()
expect(wrapper.text()).not.toContain('Choosing lime plaster for old stone walls')
expect(wrapper.text()).toContain('All 5')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 1')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-not-found-2',
color: 'info',
icon: 'i-lucide-info',
title: 'That article no longer exists',
description: 'It was deleted somewhere else, so we removed it from the list.',
})
})
it('removes the row when the delete confirmation resolves 404', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'DELETE /v1/article/2': () => mockResponse(404),
})
await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls')
await chooseMenuItem('Delete…')
await flushPromises()
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(wrapper.text()).not.toContain('Choosing lime plaster for old stone walls')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-not-found-2',
color: 'info',
icon: 'i-lucide-info',
title: 'That article no longer exists',
description: 'It was deleted somewhere else, so we removed it from the list.',
})
})
})
describe('Row vanished, online row — a 404 on unpublish removes the row and shows an info toast', () => {
it('removes the row and updates the tab counts', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/3/unpublish': () => mockResponse(404),
})
await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen')
await chooseMenuItem('Unpublish')
await flushPromises()
expect(wrapper.text()).not.toContain('Before and after: the Rue Verte kitchen')
expect(wrapper.text()).toContain('All 5')
expect(wrapper.text()).toContain('Online 3')
expect(wrapper.text()).toContain('Drafts 2')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-not-found-3',
color: 'info',
icon: 'i-lucide-info',
title: 'That article no longer exists',
description: 'It was deleted somewhere else, so we removed it from the list.',
})
})
})
describe('Delete confirm — opens the dialog with the row title and image count', () => {
it('opens ConfirmDialog with the title and 3 images', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Delete…')
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Delete this article?')
expect(props.description).toBe(
'"Winter checklist for timber windows" and its 3 images will be deleted. This can\'t be undone.',
)
expect(props.confirmLabel).toBe('Delete article')
expect(props.cancelLabel).toBe('Cancel')
expect(props.pendingLabel).toBe('Deleting…')
expect(props.tone).toBe('danger')
})
})
describe('Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row', () => {
it('removes the row, recomputes tabs and raises the success toast', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'DELETE /v1/article/4': () => mockResponse(204),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Delete…')
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
overlayMock.resolveOpen(true)
await flushPromises()
expect(wrapper.text()).not.toContain('Winter checklist for timber windows')
expect(wrapper.text()).toContain('All 5')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 1')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-deleted-4',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: '"Winter checklist for timber windows" and its images are gone.',
})
})
})
describe('Delete failed — a 500 keeps the article and rejects with the inline message', () => {
it('throws the inline error message and leaves the article in the list', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'DELETE /v1/article/4': () => mockResponse(500),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Delete…')
const props = overlayMock.getProps() as { action: () => Promise<void> }
await expect(props.action()).rejects.toThrow(
"Couldn't delete the article, so it's still there. Try again in a moment.",
)
expect(wrapper.text()).toContain('Winter checklist for timber windows')
})
})
+15 -1
View File
@@ -311,7 +311,21 @@ async function captureApp(
await page.clock.install({ time })
await page.clock.pauseAt(time)
} else if (setup.now) {
await page.clock.setFixedTime(new Date(setup.now))
await page.addInitScript((iso) => {
const anchorTime = new Date(iso).getTime()
const RealDate = Date
const realNow = RealDate.now()
function currentTime() {
return anchorTime + (RealDate.now() - realNow)
}
function TickingDate(...args: unknown[]) {
const dateArgs = args as ConstructorParameters<typeof Date>
return args.length === 0 ? new RealDate(currentTime()) : new RealDate(...dateArgs)
}
TickingDate.prototype = RealDate.prototype
TickingDate.now = currentTime
globalThis.Date = TickingDate as unknown as DateConstructor
}, setup.now)
}
await page.goto(`${origin}${setup.path}`)
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)])
+312
View File
@@ -235,7 +235,319 @@ function submitNewArticle() {
}
}
const articlesNow = '2026-09-15T12:00:00Z'
const HOUR_MS = 60 * 60 * 1000
const DAY_MS = 24 * HOUR_MS
function articlesFixtureIso(offsetMs: number) {
return new Date(new Date(articlesNow).getTime() - offsetMs).toISOString()
}
function articlesFixture() {
return [
{
id: 1,
title: 'How we price a bathroom renovation',
body: '',
images: [],
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: articlesFixtureIso(2 * HOUR_MS),
},
{
id: 2,
title: 'Choosing lime plaster for old stone walls',
body: '',
images: [],
is_online: false,
created_at: '2026-09-09T09:00:00Z',
updated_at: articlesFixtureIso(1 * DAY_MS),
},
{
id: 3,
title: 'Before and after: the Rue Verte kitchen',
body: '',
images: [],
is_online: true,
created_at: '2026-08-28T09:00:00Z',
updated_at: articlesFixtureIso(3 * DAY_MS),
},
{
id: 4,
title: 'Winter checklist for timber windows',
body: '',
images: [],
is_online: false,
created_at: '2026-09-02T09:00:00Z',
updated_at: articlesFixtureIso(5 * DAY_MS),
},
{
id: 5,
title: 'Why we reuse original floorboards',
body: '',
images: [],
is_online: true,
created_at: '2026-06-14T09:00:00Z',
updated_at: '2026-08-18T09:00:00Z',
},
{
id: 6,
title: 'Our first passive-house retrofit',
body: '',
images: [],
is_online: true,
created_at: '2026-04-03T09:00:00Z',
updated_at: '2026-07-02T09:00:00Z',
},
]
}
function articlesFixtureWithImages() {
return articlesFixture().map((article) =>
article.id === 4
? {
...article,
images: [
{ id: 10, url: '/images/10.png' },
{ id: 11, url: '/images/11.png' },
{ id: 12, url: '/images/12.png' },
],
}
: article,
)
}
function articlesApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: articlesFixture() },
...overrides,
}
}
async function openArticleRowMenu(page: Page, title: string) {
await page.getByRole('button', { name: `Actions for ${title}` }).click()
}
async function chooseArticleRowMenuItem(page: Page, label: string) {
await page.getByRole('menuitem', { name: label, exact: true }).click()
}
export const visualStates: Record<string, Record<string, VisualState>> = {
articles: {
Default: {
path: '/article',
now: articlesNow,
api: articlesApi(),
exception: {
maxPercent: 3.7,
reason: 'UTable column boundaries land ~9-12px off the board grid (110/130/140/44px), unresolved by nth-child width overrides on table-fixed',
approvedBy: 'Florian Sylvain',
},
},
'Row menu open': {
path: '/article',
now: articlesNow,
api: articlesApi(),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
},
exception: {
maxPercent: 7.5,
reason: 'Same UTable column-boundary offset as Default, plus the open row menu popover rendering',
approvedBy: 'Florian Sylvain',
},
},
Loading: {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': { status: 200, body: articlesFixture(), hang: true },
}),
exception: {
maxPercent: 10.6,
reason: 'Skeleton placeholder block sizes/positions only approximate the board skeleton shape',
approvedBy: 'Florian Sylvain',
},
},
Empty: {
path: '/article',
now: articlesNow,
api: articlesApi({ 'GET /v1/article/': { status: 200, body: [] } }),
exception: {
maxPercent: 3.6,
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the empty layout',
approvedBy: 'Florian Sylvain',
},
},
'No results': {
path: '/article',
now: articlesNow,
api: articlesApi(),
steps: async (page) => {
await page.getByPlaceholder('Search by title').fill('tiles')
await page.waitForTimeout(200)
},
exception: {
maxPercent: 3.3,
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-results layout',
approvedBy: 'Florian Sylvain',
},
},
'No drafts': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': {
status: 200,
body: articlesFixture()
.slice(0, 4)
.map((article) => ({ ...article, is_online: true })),
},
}),
steps: async (page) => {
await page.getByRole('tab', { name: /^Drafts/ }).focus()
await page.keyboard.press('Enter')
},
exception: {
maxPercent: 2.7,
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-drafts layout',
approvedBy: 'Florian Sylvain',
},
},
'Action in progress': {
path: '/article',
now: articlesNow,
api: articlesApi({
'PATCH /v1/article/4/publish': { status: 200, hang: true },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Publish')
},
exception: {
maxPercent: 3.6,
reason: 'Same UTable column-boundary offset as Default, plus the busy badge/spinner rendering',
approvedBy: 'Florian Sylvain',
},
},
Published: {
path: '/article',
now: articlesNow,
api: articlesApi({
'PATCH /v1/article/4/publish': {
status: 200,
body: {
...articlesFixture()[3],
is_online: true,
updated_at: articlesNow,
},
},
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Publish')
await page.getByText('Article published').first().waitFor()
},
exception: {
maxPercent: 4.6,
reason: 'Same UTable column-boundary offset as Default, plus the success toast rendering',
approvedBy: 'Florian Sylvain',
},
},
'Action failed': {
path: '/article',
now: articlesNow,
api: articlesApi({ 'PATCH /v1/article/3/unpublish': { status: 500 } }),
steps: async (page) => {
await openArticleRowMenu(page, 'Before and after: the Rue Verte kitchen')
await chooseArticleRowMenuItem(page, 'Unpublish')
await page.getByText("Couldn't unpublish").first().waitFor()
},
exception: {
maxPercent: 5.7,
reason: 'Same UTable column-boundary offset as Default, plus the error toast with Retry rendering',
approvedBy: 'Florian Sylvain',
},
},
'Row vanished (404)': {
path: '/article',
now: articlesNow,
api: articlesApi({ 'PATCH /v1/article/2/publish': { status: 404 } }),
steps: async (page) => {
await openArticleRowMenu(page, 'Choosing lime plaster for old stone walls')
await chooseArticleRowMenuItem(page, 'Publish')
await page.getByText('That article no longer exists').first().waitFor()
},
exception: {
maxPercent: 4.3,
reason: 'Same UTable column-boundary offset as Default, plus the info toast after row removal',
approvedBy: 'Florian Sylvain',
},
},
'Delete: confirm': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page.getByRole('alertdialog').waitFor()
},
},
'Delete: in progress': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() },
'DELETE /v1/article/4': { status: 204, hang: true },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete article' })
.click()
},
},
'Delete: failed': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() },
'DELETE /v1/article/4': { status: 500 },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete article' })
.click()
await page.getByText("Couldn't delete the article").waitFor()
},
},
Deleted: {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() },
'DELETE /v1/article/4': { status: 204 },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete article' })
.click()
await page.getByText('Article deleted').first().waitFor()
},
},
},
home: {
Default: {
path: '/home',