feat: articles list screen
This commit is contained in:
1 parent
ca3fe20519
commit
f6d0d01092
14 files changed
+2353
-25
No files matched your search
@@ -0,0 +1,259 @@
|
|||||||
|
Feature: Articles list screen
|
||||||
|
|
||||||
|
As a verified RenewCMS owner
|
||||||
|
I want /article to list every article with its status, let me search and filter it, and let me
|
||||||
|
publish, unpublish or delete a row without leaving the page
|
||||||
|
So that I can manage my whole set of articles from one place, with the list always reflecting
|
||||||
|
what the server actually holds
|
||||||
|
|
||||||
|
Background:
|
||||||
|
Given /article is a verified route (meta.auth "verified") rendered as ArticlesPage inside
|
||||||
|
DashboardShell, per docs/design/routes.md
|
||||||
|
And ArticlesPage calls GET /v1/article/ once per navigation to load the full list (all
|
||||||
|
articles, online and draft, because the request carries the jwt cookie), and every row
|
||||||
|
action goes over PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish or
|
||||||
|
DELETE /v1/article/{id}; all three endpoints already exist and this slice wires the SPA to
|
||||||
|
them unchanged, except for one persistence-layer change: ArticleRepository.FindByFilters
|
||||||
|
(which backs GET /v1/article/) gains a .Preload("Images") call, mirroring the one
|
||||||
|
ArticleRepository.Get already has, so every article in the list response carries its real
|
||||||
|
Images array instead of coming back empty; no domain or use-case change is needed, since
|
||||||
|
article.Article.Images already exists
|
||||||
|
And the PageHeader shows breadcrumb "Articles" (mobile title "Articles")
|
||||||
|
And search and the active tab live in the URL query string (?q=, ?status=), so the filtered
|
||||||
|
view can be linked and survives a reload; the list is always sorted by last updated, newest
|
||||||
|
first, with no interactive sort control in this slice; search is case-insensitive, ignores
|
||||||
|
accents, matches anywhere in the title, and is debounced 120 ms; tab counts (All, Online,
|
||||||
|
Drafts) are always computed from the full list fetched by GET /v1/article/, so they never
|
||||||
|
change while the user is only searching
|
||||||
|
And the list is rendered as ArticleTable (desktop, md and up) or ArticleCardList (below md),
|
||||||
|
both fed by the same articles array, loading flag and per-row busy state, and both routed
|
||||||
|
through the same row actions
|
||||||
|
And each row shows its title (a real link to /article/:id/edit), an ArticleStatusBadge (online
|
||||||
|
or draft), and its updated date (relative within 7 days, e.g. "5 days ago"; an absolute date
|
||||||
|
beyond that, e.g. "18 Aug 2026", with the full timestamp in a title attribute)
|
||||||
|
And clicking anywhere on a row except its menu button navigates to /article/:id/edit
|
||||||
|
And each row's ellipsis button (or Enter while it is focused) opens that row's menu, named
|
||||||
|
"Actions for {title}", listing Edit (to /article/:id/edit), Publish or Unpublish (whichever
|
||||||
|
applies to that row's current status), Copy API URL, and, below a separator, "Delete…" in
|
||||||
|
brick red, always last
|
||||||
|
And "Copy API URL" writes the absolute URL "{origin}/v1/article/{id}" to the clipboard; on
|
||||||
|
success it raises a success toast (icon circle-check, id "article-link-copied-{id}"): title
|
||||||
|
"Link copied" text "The API URL for \"{title}\" is on your clipboard."; if the clipboard
|
||||||
|
write itself fails, it raises an error toast instead (icon circle-x, id
|
||||||
|
"article-link-copy-failed-{id}"): title "Couldn't copy the link", with no text line
|
||||||
|
And row actions are never optimistic: a row only changes once the server responds; while a
|
||||||
|
publish or unpublish request for a row is in flight, that row alone is disabled and shows a
|
||||||
|
busy label ("Publishing…" or "Unpublishing…"), and every other row stays interactive
|
||||||
|
And a successful publish or unpublish (200) updates that row's ArticleStatusBadge and updated
|
||||||
|
date in place and raises a success toast (icon circle-check, id
|
||||||
|
"article-published-{id}" or "article-unpublished-{id}" per components.md's action-plus-id
|
||||||
|
toast convention): title "Article published" text "\"{title}\" is now online." for a
|
||||||
|
publish, or title "Article unpublished" text "\"{title}\" is now offline." for an unpublish
|
||||||
|
And a 500 or network failure on a publish or unpublish leaves that row exactly as it was
|
||||||
|
before the request and raises an error toast (icon circle-x, duration 0 so it stays until
|
||||||
|
dismissed or retried, id "article-publish-failed-{id}" or "article-unpublish-failed-{id}"):
|
||||||
|
title "Couldn't publish" or "Couldn't unpublish", text "The server didn't respond. Nothing
|
||||||
|
changed.", with an action "Retry" that re-sends the same request
|
||||||
|
And a 404 on any row action (publish, unpublish or delete) closes any open row menu or delete
|
||||||
|
dialog for that row, removes the row from the list, recomputes the tab counts from the
|
||||||
|
remaining articles, and raises an info toast (icon info, id "article-not-found-{id}"): title
|
||||||
|
"That article no longer exists" text "It was deleted somewhere else, so we removed it from
|
||||||
|
the list."
|
||||||
|
And "Delete…" opens ConfirmDialog: title "Delete this article?" text "\"{title}\" and its
|
||||||
|
{n} images will be deleted. This can't be undone." (a components.md convention: the row's
|
||||||
|
title plus its image count, "and its image" for exactly 1 and no image clause at all for 0),
|
||||||
|
"Cancel" and "Delete article" (danger); while the DELETE request is in flight the dialog
|
||||||
|
cannot be dismissed by Cancel, Esc or a backdrop click, both buttons are disabled, the
|
||||||
|
confirm button reads "Deleting…" with a loading indicator, and aria-busy is set
|
||||||
|
And a 500 on the delete request keeps the dialog open and instead shows an inline alert inside
|
||||||
|
the dialog: "Couldn't delete the article, so it's still there. Try again in a moment.",
|
||||||
|
re-enabling "Cancel" and "Delete article" so the user can retry or back out; the article is
|
||||||
|
left untouched
|
||||||
|
And a successful delete (204) closes the dialog, removes the row, moves focus to the next row
|
||||||
|
(or the page heading if the deleted row was last), recomputes the tab counts, and raises a
|
||||||
|
success toast (icon circle-check, id "article-deleted-{id}"): title "Article deleted" text
|
||||||
|
"\"{title}\" and its images are gone."; this is also where a delete initiated from the editor
|
||||||
|
ends up
|
||||||
|
And the list, sorted by last updated with the most recently updated article first, holds
|
||||||
|
these 6 articles by default: id 1 "How we price a bathroom renovation" (online, updated "2
|
||||||
|
hours ago"), id 2 "Choosing lime plaster for old stone walls" (draft, updated "Yesterday"),
|
||||||
|
id 3 "Before and after: the Rue Verte kitchen" (online, updated "3 days ago"), id 4 "Winter
|
||||||
|
checklist for timber windows" (draft, updated "5 days ago"), id 5 "Why we reuse original
|
||||||
|
floorboards" (online, updated "18 Aug 2026"), id 6 "Our first passive-house retrofit"
|
||||||
|
(online, updated "2 Jul 2026") — 6 total, 4 online, 2 drafts
|
||||||
|
|
||||||
|
Scenario: Default — the list loads sorted by last updated, newest first
|
||||||
|
Given GET /v1/article/ resolves 200 with the 6-article fixture above
|
||||||
|
When the user navigates to /article
|
||||||
|
Then the breadcrumb reads "Articles" (mobile title "Articles")
|
||||||
|
And the tabs read "All 6" "Online 4" "Drafts 2"
|
||||||
|
And the rows appear in exactly the fixture's order, each with its title link, its
|
||||||
|
ArticleStatusBadge and its updated date
|
||||||
|
And the table (desktop) carries a visually hidden caption "Articles, sorted by last updated,
|
||||||
|
newest first"
|
||||||
|
|
||||||
|
Scenario: Loading — skeleton rows while the first request, or a retry, is in flight
|
||||||
|
Given GET /v1/article/ has not yet resolved
|
||||||
|
When the user navigates to /article
|
||||||
|
Then 5 skeleton rows replace the table or card list, and the list container is aria-busy="true"
|
||||||
|
When GET /v1/article/ then resolves 200 with the 6-article fixture
|
||||||
|
Then the skeleton rows are replaced by the Default state's rows
|
||||||
|
|
||||||
|
Scenario: Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…
|
||||||
|
Given the Default state, with the row for id 4 "Winter checklist for timber windows" (draft,
|
||||||
|
updated "5 days ago")
|
||||||
|
When the user activates that row's ellipsis button
|
||||||
|
Then that row's menu opens, named "Actions for Winter checklist for timber windows", listing
|
||||||
|
Edit, Publish, Copy API URL, and, below a separator, "Delete…" in brick red
|
||||||
|
And every other row is unaffected
|
||||||
|
|
||||||
|
Scenario: Copy API URL succeeds — the absolute URL is copied and confirmed
|
||||||
|
Given the Default state, with the row for id 4 "Winter checklist for timber windows"
|
||||||
|
When the user chooses "Copy API URL" from id 4's row menu
|
||||||
|
Then "{origin}/v1/article/4" is written to the clipboard
|
||||||
|
And a success toast appears: icon circle-check, id "article-link-copied-4", title "Link
|
||||||
|
copied", text "The API URL for \"Winter checklist for timber windows\" is on your
|
||||||
|
clipboard."
|
||||||
|
|
||||||
|
Scenario: Copy API URL fails — the clipboard write itself is rejected
|
||||||
|
Given the Default state, with the row for id 4 "Winter checklist for timber windows"
|
||||||
|
When the user chooses "Copy API URL" from id 4's row menu, and the clipboard write fails
|
||||||
|
Then an error toast appears: icon circle-x, id "article-link-copy-failed-4", title "Couldn't
|
||||||
|
copy the link", with no text line
|
||||||
|
|
||||||
|
Scenario: Empty — zero articles
|
||||||
|
Given GET /v1/article/ resolves 200 with zero articles
|
||||||
|
When the user navigates to /article
|
||||||
|
Then the tabs read "All 0" "Online 0" "Drafts 0"
|
||||||
|
And an EmptyState (variant no-articles) replaces the list: icon file-text, title "No articles
|
||||||
|
yet", text "Write your first one. It stays a draft until you publish it.", and a primary
|
||||||
|
action "New article"
|
||||||
|
When the user activates "New article"
|
||||||
|
Then NewArticleModal opens
|
||||||
|
|
||||||
|
Scenario: No results — a search matches nothing, but the tab counts still show the full list
|
||||||
|
Given the Default state has loaded
|
||||||
|
When the user types "tiles" into the search field
|
||||||
|
Then, after the 120 ms debounce, an EmptyState (variant no-results) replaces the list: icon
|
||||||
|
search, title "No articles match \"tiles\"", text "Check the spelling, or search for a
|
||||||
|
shorter part of the title.", and an action "Clear search"
|
||||||
|
And the tabs still read "All 6" "Online 4" "Drafts 2"
|
||||||
|
When the user activates "Clear search"
|
||||||
|
Then the search field is cleared, the Default state's rows reappear, and focus returns to the
|
||||||
|
search field
|
||||||
|
|
||||||
|
Scenario Outline: An empty tab shows its own EmptyState, while the other tab still counts correctly
|
||||||
|
Given GET /v1/article/ resolves 200 with <fixture>
|
||||||
|
When the user navigates to /article and selects the "<tab>" tab
|
||||||
|
Then the tabs read "<counts>"
|
||||||
|
And an EmptyState replaces the list: title "<title>" text "<text>" action "<action>"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| fixture | tab | counts | title | text | action |
|
||||||
|
| 4 articles, all online, none drafts | Drafts | All 4, Online 4, Drafts 0 | No drafts | Everything you've written is online. | New article |
|
||||||
|
| 4 articles, all drafts, none online | Online | All 4, Online 0, Drafts 4 | Nothing is online yet | Publish a draft and it will appear on your website. | Show drafts |
|
||||||
|
|
||||||
|
Scenario: Action in progress — publishing a row disables only that row
|
||||||
|
Given the Default state has loaded
|
||||||
|
When the user chooses "Publish" from id 4 "Winter checklist for timber windows"'s row menu
|
||||||
|
Then PATCH /v1/article/4/publish is sent
|
||||||
|
And that row alone is disabled and shows the busy label "Publishing…", while the other 5 rows
|
||||||
|
stay interactive
|
||||||
|
|
||||||
|
Scenario: Published — a 200 updates the row in place and raises a toast
|
||||||
|
Given the user chose "Publish" from id 4 "Winter checklist for timber windows"'s row menu, and
|
||||||
|
the request is in flight
|
||||||
|
When PATCH /v1/article/4/publish resolves 200 with the article, is_online true, updated "Just
|
||||||
|
now"
|
||||||
|
Then id 4's row shows an online ArticleStatusBadge and updated date "Just now", no longer busy
|
||||||
|
And a success toast appears: icon circle-check, id "article-published-4", title "Article
|
||||||
|
published", text "\"Winter checklist for timber windows\" is now online."
|
||||||
|
|
||||||
|
Scenario Outline: Action failed, publish — a 500 or a network error leaves the row unchanged and offers Retry
|
||||||
|
Given the user chose "Publish" from id 4 "Winter checklist for timber windows"'s row menu, and
|
||||||
|
the request is in flight
|
||||||
|
When PATCH /v1/article/4/publish <failure>
|
||||||
|
Then id 4's row stays unchanged (draft, updated "5 days ago", no longer busy), because a row
|
||||||
|
never changes before the server responds
|
||||||
|
And an error toast appears, staying until dismissed or retried: icon circle-x, id
|
||||||
|
"article-publish-failed-4", title "Couldn't publish", text "The server didn't respond.
|
||||||
|
Nothing changed.", action "Retry"
|
||||||
|
When the user activates "Retry"
|
||||||
|
Then PATCH /v1/article/4/publish is sent again
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| failure |
|
||||||
|
| resolves with a 500 response |
|
||||||
|
| never reaches the server |
|
||||||
|
|
||||||
|
Scenario Outline: Action failed, unpublish — a 500 or a network error leaves the row unchanged and offers Retry
|
||||||
|
Given the user chose "Unpublish" from id 3 "Before and after: the Rue Verte kitchen"'s row
|
||||||
|
menu, and the request is in flight
|
||||||
|
When PATCH /v1/article/3/unpublish <failure>
|
||||||
|
Then id 3's row stays unchanged (online, updated "3 days ago", no longer busy), because a row
|
||||||
|
never changes before the server responds
|
||||||
|
And an error toast appears, staying until dismissed or retried: icon circle-x, id
|
||||||
|
"article-unpublish-failed-3", title "Couldn't unpublish", text "The server didn't respond.
|
||||||
|
Nothing changed.", action "Retry"
|
||||||
|
When the user activates "Retry"
|
||||||
|
Then PATCH /v1/article/3/unpublish is sent again
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| failure |
|
||||||
|
| resolves with a 500 response |
|
||||||
|
| never reaches the server |
|
||||||
|
|
||||||
|
Scenario Outline: Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast
|
||||||
|
Given the Default state has loaded
|
||||||
|
When the user <trigger> for id 2 "Choosing lime plaster for old stone walls"
|
||||||
|
And the request resolves 404
|
||||||
|
Then id 2's row is removed from the list (and any open menu or dialog for it closes)
|
||||||
|
And the tabs read "All 5" "Online 4" "Drafts 1"
|
||||||
|
And an info toast appears: icon info, id "article-not-found-2", title "That article no longer
|
||||||
|
exists", text "It was deleted somewhere else, so we removed it from the list."
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| trigger |
|
||||||
|
| chooses "Publish" from its row menu |
|
||||||
|
| confirms "Delete article" in its ConfirmDialog |
|
||||||
|
|
||||||
|
Scenario: Row vanished, online row — a 404 on unpublish removes the row and shows an info toast
|
||||||
|
Given the Default state has loaded
|
||||||
|
When the user chooses "Unpublish" from id 3 "Before and after: the Rue Verte kitchen"'s row
|
||||||
|
menu
|
||||||
|
And the request resolves 404
|
||||||
|
Then id 3's row is removed from the list (and any open menu closes)
|
||||||
|
And the tabs read "All 5" "Online 3" "Drafts 2"
|
||||||
|
And an info toast appears: icon info, id "article-not-found-3", title "That article no longer
|
||||||
|
exists", text "It was deleted somewhere else, so we removed it from the list."
|
||||||
|
|
||||||
|
Scenario: Delete confirm — opens the dialog with the row's title and image count
|
||||||
|
Given the Default state has loaded, and id 4 "Winter checklist for timber windows" has 3 images
|
||||||
|
When the user chooses "Delete…" from id 4's row menu
|
||||||
|
Then ConfirmDialog opens: title "Delete this article?" text "\"Winter checklist for timber
|
||||||
|
windows\" and its 3 images will be deleted. This can't be undone.", "Cancel" and "Delete
|
||||||
|
article" (danger)
|
||||||
|
|
||||||
|
Scenario: Delete in progress — the dialog cannot be dismissed while the request is in flight
|
||||||
|
Given ConfirmDialog is open for id 4 "Winter checklist for timber windows"
|
||||||
|
When the user activates "Delete article"
|
||||||
|
Then DELETE /v1/article/4 is sent
|
||||||
|
And both "Cancel" and the confirm button are disabled, the confirm button reads "Deleting…"
|
||||||
|
with a loading indicator, aria-busy is set, and Esc and a backdrop click do not close it
|
||||||
|
|
||||||
|
Scenario: Delete failed — a 500 shows an inline alert inside the dialog and keeps the article
|
||||||
|
Given ConfirmDialog is open for id 4 "Winter checklist for timber windows", and "Delete
|
||||||
|
article" has been activated
|
||||||
|
When DELETE /v1/article/4 resolves 500
|
||||||
|
Then the dialog stays open, showing the alert "Couldn't delete the article, so it's still
|
||||||
|
there. Try again in a moment.", with "Cancel" and "Delete article" re-enabled
|
||||||
|
And id 4's row is untouched in the list behind the dialog
|
||||||
|
|
||||||
|
Scenario: Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row
|
||||||
|
Given ConfirmDialog is open for id 4 "Winter checklist for timber windows", and "Delete
|
||||||
|
article" has been activated
|
||||||
|
When DELETE /v1/article/4 resolves 204
|
||||||
|
Then the dialog closes, id 4's row is removed from the list, and focus moves to the next row
|
||||||
|
And the tabs read "All 5" "Online 4" "Drafts 1"
|
||||||
|
And a success toast appears: icon circle-check, id "article-deleted-4", title "Article
|
||||||
|
deleted", text "\"Winter checklist for timber windows\" and its images are gone."
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# QA: Articles list screen
|
||||||
|
|
||||||
|
## Implementation note for coder (informational, not a QA assertion)
|
||||||
|
|
||||||
|
`internal/infrastructure/persistence/repositories/articleRepository.go`'s `FindByFilters` gains
|
||||||
|
`.Preload("Images")`, mirroring `Get`, so `GET /v1/article/` carries real `Images` arrays for the
|
||||||
|
delete dialog's count. This is a persistence-layer change asserted by the existing
|
||||||
|
`var _ domain.Repository = &ArticleRepository{}`; it needs no domain or use-case change, since
|
||||||
|
`article.Article.Images` already exists.
|
||||||
|
|
||||||
|
Toast ids follow components.md's action-plus-id convention: "article-published-{id}",
|
||||||
|
"article-unpublished-{id}", "article-publish-failed-{id}", "article-unpublish-failed-{id}",
|
||||||
|
"article-not-found-{id}", "article-deleted-{id}", "article-link-copied-{id}",
|
||||||
|
"article-link-copy-failed-{id}".
|
||||||
|
|
||||||
|
## HTTP-level (Go, `test/`, `task test`)
|
||||||
|
|
||||||
|
Build `article.NewHandler(...)` over the real use cases wired to a fake `article.Repository` (in
|
||||||
|
the style of `test/article_test.go`'s `mockArticleRepo`), mounted on a fresh `chi.NewRouter()`
|
||||||
|
mirroring `NewArticleRouter` in `api/router.go` (the outer group, no auth, for `GET /`). Call
|
||||||
|
`validator.InitValidator()` first.
|
||||||
|
|
||||||
|
1. **GET /v1/article/ returns each article's images array populated** — seed the fake repository
|
||||||
|
with an article whose `Images` slice holds two `image.Image` entries (ids 10 and 11), send
|
||||||
|
`GET /v1/article/` with `httptest`, decode the JSON array, and assert the matching entry's
|
||||||
|
`images` field is a 2-element array containing those ids (not `null` and not empty), proving
|
||||||
|
the handler's response reflects `FindByFilters`'s `Images` data. Because the fake repository's
|
||||||
|
`FindByFilters` is written by this test rather than GORM, it can't exercise the real
|
||||||
|
`.Preload("Images")` SQL by itself — this test only proves the handler passes the repository's
|
||||||
|
`Images` data through to JSON unchanged. The `.Preload` call itself against a real SQLite file is
|
||||||
|
covered under Manual verification below.
|
||||||
|
|
||||||
|
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||||
|
|
||||||
|
Mock `useApi`'s calls for `GET /v1/article/`, `PATCH /v1/article/{id}/publish`,
|
||||||
|
`PATCH /v1/article/{id}/unpublish`, `DELETE /v1/article/{id}`, and the clipboard write (e.g.
|
||||||
|
`navigator.clipboard.writeText`), per case; no real backend, no httptest server. Mount
|
||||||
|
ArticlesPage inside DashboardShell with a resolved verified session and a test router at
|
||||||
|
/article. Sample data throughout is the Background's 6-article fixture (ids 1-6, titles and dates
|
||||||
|
as listed), unless a scenario's Examples row substitutes a smaller fixture.
|
||||||
|
|
||||||
|
1. **Default** — mock `GET /v1/article/` resolving 200 with the 6-article fixture; assert
|
||||||
|
breadcrumb "Articles"; assert tabs "All 6" "Online 4" "Drafts 2"; assert the rows render in
|
||||||
|
fixture order with title link, ArticleStatusBadge and updated date; assert the table's visually
|
||||||
|
hidden caption reads "Articles, sorted by last updated, newest first".
|
||||||
|
2. **Loading** — mock `GET /v1/article/` with an unresolved promise; assert 5 skeleton rows and
|
||||||
|
`aria-busy="true"` on the list container; resolve 200 with the fixture; assert the Default
|
||||||
|
state's rows replace the skeletons.
|
||||||
|
3. **Row menu open** — from Default, click id 4's ellipsis button; assert the menu opens named
|
||||||
|
"Actions for Winter checklist for timber windows" with items Edit, Publish, Copy API URL, and a
|
||||||
|
separator then "Delete…"; assert no other row is affected.
|
||||||
|
4. **Copy API URL succeeds** — from Default, mock the clipboard write to resolve, choose "Copy API
|
||||||
|
URL" from id 4's menu; assert the clipboard write was called with
|
||||||
|
`"{origin}/v1/article/4"` (using the test's `window.location.origin`); assert a toast was
|
||||||
|
raised with id "article-link-copied-4", icon circle-check, title "Link copied", text "The API
|
||||||
|
URL for \"Winter checklist for timber windows\" is on your clipboard."
|
||||||
|
5. **Copy API URL fails** — from Default, mock the clipboard write to reject, choose "Copy API
|
||||||
|
URL" from id 4's menu; assert a toast was raised with id "article-link-copy-failed-4", icon
|
||||||
|
circle-x, title "Couldn't copy the link", and no description text.
|
||||||
|
6. **Empty** — mock `GET /v1/article/` resolving 200 with `[]`; assert tabs "All 0" "Online 0"
|
||||||
|
"Drafts 0"; assert the no-articles EmptyState (icon file-text, title "No articles yet", text,
|
||||||
|
action "New article"); click it; assert NewArticleModal opens.
|
||||||
|
7. **No results** — from Default, type "tiles" into the search field; advance past the 120 ms
|
||||||
|
debounce (fake timers); assert the no-results EmptyState (icon search, title "No articles match
|
||||||
|
\"tiles\"", text, action "Clear search"); assert tabs still read "All 6" "Online 4" "Drafts 2";
|
||||||
|
click "Clear search"; assert the Default rows reappear and the search field has focus.
|
||||||
|
8. **Empty tab, Examples-driven** — (a) mock `GET /v1/article/` with 4 online-only articles, select
|
||||||
|
the Drafts tab, assert tabs "All 4" "Online 4" "Drafts 0" and the no-drafts EmptyState (icon
|
||||||
|
pencil-line, title "No drafts", text "Everything you've written is online.", action "New
|
||||||
|
article"); (b) mock 4 draft-only articles, select the Online tab, assert tabs "All 4" "Online 0"
|
||||||
|
"Drafts 4" and the EmptyState titled "Nothing is online yet", text "Publish a draft and it will
|
||||||
|
appear on your website.", action "Show drafts" (icon per the board, confirmed in the visual
|
||||||
|
check below).
|
||||||
|
9. **Action in progress** — from Default, choose "Publish" from id 4's menu; assert
|
||||||
|
`PATCH /v1/article/4/publish` is called; assert id 4's row is disabled with busy label
|
||||||
|
"Publishing…" and the other 5 rows stay enabled.
|
||||||
|
10. **Published** — continuing from 9, resolve the PATCH 200 with `is_online: true`, updated "Just
|
||||||
|
now"; assert id 4's row shows the online badge and "Just now", no longer busy; assert a toast
|
||||||
|
was raised with id "article-published-4", icon circle-check, title "Article published", text
|
||||||
|
"\"Winter checklist for timber windows\" is now online."
|
||||||
|
11. **Action failed, publish, Examples-driven** — for each of a 500 response and a network
|
||||||
|
rejection: choose "Publish" from id 4's menu, resolve/reject the mocked PATCH accordingly;
|
||||||
|
assert id 4's row stays exactly unchanged (draft, "5 days ago", not busy) throughout — the row
|
||||||
|
is never touched before the (failed) response, so there is nothing to revert; assert a toast
|
||||||
|
with id "article-publish-failed-4", icon circle-x, title "Couldn't publish", text "The server
|
||||||
|
didn't respond. Nothing changed.", action "Retry"; click "Retry"; assert the same PATCH is
|
||||||
|
called again.
|
||||||
|
12. **Action failed, unpublish, Examples-driven** — for each of a 500 response and a network
|
||||||
|
rejection: choose "Unpublish" from id 3's menu (an online row), resolve/reject the mocked
|
||||||
|
PATCH accordingly; assert id 3's row stays exactly unchanged (online, "3 days ago", not busy);
|
||||||
|
assert a toast with id "article-unpublish-failed-3", icon circle-x, title "Couldn't
|
||||||
|
unpublish", text "The server didn't respond. Nothing changed.", action "Retry"; click "Retry";
|
||||||
|
assert the same PATCH is called again.
|
||||||
|
13. **Row vanished, draft row, Examples-driven** — for each of (publish 404), (delete-confirm
|
||||||
|
404) on id 2 (a draft row): trigger the action, resolve the mocked call 404; assert id 2's row
|
||||||
|
is removed; assert tabs read "All 5" "Online 4" "Drafts 1"; assert a toast with id
|
||||||
|
"article-not-found-2", icon info, title "That article no longer exists", text "It was deleted
|
||||||
|
somewhere else, so we removed it from the list."
|
||||||
|
14. **Row vanished, online row** — choose "Unpublish" from id 3's menu, resolve the mocked PATCH
|
||||||
|
404; assert id 3's row is removed; assert tabs read "All 5" "Online 3" "Drafts 2"; assert a
|
||||||
|
toast with id "article-not-found-3", icon info, title "That article no longer exists", text
|
||||||
|
"It was deleted somewhere else, so we removed it from the list."
|
||||||
|
15. **Delete confirm** — from Default (with id 4 carrying 3 images in the mocked
|
||||||
|
`GET /v1/article/` response), choose "Delete…" from id 4's menu; assert ConfirmDialog opens
|
||||||
|
with title "Delete this article?", text "\"Winter checklist for timber windows\" and its 3
|
||||||
|
images will be deleted. This can't be undone.", "Cancel" and "Delete article".
|
||||||
|
16. **Delete in progress** — from the open dialog, click "Delete article"; assert
|
||||||
|
`DELETE /v1/article/4` is called; assert both buttons are disabled, the confirm button reads
|
||||||
|
"Deleting…", `aria-busy` is set, and Esc / backdrop click do not close it.
|
||||||
|
17. **Delete failed** — continuing from 16, resolve the DELETE 500; assert the dialog stays open
|
||||||
|
with the inline alert "Couldn't delete the article, so it's still there. Try again in a
|
||||||
|
moment.", both buttons re-enabled; assert id 4's row is unchanged in the list behind the
|
||||||
|
dialog.
|
||||||
|
18. **Deleted** — continuing from 16 (a fresh attempt), resolve the DELETE 204; assert the dialog
|
||||||
|
closes, id 4's row is removed, focus moves to the next row; assert tabs read "All 5" "Online
|
||||||
|
4" "Drafts 1"; assert a toast with id "article-deleted-4", icon circle-check, title "Article
|
||||||
|
deleted", text "\"Winter checklist for timber windows\" and its images are gone."
|
||||||
|
|
||||||
|
## Visual (manual verification — desktop and mobile, light and dark)
|
||||||
|
|
||||||
|
Reference: `docs/design/screens/articles.md` and `docs/design/source/RenewCMS Screens
|
||||||
|
Back-office.dc.html`, group "Articles". Run `task visual:check -- articles` for each state below
|
||||||
|
and compare against the board frame; add each state's mock setup to `web/visual/states.ts` first,
|
||||||
|
reusing the 6-article fixture (ids/titles/dates above) as sample data. This screen shows relative
|
||||||
|
dates ("2 hours ago", "5 days ago"), so fix `now` to the value the board's own script assumes for
|
||||||
|
those relative strings to render identically (derive it from "2 hours ago" against "12 Mar 2026"
|
||||||
|
and the absolute dates shown, e.g. "18 Aug 2026", per the board's `base` fixture in the source
|
||||||
|
file).
|
||||||
|
|
||||||
|
- [ ] Default: tabs "All 6/Online 4/Drafts 2", 6 rows in fixture order, title/status/updated
|
||||||
|
columns (confirm against the board frame directly whether a Created column is also shown —
|
||||||
|
components.md's prose and the rendered board table disagree on this).
|
||||||
|
- [ ] Row menu open: id 4's menu open with Edit/Publish/Copy API URL/Delete…, matching the board's
|
||||||
|
dropdown placement and the brick-red Delete… treatment.
|
||||||
|
- [ ] Loading: 5 skeleton rows shaped like the real layout.
|
||||||
|
- [ ] Empty: EmptyState icon file-text, title, text, single "New article" action.
|
||||||
|
- [ ] No results: EmptyState icon search, title with the quoted search term, text, "Clear search".
|
||||||
|
- [ ] No drafts / Nothing online: both empty-tab EmptyStates, including the "Nothing is online
|
||||||
|
yet" state's icon (confirm it directly against the board frame, since it isn't given in the
|
||||||
|
prose extraction read for this slice).
|
||||||
|
- [ ] Action in progress: id 4's row at 50% opacity with a spinner in the status cell and
|
||||||
|
"aria-busy".
|
||||||
|
- [ ] Published / Action failed toasts: colors, icons and the "Retry" action match the board.
|
||||||
|
- [ ] Copy API URL: no board state depicts its toasts; verify their copy and icons directly in the
|
||||||
|
app against this spec rather than a board frame.
|
||||||
|
- [ ] Row vanished: the info toast and the row's removal match the board.
|
||||||
|
- [ ] Delete confirm / in progress / failed: the dialog's three states match the board exactly,
|
||||||
|
including the inline alert's placement in the failed state.
|
||||||
|
- [ ] Deleted: toast copy matches, and focus visibly lands on the next row.
|
||||||
|
- [ ] Keyboard/focus: roving tabindex across the tab list (Tab enters on the selected tab, ←/→
|
||||||
|
move and activate); each row's title link is reachable and Enter opens the editor; each
|
||||||
|
row's menu opens with Enter/Space/↓, arrow keys and type-ahead navigate it, Home/End jump to
|
||||||
|
the ends, Esc closes it and returns focus to the ellipsis button; ConfirmDialog traps focus,
|
||||||
|
starts on Cancel, and Enter does not confirm by default; on a successful delete, focus lands
|
||||||
|
on the next row (or the page heading if none).
|
||||||
|
- [ ] Motion: skeleton pulse and modal open/close follow the standard timings and stop under
|
||||||
|
reduced motion.
|
||||||
|
|
||||||
|
## Manual verification
|
||||||
|
|
||||||
|
- **Real GET/PATCH/DELETE round trips against a running server** — Vitest only mocks `useApi`;
|
||||||
|
confirm the real 200/404/500 responses, and the real `updated_at`-based ordering, against
|
||||||
|
`task dev` with a live SQLite file, since these automated tests never exercise the actual Go
|
||||||
|
handlers or use cases.
|
||||||
|
- **Real .Preload("Images") behavior against a live SQLite file** — confirm `GET /v1/article/`
|
||||||
|
returns accurate, non-empty `images` arrays for articles with real uploaded images, and `[]` for
|
||||||
|
articles with none, against a running server, since the HTTP-level Go test above only proves the
|
||||||
|
handler forwards whatever the (fake) repository returns, not that the real GORM query populates
|
||||||
|
it correctly.
|
||||||
|
- **Real image-count accuracy in the delete dialog** — confirm the delete dialog's count matches
|
||||||
|
an article's actual attached images end to end (upload images, then delete), since Vitest only
|
||||||
|
ever supplies a mocked count.
|
||||||
|
- **Toast placement and stacking on a live app, including the three-toast cap** — confirm against
|
||||||
|
a running app, since Vitest only asserts `useToast().add` was called with the right arguments.
|
||||||
|
- **Real clipboard write on a live app** — confirm `navigator.clipboard.writeText` actually places
|
||||||
|
the URL on the system clipboard in a real browser, since Vitest only mocks the clipboard API.
|
||||||
@@ -18,7 +18,7 @@ func NewArticleRepository(db *gorm.DB) *ArticleRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Article, error) {
|
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 {
|
if filters.IsOnline != nil {
|
||||||
query = query.Where("is_online = ?", *filters.IsOnline)
|
query = query.Where("is_online = ?", *filters.IsOnline)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -3,6 +3,7 @@ withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
online: boolean
|
online: boolean
|
||||||
size?: 'sm' | 'md' | 'lg'
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
pending?: 'publish' | 'unpublish'
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
size: 'md',
|
size: 'md',
|
||||||
@@ -12,12 +13,17 @@ withDefaults(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UBadge
|
<UBadge
|
||||||
v-if="online"
|
v-if="pending"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
variant="solid"
|
variant="outline"
|
||||||
icon="i-lucide-globe"
|
icon="i-lucide-loader-circle"
|
||||||
:size="size"
|
: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
|
Online
|
||||||
</UBadge>
|
</UBadge>
|
||||||
<UBadge
|
<UBadge
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -61,15 +61,8 @@ if (typeof window !== 'undefined') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useApi() {
|
export function useApi() {
|
||||||
async function request<T>(
|
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
path: string,
|
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
|
||||||
options: RequestOptions = {},
|
|
||||||
): Promise<T> {
|
|
||||||
const {
|
|
||||||
onUnauthorized = 'redirect',
|
|
||||||
onForbidden = 'redirect',
|
|
||||||
...init
|
|
||||||
} = options
|
|
||||||
|
|
||||||
let response: Response
|
let response: Response
|
||||||
try {
|
try {
|
||||||
@@ -99,10 +92,7 @@ export function useApi() {
|
|||||||
|
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
if (
|
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
|
||||||
onForbidden !== 'passthrough' &&
|
|
||||||
session.session?.verified === false
|
|
||||||
) {
|
|
||||||
router.push('/register/pending')
|
router.push('/register/pending')
|
||||||
}
|
}
|
||||||
throw new ApiError(403)
|
throw new ApiError(403)
|
||||||
@@ -115,16 +105,17 @@ export function useApi() {
|
|||||||
throw new ApiError(response.status)
|
throw new ApiError(response.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMe(): Promise<MeResult> {
|
async function getMe(): Promise<MeResult> {
|
||||||
const body = await request<{ is_verified: boolean; username?: string }>(
|
const body = await request<{ is_verified: boolean; username?: string }>('/v1/auth/me', {
|
||||||
'/v1/auth/me',
|
onUnauthorized: 'passthrough',
|
||||||
{
|
})
|
||||||
onUnauthorized: 'passthrough',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return { verified: Boolean(body.is_verified), username: body.username }
|
return { verified: Boolean(body.is_verified), username: body.username }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ function formatAbsoluteDate(date: Date) {
|
|||||||
return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`
|
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()) {
|
export function formatRelativeDate(iso: string, now: Date = new Date()) {
|
||||||
const date = new Date(iso)
|
const date = new Date(iso)
|
||||||
const diffMs = now.getTime() - date.getTime()
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
|||||||
@@ -1,9 +1,414 @@
|
|||||||
<script setup lang="ts">
|
<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 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardShell>
|
<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>
|
</DashboardShell>
|
||||||
</template>
|
</template>
|
||||||
@@ -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
@@ -311,7 +311,21 @@ async function captureApp(
|
|||||||
await page.clock.install({ time })
|
await page.clock.install({ time })
|
||||||
await page.clock.pauseAt(time)
|
await page.clock.pauseAt(time)
|
||||||
} else if (setup.now) {
|
} 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 page.goto(`${origin}${setup.path}`)
|
||||||
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)])
|
await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)])
|
||||||
|
|||||||
@@ -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>> = {
|
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: {
|
home: {
|
||||||
Default: {
|
Default: {
|
||||||
path: '/home',
|
path: '/home',
|
||||||
|
|||||||
Reference in new issue
Block a user