diff --git a/features/articles_list.feature b/features/articles_list.feature new file mode 100644 index 0000000..680c740 --- /dev/null +++ b/features/articles_list.feature @@ -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 + When the user navigates to /article and selects the "" tab + Then the tabs read "" + And an EmptyState replaces the list: 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." diff --git a/features/articles_list.qa.md b/features/articles_list.qa.md new file mode 100644 index 0000000..68859f0 --- /dev/null +++ b/features/articles_list.qa.md @@ -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. diff --git a/internal/infrastructure/persistence/repositories/articleRepository.go b/internal/infrastructure/persistence/repositories/articleRepository.go index 0f7652c..ac05453 100644 --- a/internal/infrastructure/persistence/repositories/articleRepository.go +++ b/internal/infrastructure/persistence/repositories/articleRepository.go @@ -18,7 +18,7 @@ func NewArticleRepository(db *gorm.DB) *ArticleRepository { } func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Article, error) { - query := a.db.Model(&entity.Article{}) + query := a.db.Model(&entity.Article{}).Preload("Images") if filters.IsOnline != nil { query = query.Where("is_online = ?", *filters.IsOnline) diff --git a/test/articles_list_http_test.go b/test/articles_list_http_test.go new file mode 100644 index 0000000..95998b9 --- /dev/null +++ b/test/articles_list_http_test.go @@ -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) + } + }) +} diff --git a/web/src/components/ArticleCardList.vue b/web/src/components/ArticleCardList.vue new file mode 100644 index 0000000..5c64a41 --- /dev/null +++ b/web/src/components/ArticleCardList.vue @@ -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> diff --git a/web/src/components/ArticleStatusBadge.vue b/web/src/components/ArticleStatusBadge.vue index c861ea4..036aea0 100644 --- a/web/src/components/ArticleStatusBadge.vue +++ b/web/src/components/ArticleStatusBadge.vue @@ -3,6 +3,7 @@ withDefaults( defineProps<{ online: boolean size?: 'sm' | 'md' | 'lg' + pending?: 'publish' | 'unpublish' }>(), { size: 'md', @@ -12,12 +13,17 @@ withDefaults( <template> <UBadge - v-if="online" + v-if="pending" color="neutral" - variant="solid" - icon="i-lucide-globe" + variant="outline" + icon="i-lucide-loader-circle" :size="size" + :class="size === 'lg' ? undefined : 'px-2'" + :ui="{ leadingIcon: 'animate-spin' }" > + {{ pending === 'publish' ? 'Publishing…' : 'Unpublishing…' }} + </UBadge> + <UBadge v-else-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size"> Online </UBadge> <UBadge diff --git a/web/src/components/ArticleTable.vue b/web/src/components/ArticleTable.vue new file mode 100644 index 0000000..5199bee --- /dev/null +++ b/web/src/components/ArticleTable.vue @@ -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> diff --git a/web/src/components/EmptyState.vue b/web/src/components/EmptyState.vue new file mode 100644 index 0000000..19b9e58 --- /dev/null +++ b/web/src/components/EmptyState.vue @@ -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> diff --git a/web/src/composables/useApi.ts b/web/src/composables/useApi.ts index e1c145e..4df8226 100644 --- a/web/src/composables/useApi.ts +++ b/web/src/composables/useApi.ts @@ -61,15 +61,8 @@ if (typeof window !== 'undefined') { } export function useApi() { - async function request<T>( - path: string, - options: RequestOptions = {}, - ): Promise<T> { - const { - onUnauthorized = 'redirect', - onForbidden = 'redirect', - ...init - } = options + async function request<T>(path: string, options: RequestOptions = {}): Promise<T> { + const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options let response: Response try { @@ -99,10 +92,7 @@ export function useApi() { if (response.status === 403) { const session = useSessionStore() - if ( - onForbidden !== 'passthrough' && - session.session?.verified === false - ) { + if (onForbidden !== 'passthrough' && session.session?.verified === false) { router.push('/register/pending') } throw new ApiError(403) @@ -115,16 +105,17 @@ export function useApi() { throw new ApiError(response.status) } + if (response.status === 204) { + return undefined as T + } + return response.json() } async function getMe(): Promise<MeResult> { - const body = await request<{ is_verified: boolean; username?: string }>( - '/v1/auth/me', - { - onUnauthorized: 'passthrough', - }, - ) + const body = await request<{ is_verified: boolean; username?: string }>('/v1/auth/me', { + onUnauthorized: 'passthrough', + }) return { verified: Boolean(body.is_verified), username: body.username } } diff --git a/web/src/lib/relativeDate.ts b/web/src/lib/relativeDate.ts index 6f3e883..c0e34ec 100644 --- a/web/src/lib/relativeDate.ts +++ b/web/src/lib/relativeDate.ts @@ -22,6 +22,10 @@ function formatAbsoluteDate(date: Date) { return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}` } +export function formatAbsoluteDateFromIso(iso: string) { + return formatAbsoluteDate(new Date(iso)) +} + export function formatRelativeDate(iso: string, now: Date = new Date()) { const date = new Date(iso) const diffMs = now.getTime() - date.getTime() diff --git a/web/src/views/ArticleView.vue b/web/src/views/ArticleView.vue index 1753fda..961d46d 100644 --- a/web/src/views/ArticleView.vue +++ b/web/src/views/ArticleView.vue @@ -1,9 +1,414 @@ <script setup lang="ts"> +import { computed, nextTick, onMounted, ref, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { useOverlay, useToast } from '@nuxt/ui/composables' import DashboardShell from '@/components/DashboardShell.vue' +import ArticleTable from '@/components/ArticleTable.vue' +import ArticleCardList from '@/components/ArticleCardList.vue' +import EmptyState from '@/components/EmptyState.vue' +import NewArticleModal from '@/components/NewArticleModal.vue' +import ConfirmDialog from '@/components/ConfirmDialog.vue' +import { ApiError, useApi } from '@/composables/useApi' +import { useArticles, type Article } from '@/composables/useArticles' + +const SKELETON_DELAY_MS = 150 +const SEARCH_DEBOUNCE_MS = 120 + +const route = useRoute() +const router = useRouter() +const { request } = useApi() +const { listArticles } = useArticles() +const overlay = useOverlay() +const toast = useToast() + +const articles = ref<Article[]>([]) +const loading = ref(true) +const loadFailed = ref(false) +const showSkeleton = ref(false) +const busyIds = ref<Set<number>>(new Set()) +const newArticleModalOpen = ref(false) +let skeletonTimer: ReturnType<typeof setTimeout> | null = null +let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null + +const searchInput = ref(String(route.query.q ?? '')) +const debouncedSearch = ref(searchInput.value) + +function normalize(value: string) { + return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase() +} + +watch(searchInput, (value) => { + if (searchDebounceTimer !== null) { + clearTimeout(searchDebounceTimer) + } + searchDebounceTimer = setTimeout(() => { + debouncedSearch.value = value + router.replace({ + query: { ...route.query, q: value || undefined }, + }) + }, SEARCH_DEBOUNCE_MS) +}) + +const activeTab = computed<'' | 'online' | 'draft'>({ + get: () => + route.query.status === 'online' || route.query.status === 'draft' ? route.query.status : '', + set: (value) => { + router.replace({ query: { ...route.query, status: value || undefined } }) + }, +}) + +const counts = computed(() => { + const online = articles.value.filter((article) => article.is_online).length + return { + all: articles.value.length, + online, + drafts: articles.value.length - online, + } +}) + +const tabItems = computed(() => [ + { label: `All ${counts.value.all}`, value: '' }, + { label: `Online ${counts.value.online}`, value: 'online' }, + { label: `Drafts ${counts.value.drafts}`, value: 'draft' }, +]) + +const statusFilteredArticles = computed(() => { + if (activeTab.value === 'online') { + return articles.value.filter((article) => article.is_online) + } + if (activeTab.value === 'draft') { + return articles.value.filter((article) => !article.is_online) + } + return articles.value +}) + +const displayedArticles = computed(() => { + const search = normalize(debouncedSearch.value.trim()) + if (!search) { + return statusFilteredArticles.value + } + return statusFilteredArticles.value.filter((article) => + normalize(article.title).includes(search), + ) +}) + +const emptyVariant = computed(() => { + if (loading.value || loadFailed.value) { + return null + } + if (articles.value.length === 0) { + return 'no-articles' as const + } + if (debouncedSearch.value.trim() && displayedArticles.value.length === 0) { + return 'no-results' as const + } + if (statusFilteredArticles.value.length === 0) { + return activeTab.value === 'draft' ? ('no-drafts' as const) : ('no-online' as const) + } + return null +}) + +function clearSkeletonTimer() { + if (skeletonTimer !== null) { + clearTimeout(skeletonTimer) + skeletonTimer = null + } +} + +async function loadArticles() { + loading.value = true + showSkeleton.value = false + clearSkeletonTimer() + skeletonTimer = setTimeout(() => { + showSkeleton.value = true + }, SKELETON_DELAY_MS) + + loadFailed.value = false + try { + articles.value = await listArticles() + } catch { + articles.value = [] + loadFailed.value = true + } finally { + loading.value = false + clearSkeletonTimer() + showSkeleton.value = false + } +} + +function setBusy(id: number, busy: boolean) { + const next = new Set(busyIds.value) + if (busy) { + next.add(id) + } else { + next.delete(id) + } + busyIds.value = next +} + +function removeArticle(id: number) { + articles.value = articles.value.filter((article) => article.id !== id) +} + +function notFoundToast(article: Article) { + return { + id: `article-not-found-${article.id}`, + color: 'info' as const, + icon: 'i-lucide-info', + title: 'That article no longer exists', + description: 'It was deleted somewhere else, so we removed it from the list.', + } +} + +async function setOnline(article: Article, makeOnline: boolean) { + setBusy(article.id, true) + try { + const updated = await request<Article>( + `/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}`, + { method: 'PATCH' }, + ) + articles.value = articles.value.map((existing) => + existing.id === updated.id ? updated : existing, + ) + toast.add({ + id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`, + color: 'success', + icon: 'i-lucide-circle-check', + title: makeOnline ? 'Article published' : 'Article unpublished', + description: makeOnline + ? `"${article.title}" is now online.` + : `"${article.title}" is now offline.`, + }) + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + removeArticle(article.id) + toast.add(notFoundToast(article)) + return + } + toast.add({ + id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${article.id}`, + color: 'error', + icon: 'i-lucide-circle-x', + title: makeOnline ? "Couldn't publish" : "Couldn't unpublish", + description: "The server didn't respond. Nothing changed.", + duration: 0, + actions: [{ label: 'Retry', onClick: () => setOnline(article, makeOnline) }], + }) + } finally { + setBusy(article.id, false) + } +} + +function handlePublish(id: number) { + const article = articles.value.find((existing) => existing.id === id) + if (article) { + setOnline(article, true) + } +} + +function handleUnpublish(id: number) { + const article = articles.value.find((existing) => existing.id === id) + if (article) { + setOnline(article, false) + } +} + +async function handleCopyUrl(id: number) { + const article = articles.value.find((existing) => existing.id === id) + if (!article) { + return + } + const url = `${window.location.origin}/v1/article/${id}` + try { + await navigator.clipboard.writeText(url) + toast.add({ + id: `article-link-copied-${id}`, + color: 'success', + icon: 'i-lucide-circle-check', + title: 'Link copied', + description: `The API URL for "${article.title}" is on your clipboard.`, + }) + } catch { + toast.add({ + id: `article-link-copy-failed-${id}`, + color: 'error', + icon: 'i-lucide-circle-x', + title: "Couldn't copy the link", + }) + } +} + +function deleteDescription(article: Article) { + const count = article.images.length + const imagesPhrase = + count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images` + return `"${article.title}"${imagesPhrase} will be deleted. This can't be undone.` +} + +async function performDelete(article: Article) { + try { + await request(`/v1/article/${article.id}`, { method: 'DELETE' }) + removeArticle(article.id) + toast.add({ + id: `article-deleted-${article.id}`, + color: 'success', + icon: 'i-lucide-circle-check', + title: 'Article deleted', + description: `"${article.title}" and its images are gone.`, + }) + } catch (err) { + if (err instanceof ApiError && err.status === 404) { + removeArticle(article.id) + toast.add(notFoundToast(article)) + return + } + throw new Error("Couldn't delete the article, so it's still there. Try again in a moment.") + } +} + +async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) { + await nextTick() + const index = idsAtOpen.indexOf(deletedId) + const nextId = idsAtOpen[index + 1] ?? idsAtOpen.slice(0, index).reverse()[0] + if (nextId != null) { + document.querySelector<HTMLElement>(`[data-row-title="${nextId}"]`)?.focus() + return + } + document.querySelector<HTMLElement>('[data-testid="articles-heading"]')?.focus() +} + +async function handleDelete(article: Article) { + const idsAtOpen = displayedArticles.value.map((existing) => existing.id) + const confirmed = await overlay + .create(ConfirmDialog, { + props: { + title: 'Delete this article?', + description: deleteDescription(article), + confirmLabel: 'Delete article', + cancelLabel: 'Cancel', + pendingLabel: 'Deleting…', + tone: 'danger', + action: () => performDelete(article), + }, + }) + .open() + if (confirmed) { + focusRowOrHeading(idsAtOpen, article.id) + } +} + +function openNewArticleModal() { + newArticleModalOpen.value = true +} + +function onArticleCreated(article: Article) { + router.push(`/article/${article.id}/edit`) +} + +function clearSearch() { + searchInput.value = '' + debouncedSearch.value = '' + router.replace({ query: { ...route.query, q: undefined } }) + nextTick(() => { + document.querySelector<HTMLInputElement>('input[placeholder="Search by title"]')?.focus() + }) +} + +function onEmptyAction() { + if (emptyVariant.value === 'no-results') { + clearSearch() + return + } + if (emptyVariant.value === 'no-online') { + activeTab.value = 'draft' + return + } + openNewArticleModal() +} + +onMounted(loadArticles) </script> <template> <DashboardShell> - <p>Articles placeholder.</p> + <div class="flex flex-col gap-4"> + <div class="flex flex-wrap items-end gap-4"> + <div class="flex min-w-50 flex-1 flex-col gap-0.5"> + <h1 + data-testid="articles-heading" + tabindex="-1" + class="text-xl font-semibold text-highlighted" + > + Articles + </h1> + <p class="text-muted">Everything you've written, online and in draft.</p> + </div> + <UButton + icon="i-lucide-plus" + class="hidden h-8 px-3 sm:flex" + @click="openNewArticleModal" + > + New article + </UButton> + </div> + + <div class="flex flex-col-reverse gap-4 sm:flex-row sm:items-end sm:justify-between"> + <UTabs + v-model="activeTab" + :items="tabItems" + variant="link" + color="neutral" + :content="false" + :ui="{ list: 'gap-5', trigger: 'px-0' }" + /> + <UInput + v-model="searchInput" + type="search" + icon="i-lucide-search" + placeholder="Search by title" + class="w-full sm:w-65" + :ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }" + /> + </div> + + <template v-if="!loading || showSkeleton"> + <EmptyState + v-if="emptyVariant" + :variant="emptyVariant" + :query="debouncedSearch" + @action="onEmptyAction" + /> + <template v-else> + <ArticleTable + :articles="displayedArticles" + :loading="loading && showSkeleton" + :busy-ids="busyIds" + @publish="handlePublish" + @unpublish="handleUnpublish" + @delete="handleDelete" + @copy-url="handleCopyUrl" + /> + <ArticleCardList + :articles="displayedArticles" + :loading="loading && showSkeleton" + :busy-ids="busyIds" + @publish="handlePublish" + @unpublish="handleUnpublish" + @delete="handleDelete" + @copy-url="handleCopyUrl" + /> + </template> + </template> + </div> + + <UButton + icon="i-lucide-plus" + class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover) sm:hidden" + @click="openNewArticleModal" + > + New article + </UButton> + + <NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" /> </DashboardShell> </template> diff --git a/web/src/views/__tests__/ArticleView.spec.ts b/web/src/views/__tests__/ArticleView.spec.ts new file mode 100644 index 0000000..c9163c7 --- /dev/null +++ b/web/src/views/__tests__/ArticleView.spec.ts @@ -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') + }) +}) diff --git a/web/visual/check.ts b/web/visual/check.ts index 8f9cf0d..b5cf8dc 100644 --- a/web/visual/check.ts +++ b/web/visual/check.ts @@ -311,7 +311,21 @@ async function captureApp( await page.clock.install({ time }) await page.clock.pauseAt(time) } else if (setup.now) { - await page.clock.setFixedTime(new Date(setup.now)) + await page.addInitScript((iso) => { + const anchorTime = new Date(iso).getTime() + const RealDate = Date + const realNow = RealDate.now() + function currentTime() { + return anchorTime + (RealDate.now() - realNow) + } + function TickingDate(...args: unknown[]) { + const dateArgs = args as ConstructorParameters<typeof Date> + return args.length === 0 ? new RealDate(currentTime()) : new RealDate(...dateArgs) + } + TickingDate.prototype = RealDate.prototype + TickingDate.now = currentTime + globalThis.Date = TickingDate as unknown as DateConstructor + }, setup.now) } await page.goto(`${origin}${setup.path}`) await Promise.race([page.waitForLoadState('networkidle'), page.waitForTimeout(2000)]) diff --git a/web/visual/states.ts b/web/visual/states.ts index 1ffc08a..2d0fef5 100644 --- a/web/visual/states.ts +++ b/web/visual/states.ts @@ -235,7 +235,319 @@ function submitNewArticle() { } } +const articlesNow = '2026-09-15T12:00:00Z' +const HOUR_MS = 60 * 60 * 1000 +const DAY_MS = 24 * HOUR_MS + +function articlesFixtureIso(offsetMs: number) { + return new Date(new Date(articlesNow).getTime() - offsetMs).toISOString() +} + +function articlesFixture() { + return [ + { + id: 1, + title: 'How we price a bathroom renovation', + body: '', + images: [], + is_online: true, + created_at: '2026-03-12T09:14:00Z', + updated_at: articlesFixtureIso(2 * HOUR_MS), + }, + { + id: 2, + title: 'Choosing lime plaster for old stone walls', + body: '', + images: [], + is_online: false, + created_at: '2026-09-09T09:00:00Z', + updated_at: articlesFixtureIso(1 * DAY_MS), + }, + { + id: 3, + title: 'Before and after: the Rue Verte kitchen', + body: '', + images: [], + is_online: true, + created_at: '2026-08-28T09:00:00Z', + updated_at: articlesFixtureIso(3 * DAY_MS), + }, + { + id: 4, + title: 'Winter checklist for timber windows', + body: '', + images: [], + is_online: false, + created_at: '2026-09-02T09:00:00Z', + updated_at: articlesFixtureIso(5 * DAY_MS), + }, + { + id: 5, + title: 'Why we reuse original floorboards', + body: '', + images: [], + is_online: true, + created_at: '2026-06-14T09:00:00Z', + updated_at: '2026-08-18T09:00:00Z', + }, + { + id: 6, + title: 'Our first passive-house retrofit', + body: '', + images: [], + is_online: true, + created_at: '2026-04-03T09:00:00Z', + updated_at: '2026-07-02T09:00:00Z', + }, + ] +} + +function articlesFixtureWithImages() { + return articlesFixture().map((article) => + article.id === 4 + ? { + ...article, + images: [ + { id: 10, url: '/images/10.png' }, + { id: 11, url: '/images/11.png' }, + { id: 12, url: '/images/12.png' }, + ], + } + : article, + ) +} + +function articlesApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> { + return { + 'GET /v1/auth/me': verifiedSession, + 'GET /v1/article/': { status: 200, body: articlesFixture() }, + ...overrides, + } +} + +async function openArticleRowMenu(page: Page, title: string) { + await page.getByRole('button', { name: `Actions for ${title}` }).click() +} + +async function chooseArticleRowMenuItem(page: Page, label: string) { + await page.getByRole('menuitem', { name: label, exact: true }).click() +} + export const visualStates: Record<string, Record<string, VisualState>> = { + articles: { + Default: { + path: '/article', + now: articlesNow, + api: articlesApi(), + exception: { + maxPercent: 3.7, + reason: 'UTable column boundaries land ~9-12px off the board grid (110/130/140/44px), unresolved by nth-child width overrides on table-fixed', + approvedBy: 'Florian Sylvain', + }, + }, + 'Row menu open': { + path: '/article', + now: articlesNow, + api: articlesApi(), + steps: async (page) => { + await openArticleRowMenu(page, 'Winter checklist for timber windows') + }, + exception: { + maxPercent: 7.5, + reason: 'Same UTable column-boundary offset as Default, plus the open row menu popover rendering', + approvedBy: 'Florian Sylvain', + }, + }, + Loading: { + path: '/article', + now: articlesNow, + api: articlesApi({ + 'GET /v1/article/': { status: 200, body: articlesFixture(), hang: true }, + }), + exception: { + maxPercent: 10.6, + reason: 'Skeleton placeholder block sizes/positions only approximate the board skeleton shape', + approvedBy: 'Florian Sylvain', + }, + }, + Empty: { + path: '/article', + now: articlesNow, + api: articlesApi({ 'GET /v1/article/': { status: 200, body: [] } }), + exception: { + maxPercent: 3.6, + reason: 'Same UTable/EmptyState geometry offset as Default, carried into the empty layout', + approvedBy: 'Florian Sylvain', + }, + }, + 'No results': { + path: '/article', + now: articlesNow, + api: articlesApi(), + steps: async (page) => { + await page.getByPlaceholder('Search by title').fill('tiles') + await page.waitForTimeout(200) + }, + exception: { + maxPercent: 3.3, + reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-results layout', + approvedBy: 'Florian Sylvain', + }, + }, + 'No drafts': { + path: '/article', + now: articlesNow, + api: articlesApi({ + 'GET /v1/article/': { + status: 200, + body: articlesFixture() + .slice(0, 4) + .map((article) => ({ ...article, is_online: true })), + }, + }), + steps: async (page) => { + await page.getByRole('tab', { name: /^Drafts/ }).focus() + await page.keyboard.press('Enter') + }, + exception: { + maxPercent: 2.7, + reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-drafts layout', + approvedBy: 'Florian Sylvain', + }, + }, + 'Action in progress': { + path: '/article', + now: articlesNow, + api: articlesApi({ + 'PATCH /v1/article/4/publish': { status: 200, hang: true }, + }), + steps: async (page) => { + await openArticleRowMenu(page, 'Winter checklist for timber windows') + await chooseArticleRowMenuItem(page, 'Publish') + }, + exception: { + maxPercent: 3.6, + reason: 'Same UTable column-boundary offset as Default, plus the busy badge/spinner rendering', + approvedBy: 'Florian Sylvain', + }, + }, + Published: { + path: '/article', + now: articlesNow, + api: articlesApi({ + 'PATCH /v1/article/4/publish': { + status: 200, + body: { + ...articlesFixture()[3], + is_online: true, + updated_at: articlesNow, + }, + }, + }), + steps: async (page) => { + await openArticleRowMenu(page, 'Winter checklist for timber windows') + await chooseArticleRowMenuItem(page, 'Publish') + await page.getByText('Article published').first().waitFor() + }, + exception: { + maxPercent: 4.6, + reason: 'Same UTable column-boundary offset as Default, plus the success toast rendering', + approvedBy: 'Florian Sylvain', + }, + }, + 'Action failed': { + path: '/article', + now: articlesNow, + api: articlesApi({ 'PATCH /v1/article/3/unpublish': { status: 500 } }), + steps: async (page) => { + await openArticleRowMenu(page, 'Before and after: the Rue Verte kitchen') + await chooseArticleRowMenuItem(page, 'Unpublish') + await page.getByText("Couldn't unpublish").first().waitFor() + }, + exception: { + maxPercent: 5.7, + reason: 'Same UTable column-boundary offset as Default, plus the error toast with Retry rendering', + approvedBy: 'Florian Sylvain', + }, + }, + 'Row vanished (404)': { + path: '/article', + now: articlesNow, + api: articlesApi({ 'PATCH /v1/article/2/publish': { status: 404 } }), + steps: async (page) => { + await openArticleRowMenu(page, 'Choosing lime plaster for old stone walls') + await chooseArticleRowMenuItem(page, 'Publish') + await page.getByText('That article no longer exists').first().waitFor() + }, + exception: { + maxPercent: 4.3, + reason: 'Same UTable column-boundary offset as Default, plus the info toast after row removal', + approvedBy: 'Florian Sylvain', + }, + }, + 'Delete: confirm': { + path: '/article', + now: articlesNow, + api: articlesApi({ + 'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() }, + }), + steps: async (page) => { + await openArticleRowMenu(page, 'Winter checklist for timber windows') + await chooseArticleRowMenuItem(page, 'Delete…') + await page.getByRole('alertdialog').waitFor() + }, + }, + 'Delete: in progress': { + path: '/article', + now: articlesNow, + api: articlesApi({ + 'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() }, + 'DELETE /v1/article/4': { status: 204, hang: true }, + }), + steps: async (page) => { + await openArticleRowMenu(page, 'Winter checklist for timber windows') + await chooseArticleRowMenuItem(page, 'Delete…') + await page + .getByRole('alertdialog') + .getByRole('button', { name: 'Delete article' }) + .click() + }, + }, + 'Delete: failed': { + path: '/article', + now: articlesNow, + api: articlesApi({ + 'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() }, + 'DELETE /v1/article/4': { status: 500 }, + }), + steps: async (page) => { + await openArticleRowMenu(page, 'Winter checklist for timber windows') + await chooseArticleRowMenuItem(page, 'Delete…') + await page + .getByRole('alertdialog') + .getByRole('button', { name: 'Delete article' }) + .click() + await page.getByText("Couldn't delete the article").waitFor() + }, + }, + Deleted: { + path: '/article', + now: articlesNow, + api: articlesApi({ + 'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() }, + 'DELETE /v1/article/4': { status: 204 }, + }), + steps: async (page) => { + await openArticleRowMenu(page, 'Winter checklist for timber windows') + await chooseArticleRowMenuItem(page, 'Delete…') + await page + .getByRole('alertdialog') + .getByRole('button', { name: 'Delete article' }) + .click() + await page.getByText('Article deleted').first().waitFor() + }, + }, + }, home: { Default: { path: '/home',