feat: article editor core

This commit is contained in:
fsylvain committed 2026-09-27 02:11:38 +02:00
1 parent d493a181e1
commit a4734b2c91
55 files changed
+4186 -496

No files matched your search

+242
View File
@@ -0,0 +1,242 @@
Feature: Editor screen — load, edit and save an article's body
As a verified RenewCMS owner
I want /article/:id/edit to load an article, let me edit its body, save it, and warn me
before I lose unsaved work
So that I can safely write and revise an article's content, with clear feedback while
saving, near the length limit, offline, or when my session expires mid-edit
Background:
Given /article/:id/edit is a verified route (meta.auth "verified") rendered as EditorPage
inside DashboardShell, per docs/design/routes.md
And EditorPage calls GET /v1/article/{id} once per navigation to load the article, per
docs/design/screens/editor.md and features/public_article_read.feature
And the header shows breadcrumb "Articles › {article.title}" linking "Articles" to
/article (mobile top bar title "Edit article" instead), an ArticleStatusBadge size "md"
reflecting article.is_online ("Draft" or "Online"), a SaveStatus span, a Save button,
and a PublishControl button, per docs/design/components.md
And the article's title is never editable after creation, per
features/article_title_rules.feature; only the body is edited here
And the body is edited in ArticleEditor (modelValue bound to the fetched HTML body); any
change moves useArticleDraft's state from "clean" to "dirty", and PublishControl's label
switches from "Publish" (or "Unpublish", if online) to "Save and publish" while dirty and
the article is a draft; clicking PublishControl itself is out of scope for this slice and
is specified in a follow-on slice (editor_publish_and_delete)
And Save is enabled only when useArticleDraft's state is "dirty", the request is not already
in flight, the app is not offline, and the body's HTML length is at most 10000 characters;
it is disabled in every other case
And Save (a click, or Ctrl/⌘ S) sends PATCH /v1/article/{id} with JSON {"body": "..."},
the same contract features/edit_article_body.feature already gives the API: 200 with the
updated article.Article, 400 for a body over 10000 characters, 404 for an id absent from
the repository, 401 with no valid session
And a successful save (200) sets useArticleDraft's state to "saved" and records the save
time; SaveStatus shows "Saved at {HH:MM}" for the first hour and "Saved {n} min ago" after
an hour has passed, per docs/design/components.md
And a 500 response or a request that never reaches the server sets useArticleDraft's state
to "error"; SaveStatus shows "Save failed" with an inline Retry button (UButton size="xs"
variant="outline"); the edited body is kept exactly as typed, both in the editor and for
the next Retry
And a 404 response to a save (the article was deleted elsewhere) does not use the "error"
SaveStatus; it shows a toast "That article no longer exists.", per docs/design/states.md's
HTTP-status-to-copy table
And a 401 response to a save is handled by useApi's global handling
(features/api_client_global_error_handling.feature): before the redirect, useArticleDraft
mirrors the current dirty body to sessionStorage keyed by the article's id, so it survives
the redirect to /login?redirect=/article/{id}/edit&reason=expired
And CharacterCounter shows "{len} / 10 000" below the editor, using the body's HTML string
length (formatting and images count); at 9000 characters or more (90%, "Near the limit")
it adds a warning icon and stays enabled; above 10000 ("Too long") it adds an icon and the
text "{over} characters over. Shorten the text or remove an image. Formatting counts too.",
and disables both Save and PublishControl until the length is 10000 or fewer again
And a Preview switch, when on, replaces the editable ArticleEditor and its toolbar with a
read-only rendering of the title and body in the content type scale; switching it off
restores the editable surface with the same content, unchanged
And when navigator.onLine is false, or the browser fires a window "offline" event,
useArticleDraft's state becomes "offline"; SaveStatus shows the wifi-off icon and reads
"Waiting for connection"; Save and PublishControl are disabled with a tooltip explaining
why, and DashboardShell's existing OfflineBanner is shown; when the connection returns (a
window "online" event, or navigator.onLine true again), Save and PublishControl re-enable
according to the underlying dirty/saved/error state, which is unaffected by having been
offline
And navigating away (the breadcrumb "Articles" link, a sidebar nav link, the browser back
button, or closing the tab) while useArticleDraft's state is "dirty" opens ConfirmDialog
(neutral tone) titled "Leave without saving?", text 'Your changes to "{article.title}"
haven't been saved.', cancel "Discard changes", confirm "Keep editing"; "Discard changes"
proceeds with the navigation, discarding the edit; "Keep editing" closes the dialog and
keeps the user on the editor with the edit intact; navigating away while "clean" or
"saved" never opens this dialog
And an id absent from the repository (GET resolves 404) replaces the entire editor with an
error state: title "This article doesn't exist.", and a single action "All articles"
navigating to /article; no breadcrumb-title, SaveStatus, Save, PublishControl or
ArticleEditor renders in this state
And skeleton placeholders (for the title, content and details) replace the editor's content
only after 150 ms of GET /v1/article/{id} being unresolved, so a fast response never
flashes a skeleton, per the Skeleton component spec
Scenario: Skeletons appear only after 150 ms, never for a fast response
Given the user navigates to /article/12/edit for the first time in this session
When GET /v1/article/12 has not resolved 150 ms after the navigation
Then skeleton placeholders replace the title, content and details areas
When GET /v1/article/12 then resolves 200 with a draft article titled "How we price a
bathroom renovation"
Then the skeletons are replaced by the Saved state's header, editor and details
Given a second, separate navigation to /article/12/edit
When GET /v1/article/12 resolves in under 150 ms
Then no skeleton is ever shown, and the Saved state's content renders directly
Scenario Outline: Saved — a freshly loaded article, draft or online
Given GET /v1/article/12 resolves 200 with an article titled "How we price a bathroom
renovation", is_online <is_online>, body length 4318, updated_at "Today, 11:02"
When the user navigates to /article/12/edit
Then the breadcrumb reads "Articles › How we price a bathroom renovation"
And the ArticleStatusBadge reads "<badge>"
And SaveStatus reads "Saved at 11:02"
And the Save button is disabled
And PublishControl reads "<publish_label>"
And the character counter reads "4 318 / 10 000"
Examples:
| is_online | badge | publish_label |
| false | Draft | Publish |
| true | Online | Unpublish |
Scenario: Unsaved changes — any edit marks the article dirty
Given the user is on /article/12/edit in the Saved state, article "How we price a bathroom
renovation", draft
When the user types into the body
Then SaveStatus reads "Unsaved changes"
And the Save button becomes enabled
And PublishControl now reads "Save and publish"
Scenario: Saving — Save sends the PATCH and disables itself while in flight
Given the user is on /article/12/edit with unsaved changes to the body
When the user activates Save
Then PATCH /v1/article/12 is sent with JSON {"body": "<the edited body>"}
And SaveStatus reads "Saving…"
And the Save button is disabled
Scenario: Ctrl/⌘ S saves the same way as clicking Save
Given the user is on /article/12/edit with unsaved changes to the body
When the user presses Ctrl+S (or ⌘+S)
Then PATCH /v1/article/12 is sent with JSON {"body": "<the edited body>"}
And SaveStatus reads "Saving…"
Scenario: A successful save returns to the Saved state with the new time
Given the user is on /article/12/edit in the Saving state
When PATCH /v1/article/12 resolves 200 with the updated article
Then SaveStatus reads "Saved at {current time}"
And the Save button is disabled again
Scenario: SaveStatus switches to a relative time after an hour
Given the user is on /article/12/edit and PATCH /v1/article/12 resolved 200 at 11:02
When more than an hour has passed with no further edit or save
Then SaveStatus reads "Saved {n} min ago" instead of "Saved at 11:02"
Scenario Outline: Save failed — a 500 or a network failure keeps the changes and offers Retry
Given the user is on /article/12/edit with unsaved changes to the body "Revised opening
paragraph."
When the user activates Save
And PATCH /v1/article/12 <failure>
Then SaveStatus reads "Save failed" with a Retry button
And the editor still shows the body "Revised opening paragraph."
And no navigation occurs
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: Retry resends the same body and can recover
Given the user is on /article/12/edit in the Save failed state, with unsaved body "Revised
opening paragraph." and PATCH /v1/article/12 having failed with a 500
When the user activates Retry
Then PATCH /v1/article/12 is sent again with JSON {"body": "Revised opening paragraph."}
When it resolves 200 with the updated article
Then SaveStatus reads "Saved at {current time}"
Scenario: A save against an article deleted elsewhere shows a toast, not the error SaveStatus
Given the user is on /article/12/edit with unsaved changes to the body
When the user activates Save
And PATCH /v1/article/12 resolves 404
Then a toast reads "That article no longer exists."
And SaveStatus does not read "Save failed"
Scenario: Near the limit — the counter warns at 9000 characters without blocking Save
Given the user is on /article/12/edit with unsaved changes making the body's HTML length
9240 characters
Then the character counter reads "9 240 / 10 000" with a warning icon
And the Save button remains enabled
Scenario: Too long — over 10000 characters disables Save and Publish until shortened
Given the user is on /article/12/edit with unsaved changes making the body's HTML length
10214 characters
Then the character counter reads "10 214 / 10 000" with the text "214 characters over.
Shorten the text or remove an image. Formatting counts too."
And the Save button is disabled
And PublishControl is disabled
When the user shortens the body to 10000 characters or fewer
Then the character counter no longer shows the over-limit text
And the Save button is enabled again
Scenario: Preview shows the rendered title and body without the editing surface
Given the user is on /article/12/edit, article titled "How we price a bathroom renovation"
with some body content
When the user switches Preview on
Then the editing toolbar and the editable surface are replaced by a read-only rendering of
the title and body in the content type scale
When the user switches Preview off
Then the editable ArticleEditor and toolbar return, with the same body content unchanged
Scenario: Offline blocks Save and Publish, and recovers when the connection returns
Given the user is on /article/12/edit with unsaved changes to the body
When navigator.onLine becomes false
Then SaveStatus shows the wifi-off icon and reads "Waiting for connection"
And the Save button and PublishControl are disabled with a tooltip explaining why
When navigator.onLine becomes true again
Then the Save button re-enables, reflecting the still-unsaved change
And SaveStatus reads "Unsaved changes" again
Scenario: Leaving with unsaved changes prompts, and "Discard changes" proceeds
Given the user is on /article/12/edit with unsaved changes to the body
When the user activates the "Articles" breadcrumb link
Then ConfirmDialog opens, titled "Leave without saving?", text 'Your changes to "How we
price a bathroom renovation" haven't been saved.', cancel "Discard changes", confirm
"Keep editing"
When the user activates "Discard changes"
Then the app navigates to /article
And the edited body is not saved
Scenario: Leaving with unsaved changes, "Keep editing" stays on the page
Given the user is on /article/12/edit with unsaved changes to the body, and the "Leave
without saving?" dialog is open
When the user activates "Keep editing"
Then the dialog closes
And the user remains on /article/12/edit with the unsaved body intact
Scenario: Leaving a clean article never prompts
Given the user is on /article/12/edit in the Saved state, with no unsaved changes
When the user activates the "Articles" breadcrumb link
Then the app navigates to /article directly, with no ConfirmDialog
Scenario: Session expired mid-save keeps the draft and restores it after logging back in
Given the user is on /article/12/edit with unsaved changes to the body "Paragraph added
just before the session expired."
When the user activates Save
And PATCH /v1/article/12 resolves 401
Then the body "Paragraph added just before the session expired." is mirrored to
sessionStorage keyed by article id 12
And the app navigates to /login?redirect=/article/12/edit&reason=expired
When the user logs in again and is returned to /article/12/edit
And GET /v1/article/12 resolves 200 with the last-saved body (without that paragraph)
Then the editor shows the body "Paragraph added just before the session expired." restored
from sessionStorage, not the last-saved body from the GET
And SaveStatus reads "Unsaved changes"
Scenario: An unknown or deleted article id replaces the editor with an error state
Given GET /v1/article/9999 resolves 404
When the user navigates to /article/9999/edit
Then the editor shows a single error state: title "This article doesn't exist." and one
action "All articles"
And no breadcrumb-title, SaveStatus, Save, PublishControl or ArticleEditor renders
When the user activates "All articles"
Then the app navigates to /article
+129
View File
@@ -0,0 +1,129 @@
# QA: Editor screen — load, edit and save (core)
## Implementation note for coder (informational, not a QA assertion)
`docs/design/components.md`'s `ErrorView` only documents two fixed copies (`kind: 'not-found'` →
"Page not found" / Home, and `kind: 'error'` → the generic retry copy). The editor's own 404-on-
load state needs different copy ("This article doesn't exist." / "All articles" → /article),
per `docs/design/states.md`'s HTTP-status-to-copy table. Either widen `ErrorView` with an optional
title/description/action override, or add a third `kind`; this is a same-layer prop widening, not
a new component, so it doesn't need its own slice.
Known limitation, not a bug to silently fix: per `features/public_article_read.feature`,
`GET /v1/article/{id}` falls back to public online-only visibility on a malformed/expired jwt
(no 401). So if a session is already stale when the owner opens an *offline* article, the GET
answers 404 exactly like a truly deleted article — the editor cannot tell the two apart from this
response alone, and correctly shows the "This article doesn't exist." state.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` for `GET /v1/article/{id}` and `PATCH /v1/article/{id}` per case; no
real backend, no httptest server. Mount EditorPage inside DashboardShell with a resolved verified
session (username "marta") and a test router at `/article/12/edit`. Use fake timers for the
150 ms skeleton delay and the relative-time SaveStatus format. Use `Object.defineProperty`/mocked
`navigator.onLine` and dispatched `window` `online`/`offline` events for the offline scenarios.
Fixture article (reused across cases unless a scenario overrides a field): id 12, title
"How we price a bathroom renovation", is_online false, body length 4318, created_at
"2026-03-12T09:14:00Z", updated_at "2026-09-26T11:02:00Z" (fixed `now`
"2026-09-26T11:02:00Z" unless a scenario advances it).
1. **Skeleton only after 150 ms** — mock `GET /v1/article/12` unresolved; assert no skeleton
before 150 ms; advance past it; assert skeleton placeholders for title/content/details;
resolve 200; assert the Saved state renders. Separately, resolve under 150 ms; assert no
skeleton ever renders.
2. **Saved state, draft and online** — Examples-driven on `is_online`: mock `GET
/v1/article/12` resolving 200 with the fixture at each `is_online` value; assert the
breadcrumb, ArticleStatusBadge ("Draft"/"Online"), SaveStatus "Saved at 11:02", Save
disabled, PublishControl ("Publish"/"Unpublish"), and character counter "4 318 / 10 000".
3. **Unsaved changes** — from the Saved state, type into the body; assert SaveStatus "Unsaved
changes", Save enabled, PublishControl now "Save and publish".
4. **Saving via click and via Ctrl/⌘ S** — from Unsaved changes, (a) click Save, (b) press
Ctrl+S; in each, assert `PATCH /v1/article/12` is called with the edited body, SaveStatus
"Saving…", Save disabled while unresolved.
5. **Successful save** — from Saving, resolve `PATCH` 200 with the updated article; assert
SaveStatus "Saved at {time}" and Save disabled again.
6. **Relative save time after an hour** — after a 200 save at 11:02, advance the fake clock past
one hour with no further edit; assert SaveStatus switches to "Saved {n} min ago".
7. **Save failed, Examples-driven** — (a) `PATCH` resolves 500, (b) `PATCH` rejects (network);
in each, assert SaveStatus "Save failed" with a visible Retry button, and the editor still
shows the typed body.
8. **Retry recovers** — from Save failed (500), click Retry; assert `PATCH` is called again with
the same body; resolve 200; assert SaveStatus "Saved at {time}".
9. **404 on save shows a toast, not the error SaveStatus** — from Unsaved changes, save; resolve
`PATCH` 404; assert a toast "That article no longer exists." appears and SaveStatus never
reads "Save failed".
10. **Near the limit** — set the body's HTML length to 9240 (via a fixture string of that
length); assert counter "9 240 / 10 000" with a warning icon and Save still enabled.
11. **Too long** — set the body's HTML length to 10214; assert counter "10 214 / 10 000" with
"214 characters over. Shorten the text or remove an image. Formatting counts too.", Save
disabled, PublishControl disabled; shorten to 10000; assert both re-enable and the over-limit
text disappears.
12. **Preview toggle** — from a loaded article with body content, switch Preview on; assert the
toolbar and editable surface are gone and a read-only rendering of title+body appears;
switch off; assert the editable surface returns with the same content.
13. **Offline blocks and recovers** — from Unsaved changes, set `navigator.onLine` false and
dispatch `window` `offline`; assert SaveStatus shows the wifi-off icon and reads "Waiting
for connection", Save and PublishControl disabled with a tooltip; set `navigator.onLine`
true and dispatch `online`; assert Save re-enables and SaveStatus reads "Unsaved changes"
again.
14. **Leave guard: Discard changes proceeds** — from Unsaved changes, click the "Articles"
breadcrumb link; assert ConfirmDialog opens with the exact title/text/button labels; click
"Discard changes"; assert the router navigates to `/article` and no further `PATCH` fires.
15. **Leave guard: Keep editing stays** — same setup; click "Keep editing"; assert the dialog
closes and the router does not navigate, and the unsaved body is still shown.
16. **No leave guard when clean** — from the Saved state, click the "Articles" breadcrumb link;
assert the router navigates to `/article` with no ConfirmDialog ever rendered.
17. **Session expiry mid-save mirrors and restores the draft** — from Unsaved changes with body
"Paragraph added just before the session expired.", save; resolve `PATCH` 401; assert
`sessionStorage` holds that body keyed by article id 12, and the router navigates to
`/login?redirect=/article/12/edit&reason=expired`. Then, simulating the return trip, mount
EditorPage again at `/article/12/edit` with `GET /v1/article/12` resolving 200 with a body
that lacks that paragraph; assert the rendered body is the restored sessionStorage draft
(with the paragraph) and SaveStatus reads "Unsaved changes".
18. **Not found on load** — mock `GET /v1/article/9999` resolving 404; mount at
`/article/9999/edit`; assert only the error state renders (title "This article doesn't
exist.", action "All articles"), with no breadcrumb-title/SaveStatus/Save/PublishControl/
ArticleEditor; click "All articles"; assert the router navigates to `/article`.
## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/screens/editor.md` and `docs/design/source/RenewCMS Screens
Back-office.dc.html`, group "Editor". Run `task visual:check -- editor` for each state below and
compare against the board frame; add each state's mock setup to `web/visual/states.ts` first,
reusing the sample data below and a fixed `now` of "2026-09-26T11:02:00Z" (updated_at "Today,
11:02"; created_at "12 Mar 2026, 09:14"; id 12; title "How we price a bathroom renovation";
3 images).
- [ ] Loading: skeletons for title, content and details, shaped like the real layout.
- [ ] Saved: breadcrumb, "Draft" badge, "Saved at 11:02", disabled Save, "Publish", counter
"4 318 / 10 000", details panel (Created/Updated/ID/Images/Public API URL).
- [ ] Unsaved changes: "Unsaved changes" SaveStatus, enabled Save, "Save and publish".
- [ ] Saving: "Saving…" SaveStatus, disabled Save.
- [ ] Save failed: "Save failed" with Retry, per the SaveStatus error spec.
- [ ] Near the limit: counter "9 240 / 10 000" with the warning icon/style.
- [ ] Too long: counter "10 214 / 10 000" with the over-limit text and disabled Save/Publish.
- [ ] Preview: read-only rendered title and body in the content scale, toolbar hidden.
- [ ] Offline: SaveStatus shows the wifi-off icon and reads "Waiting for connection", disabled
Save/Publish, OfflineBanner visible.
- [ ] Not found (404): the standalone error state, no editor chrome.
- [ ] Mobile (all of the above at 390×844): sticky bottom bar for Save/PublishControl, details
panel as a slideover, single "Edit article" mobile top-bar title instead of the breadcrumb.
- [ ] Keyboard/focus: Tab reaches the breadcrumb, Save, Preview switch and PublishControl in a
sensible order; Ctrl/⌘ S saves from anywhere in the editor surface; the leave-guard
ConfirmDialog traps focus starting on "Discard changes" per ConfirmDialog's a11y spec, and
Esc behaves like dismissing (equivalent to "Keep editing"); the character-counter live
region announces only when crossing the warn/over thresholds, not on every keystroke.
- [ ] Motion: modal open/close and skeleton pulse follow the standard 240 ms / 1.6 s timings and
stop under reduced motion.
## Manual verification
- **Real GET/PATCH round trip against a running server** — Vitest only mocks `useApi`; confirm
the real 200/404/401/500 responses and the real `sessionStorage` restoration across an actual
session expiry (`JWT_SECRET`-signed cookie timing out) against `task dev` with a live SQLite
file, since automated tests never exercise the real Go handler, `UpdateArticleUseCase`, or a
real cookie's `exp` claim.
- **Real `beforeunload` prompt** — Vitest can assert `onBeforeRouteLeave`'s ConfirmDialog, but a
real browser tab-close prompt (native `beforeunload`) needs manual confirmation, since jsdom
doesn't model it.
+1
View File
@@ -7,6 +7,7 @@
"dependencies": {
"@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3",
"@vueuse/core": "14.4.0",
"pinia": "^3.0.4",
"tailwindcss": "^4.1.18",
+49 -48
View File
@@ -1,50 +1,51 @@
{
"name": "renewcms-frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18",
"@vueuse/core": "14.4.0",
"pinia": "^3.0.4",
"tailwindcss": "^4.1.18",
"vue": "^3.5.25",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.136",
"@iconify/utils": "^3.1.7",
"@tsconfig/node24": "^24.0.3",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.5.1",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.1",
"eslint-plugin-vue": "~10.5.1",
"jiti": "^2.6.1",
"jsdom": "25",
"npm-run-all2": "^8.0.4",
"playwright": "^1.63.0",
"prettier": "3.6.2",
"typescript": "~5.9.0",
"vite": "^7.2.4",
"vite-plugin-vue-devtools": "^8.0.5",
"vitest": "^5.0.2",
"vue-tsc": "^3.1.5"
}
"name": "renewcms-frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3",
"@vueuse/core": "14.4.0",
"pinia": "^3.0.4",
"tailwindcss": "^4.1.18",
"vue": "^3.5.25",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@iconify-json/lucide": "^1.2.136",
"@iconify/utils": "^3.1.7",
"@tsconfig/node24": "^24.0.3",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.5.1",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.1",
"eslint-plugin-vue": "~10.5.1",
"jiti": "^2.6.1",
"jsdom": "25",
"npm-run-all2": "^8.0.4",
"playwright": "^1.63.0",
"prettier": "3.6.2",
"typescript": "~5.9.0",
"vite": "^7.2.4",
"vite-plugin-vue-devtools": "^8.0.5",
"vitest": "^5.0.2",
"vue-tsc": "^3.1.5"
}
}
+17 -1
View File
@@ -112,7 +112,8 @@
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--shadow-popover: 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);
--shadow-popover:
0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);
--shadow-modal: 0 16px 48px rgb(17 17 17 / 0.18);
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
@@ -260,9 +261,24 @@ body {
.article-content li {
margin: 0 0 0.375rem;
}
.article-content li > p {
margin: 0;
}
.article-content li::marker {
color: var(--ui-text-muted);
}
.article-content [data-indent='1'] {
padding-left: 1.5rem;
}
.article-content [data-indent='2'] {
padding-left: 3rem;
}
.article-content [data-indent='3'] {
padding-left: 4.5rem;
}
.article-content [data-indent='4'] {
padding-left: 6rem;
}
.article-content blockquote {
margin: 1.75rem 0;
padding-left: 1.25rem;
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Article } from '@/composables/useArticles'
const props = defineProps<{ article: Article }>()
defineEmits<{ 'copy-url': [] }>()
const open = defineModel<boolean>('open', { default: false })
const TITLE_NOTE =
"Titles can't be changed after an article is created. To use a different title, create a new article and delete this one."
const MONTH_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
function isSameDay(a: Date, b: Date) {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
function formatDetailDate(iso: string, relative = false) {
const date = new Date(iso)
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
if (relative && isSameDay(date, new Date())) {
return `Today, ${hours}:${minutes}`
}
return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${hours}:${minutes}`
}
const createdLabel = computed(() => formatDetailDate(props.article.created_at))
const updatedLabel = computed(() =>
formatDetailDate(props.article.updated_at, true),
)
const publicUrl = computed(() => `/v1/article/${props.article.id}`)
const imagesCount = computed(() => props.article.images.length)
const apiNote = computed(() =>
props.article.is_online
? 'Live. Your website can read this article.'
: 'Returns 404 until the article is online.',
)
</script>
<template>
<div>
<UCard
data-testid="article-details"
class="hidden lg:flex"
:ui="{
root: 'flex-col gap-4 rounded-none bg-transparent shadow-none ring-0 divide-y-0',
header: 'p-0 sm:p-0',
body: 'flex flex-col gap-4 p-0 sm:p-0',
}"
>
<template #header>
<h2 class="text-base font-semibold text-highlighted">
Details
</h2>
</template>
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted">Created</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd>
<dt class="text-muted">Updated</dt>
<dd class="text-highlighted">{{ updatedLabel }}</dd>
<dt class="text-muted">ID</dt>
<dd class="font-mono text-highlighted">{{ article.id }}</dd>
<dt class="text-muted">Images</dt>
<dd class="text-highlighted">{{ imagesCount }}</dd>
</dl>
<div class="flex flex-col gap-1.5 text-sm">
<p class="text-muted">Public API URL</p>
<div
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
>
<span class="flex-1">{{ publicUrl }}</span>
<UIcon name="i-lucide-copy" class="size-3.5" />
</div>
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div>
<div
class="flex flex-col gap-1 border-t border-default pt-3 text-sm"
>
<p class="text-muted">Title</p>
<p class="text-xs/[18px] text-toned">{{ TITLE_NOTE }}</p>
</div>
</UCard>
<USlideover
v-model:open="open"
side="right"
title="Details"
class="lg:hidden"
>
<template #body>
<div class="flex flex-col gap-4 text-sm">
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-2">
<dt class="text-muted">Created</dt>
<dd class="text-highlighted">{{ createdLabel }}</dd>
<dt class="text-muted">Updated</dt>
<dd class="text-highlighted">{{ updatedLabel }}</dd>
<dt class="text-muted">ID</dt>
<dd class="font-mono text-highlighted">
{{ article.id }}
</dd>
<dt class="text-muted">Images</dt>
<dd class="text-highlighted">{{ imagesCount }}</dd>
</dl>
<div class="flex flex-col gap-1.5">
<p class="text-muted">Public API URL</p>
<div
class="flex items-center gap-1.5 bg-elevated px-2 py-1.5 font-mono text-xs"
>
<span class="flex-1">{{ publicUrl }}</span>
<UIcon name="i-lucide-copy" class="size-3.5" />
</div>
<p class="text-xs/[18px] text-muted">{{ apiNote }}</p>
</div>
<div
class="flex flex-col gap-1 border-t border-default pt-3"
>
<p class="text-muted">Title</p>
<p class="text-xs/[18px] text-toned">
{{ TITLE_NOTE }}
</p>
</div>
</div>
</template>
</USlideover>
</div>
</template>
+255
View File
@@ -0,0 +1,255 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { EditorToolbarItem } from '@nuxt/ui'
import type { Editor } from '@tiptap/core'
import { ArticleIndent } from '@/lib/articleIndent'
const props = withDefaults(
defineProps<{
modelValue: string
articleId: number
disabled?: boolean
}>(),
{
disabled: false,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
'request-image': []
}>()
const editorInstance = ref<Editor | null>(null)
const extensions = [ArticleIndent]
function captureEditor(editor: Editor) {
editorInstance.value = editor
return ''
}
function onUpdate(value: string) {
emit('update:modelValue', value)
}
function currentBlockLabel(editor: Editor) {
for (let level = 1; level <= 4; level += 1) {
if (editor.isActive('heading', { level })) {
return `Heading ${level}`
}
}
return 'Paragraph'
}
function isDefaultAlignment(editor: Editor) {
return !['center', 'right', 'justify'].some((align) =>
editor.isActive({ textAlign: align }),
)
}
function buildItems(editor: Editor): EditorToolbarItem[][] {
return [
[
{
kind: 'undo',
icon: 'i-lucide-undo-2',
tooltip: { text: 'Undo' },
},
{
kind: 'redo',
icon: 'i-lucide-redo-2',
tooltip: { text: 'Redo' },
},
],
[
{
icon: 'i-lucide-pilcrow',
label: currentBlockLabel(editor),
trailingIcon: 'i-lucide-chevron-down',
ui: { trailingIcon: 'size-3' },
tooltip: { text: 'Paragraph style' },
items: [
{
icon: 'i-lucide-pilcrow',
label: 'Paragraph',
onSelect: () =>
editor.chain().focus().setParagraph().run(),
},
{
kind: 'heading',
level: 1,
icon: 'i-lucide-heading-1',
label: 'Heading 1',
tooltip: { text: 'Heading 1' },
},
{
kind: 'heading',
level: 2,
icon: 'i-lucide-heading-2',
label: 'Heading 2',
tooltip: { text: 'Heading 2' },
},
{
kind: 'heading',
level: 3,
icon: 'i-lucide-heading-3',
label: 'Heading 3',
tooltip: { text: 'Heading 3' },
},
{
kind: 'heading',
level: 4,
icon: 'i-lucide-heading-4',
label: 'Heading 4',
tooltip: { text: 'Heading 4' },
},
],
},
],
[
{
kind: 'mark',
mark: 'bold',
icon: 'i-lucide-bold',
tooltip: { text: 'Bold' },
},
{
kind: 'mark',
mark: 'italic',
icon: 'i-lucide-italic',
tooltip: { text: 'Italic' },
},
{
kind: 'mark',
mark: 'underline',
icon: 'i-lucide-underline',
tooltip: { text: 'Underline' },
},
{
kind: 'mark',
mark: 'strike',
icon: 'i-lucide-strikethrough',
tooltip: { text: 'Strikethrough' },
},
],
[
{
kind: 'textAlign',
align: 'left',
active: isDefaultAlignment(editor),
icon: 'i-lucide-align-left',
tooltip: { text: 'Align left' },
},
{
kind: 'textAlign',
align: 'center',
icon: 'i-lucide-align-center',
tooltip: { text: 'Align center' },
},
{
kind: 'textAlign',
align: 'right',
icon: 'i-lucide-align-right',
tooltip: { text: 'Align right' },
},
{
kind: 'textAlign',
align: 'justify',
icon: 'i-lucide-align-justify',
tooltip: { text: 'Justify' },
},
],
[
{
kind: 'bulletList',
icon: 'i-lucide-list',
tooltip: { text: 'Bullet list' },
},
{
kind: 'orderedList',
icon: 'i-lucide-list-ordered',
tooltip: { text: 'Numbered list' },
},
{
icon: 'i-lucide-indent-decrease',
tooltip: { text: 'Outdent' },
onClick: () => editor.chain().focus().outdent().run(),
},
{
icon: 'i-lucide-indent-increase',
tooltip: { text: 'Indent' },
onClick: () => editor.chain().focus().indent().run(),
},
],
[
{
kind: 'clearFormatting',
icon: 'i-lucide-remove-formatting',
tooltip: { text: 'Clear formatting' },
},
{
icon: 'i-lucide-image-plus',
label: 'Image',
tooltip: { text: 'Add an image' },
onClick: () => emit('request-image'),
},
],
]
}
defineExpose({
focus() {
editorInstance.value?.commands.focus()
},
})
</script>
<template>
<UEditor
v-slot="{ editor }"
:model-value="props.modelValue"
:extensions="extensions"
content-type="html"
:editable="!props.disabled"
:ui="{ base: 'article-content' }"
class="article-editor grid h-full min-h-0 grid-cols-1 grid-rows-[auto_minmax(0,1fr)_auto] lg:grid-cols-[minmax(0,1fr)_17.5rem]"
@update:model-value="onUpdate"
>
<span class="hidden">{{ captureEditor(editor) }}</span>
<UEditorToolbar
:editor="editor"
:items="buildItems(editor)"
layout="fixed"
class="col-span-full row-start-1 border-b border-default bg-default px-6 py-1.5"
/>
<div class="col-start-1 row-start-3">
<slot name="footer" />
</div>
<div
class="col-start-2 row-span-2 row-start-2 hidden min-h-0 lg:block"
>
<slot name="aside" />
</div>
</UEditor>
</template>
<style scoped>
.article-editor :deep([data-slot='content']) {
grid-column-start: 1;
grid-row-start: 2;
min-height: 0;
overflow-y: auto;
padding: 2.5rem 3rem;
background: var(--ui-bg);
}
.article-editor :deep(.ProseMirror) {
max-width: 42rem;
padding: 0;
outline: none;
}
@media (max-width: 47.999rem) {
.article-editor :deep([data-slot='content']) {
padding: 1.5rem 1rem;
}
}
</style>
+9 -3
View File
@@ -2,7 +2,7 @@
withDefaults(
defineProps<{
online: boolean
size?: 'sm' | 'md'
size?: 'sm' | 'md' | 'lg'
}>(),
{
size: 'md',
@@ -11,7 +11,13 @@ withDefaults(
</script>
<template>
<UBadge v-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
<UBadge
v-if="online"
color="neutral"
variant="solid"
icon="i-lucide-globe"
:size="size"
>
Online
</UBadge>
<UBadge
@@ -20,7 +26,7 @@ withDefaults(
variant="outline"
icon="i-lucide-pencil-line"
:size="size"
class="px-2"
:class="size === 'lg' ? undefined : 'px-2'"
>
Draft
</UBadge>
+15 -5
View File
@@ -33,18 +33,26 @@ onUnmounted(() => {
})
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base'))
const inputClass = computed(() => (belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base'))
const controlClass = computed(() =>
belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base',
)
const inputClass = computed(() =>
belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base',
)
const mainClass = computed(() => [
'flex min-h-screen flex-col items-center gap-5',
belowSm.value ? 'bg-default pt-10' : 'bg-muted pt-[12vh]',
])
const cardClass = computed(() => (belowSm.value ? 'w-full' : 'w-full max-w-[400px]'))
const cardClass = computed(() =>
belowSm.value ? 'w-full' : 'w-full max-w-[400px]',
)
const cardUi = computed(() => ({
root: belowSm.value ? 'rounded-none ring-0' : 'rounded-none ring-0 border border-default',
root: belowSm.value
? 'rounded-none ring-0'
: 'rounded-none ring-0 border border-default',
container: belowSm.value
? 'flex flex-col gap-6 px-5 pt-2 pb-0 lg:flex'
: 'flex flex-col gap-6 p-10 sm:p-10 lg:flex',
@@ -102,7 +110,9 @@ watch(
</template>
<template v-if="title" #title>
<h1 ref="titleEl" tabindex="-1" class="outline-none">{{ title }}</h1>
<h1 ref="titleEl" tabindex="-1" class="outline-none">
{{ title }}
</h1>
</template>
<slot v-if="slots.notice" name="notice" />
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
value: number
max: number
min?: number
warnAt?: number
variant?: 'inline' | 'footer'
}>(),
{
min: 0,
warnAt: 0.9,
variant: 'inline',
},
)
function formatThousands(count: number) {
return count.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
}
const overBy = computed(() => props.value - props.max)
const status = computed<'normal' | 'warn' | 'over'>(() => {
if (overBy.value > 0) {
return 'over'
}
if (props.value >= props.max * props.warnAt) {
return 'warn'
}
return 'normal'
})
const overText = computed(() =>
overBy.value === 1
? '1 character over. Shorten the text or remove an image. Formatting counts too.'
: `${formatThousands(overBy.value)} characters over. Shorten the text or remove an image. Formatting counts too.`,
)
const warnText = 'Close to the limit. Images and formatting count too.'
const message = computed(() => {
if (status.value === 'over') {
return overText.value
}
if (status.value === 'warn') {
return warnText
}
return ''
})
const announcement = ref('')
watch(status, (next, previous) => {
if (next === previous) {
return
}
if (next === 'over') {
announcement.value = overText.value
} else if (next === 'warn') {
announcement.value = 'Almost at the length limit.'
} else {
announcement.value = ''
}
})
</script>
<template>
<div
v-if="variant === 'footer'"
class="flex items-center gap-3 border-t border-default bg-default px-6 py-2"
>
<span
class="flex-1 text-xs"
:class="{
'text-warning': status === 'warn',
'text-error': status === 'over',
'text-muted': status === 'normal',
}"
>{{ message }}</span
>
<span
class="flex items-center gap-1.5 font-mono text-xs"
:class="{
'text-warning': status === 'warn',
'text-error': status === 'over',
'text-muted': status === 'normal',
}"
>
<UIcon
v-if="status === 'warn'"
name="i-lucide-triangle-alert"
class="size-3.5"
data-testid="character-counter-warning-icon"
/>
<UIcon
v-else-if="status === 'over'"
name="i-lucide-circle-alert"
class="size-3.5"
data-testid="character-counter-over-icon"
/>
{{ formatThousands(value) }} / {{ formatThousands(max) }}
</span>
<span aria-live="polite" class="sr-only">{{ announcement }}</span>
</div>
<div v-else class="flex flex-col gap-1">
<div
class="flex items-center gap-1.5 font-mono text-sm"
:class="{
'text-warning': status === 'warn',
'text-error': status === 'over',
'text-muted': status === 'normal',
}"
>
<UIcon
v-if="status === 'warn'"
name="i-lucide-triangle-alert"
class="size-4"
data-testid="character-counter-warning-icon"
/>
<UIcon
v-else-if="status === 'over'"
name="i-lucide-circle-alert"
class="size-4"
data-testid="character-counter-over-icon"
/>
<span
>{{ formatThousands(value) }} / {{ formatThousands(max) }}</span
>
</div>
<p v-if="status === 'over'" class="text-sm text-error">
{{ overText }}
</p>
<span aria-live="polite" class="sr-only">{{ announcement }}</span>
</div>
</template>
+35 -16
View File
@@ -1,15 +1,21 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
const props = defineProps<{
title: string
description: string
confirmLabel: string
cancelLabel: string
pendingLabel: string
tone: 'danger' | 'neutral'
action: () => Promise<void>
}>()
const props = withDefaults(
defineProps<{
title: string
description: string
confirmLabel: string
cancelLabel: string
pendingLabel: string
tone: 'danger' | 'neutral'
action: () => Promise<void>
dismissResolvesTo?: 'cancel' | 'confirm'
}>(),
{
dismissResolvesTo: 'cancel',
},
)
const emit = defineEmits<{ close: [confirmed: boolean] }>()
@@ -19,8 +25,12 @@ const error = ref('')
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
let settled = false
const confirmColor = computed(() => (props.tone === 'danger' ? 'error' : 'primary'))
const confirmText = computed(() => (pending.value ? props.pendingLabel : props.confirmLabel))
const confirmColor = computed(() =>
props.tone === 'danger' ? 'error' : 'primary',
)
const confirmText = computed(() =>
pending.value ? props.pendingLabel : props.confirmLabel,
)
onMounted(async () => {
await nextTick()
@@ -38,7 +48,7 @@ function settle(confirmed: boolean) {
watch(open, (value) => {
if (!value) {
settle(false)
settle(props.dismissResolvesTo === 'confirm')
}
})
@@ -52,7 +62,10 @@ async function onConfirm() {
await props.action()
settle(true)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Something went wrong. Try again.'
error.value =
err instanceof Error
? err.message
: 'Something went wrong. Try again.'
} finally {
pending.value = false
}
@@ -73,13 +86,17 @@ function onCancel() {
:description="description"
:dismissible="!pending"
:close="false"
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
:content="{
role: 'alertdialog',
'aria-busy': pending ? 'true' : undefined,
}"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-[220px] sm:max-w-110 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'min-h-0 px-6 pt-6 pb-0 sm:px-6 max-sm:px-5 max-sm:pt-5',
title: 'text-lg max-sm:text-[1.0625rem]/6',
description: 'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
description:
'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
body: 'px-6 pt-4 pb-0 sm:px-6 sm:pt-4 sm:pb-0 max-sm:px-5',
footer: 'p-6 sm:px-6 max-sm:p-5',
}"
@@ -95,7 +112,9 @@ function onCancel() {
/>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<div
class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end"
>
<UButton
ref="cancelButton"
color="neutral"
+51 -18
View File
@@ -9,18 +9,41 @@ import ErrorView from '@/components/ErrorView.vue'
import OfflineBanner from '@/components/OfflineBanner.vue'
import UserMenu from '@/components/UserMenu.vue'
const props = withDefaults(
defineProps<{
mobileTitle?: string
fullBleed?: boolean
}>(),
{
mobileTitle: undefined,
fullBleed: false,
},
)
const bodyUi = computed(() =>
props.fullBleed
? 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0'
: 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8',
)
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const api = useApi()
const navItems = [
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
]
const navItems = computed(() =>
[
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
].map((item) => ({
...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
})),
)
const pageTitle = computed(() => (route.meta.title as string) ?? '')
const routeTitle = computed(() => (route.meta.title as string) ?? '')
const pageTitle = computed(() => props.mobileTitle ?? routeTitle.value)
const sidebarCollapsed = ref(false)
const loggingOut = ref(false)
@@ -42,7 +65,9 @@ async function onLogout() {
<template>
<UDashboardGroup unit="rem">
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
<a href="#dashboard-content" class="sr-only focus:not-sr-only"
>Skip to content</a
>
<UDashboardSidebar
v-model:collapsed="sidebarCollapsed"
@@ -88,9 +113,7 @@ async function onLogout() {
</template>
</UDashboardSidebar>
<UDashboardPanel
:ui="{ body: 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8' }"
>
<UDashboardPanel :ui="{ body: bodyUi }">
<template #header>
<UDashboardNavbar
:toggle="{ ui: { leadingIcon: 'size-5' } }"
@@ -115,12 +138,14 @@ async function onLogout() {
aria-hidden="true"
class="mx-1.5 hidden h-4 w-px bg-(--ui-border) lg:block"
/>
<UBreadcrumb
:items="[{ label: pageTitle }]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
<slot name="breadcrumb">
<UBreadcrumb
:items="[{ label: routeTitle }]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</slot>
</template>
<template #right>
<slot name="navbar-right" />
@@ -129,7 +154,12 @@ async function onLogout() {
</template>
<template #body>
<div id="dashboard-content">
<div
id="dashboard-content"
:class="
fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''
"
>
<OfflineBanner />
<AppBoot v-if="session.status === 'checking'" />
@@ -138,7 +168,10 @@ async function onLogout() {
kind="error"
@retry="onRetry"
/>
<div v-else>
<div
v-else
:class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''"
>
<slot />
</div>
</div>
+50 -24
View File
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
const props = withDefaults(
defineProps<{
kind: 'not-found' | 'error'
kind: 'not-found' | 'error' | 'article-not-found'
breadcrumb?: string
}>(),
{
@@ -18,27 +18,40 @@ const emit = defineEmits<{
const router = useRouter()
const copy = computed(() =>
props.kind === 'not-found'
? {
title: 'Page not found',
text: 'The link may be mistyped, or the page may have moved.',
primary: 'Go to Home',
secondary: 'All articles',
}
: {
title: 'Something went wrong',
text: "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
primary: 'Try again',
secondary: 'Go to Home',
},
)
const copy = computed(() => {
if (props.kind === 'not-found') {
return {
title: 'Page not found',
text: 'The link may be mistyped, or the page may have moved.',
primary: 'Go to Home',
secondary: 'All articles',
}
}
if (props.kind === 'article-not-found') {
return {
title: "This article doesn't exist.",
text: 'It may have been deleted, or the link is wrong.',
primary: 'All articles',
secondary: '',
}
}
return {
title: 'Something went wrong',
text: "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
primary: 'Try again',
secondary: 'Go to Home',
}
})
function onPrimary() {
if (props.kind === 'error') {
emit('retry')
return
}
if (props.kind === 'article-not-found') {
router.push('/article')
return
}
router.push('/home')
}
@@ -52,17 +65,30 @@ function onSecondary() {
</script>
<template>
<div role="alert" class="space-y-4 p-6">
<nav aria-label="Breadcrumb">
<div role="alert" class="flex max-w-140 flex-col gap-3.5 p-8">
<nav v-if="kind !== 'article-not-found'" aria-label="Breadcrumb">
<span>{{ breadcrumb }}</span>
</nav>
<h1 tabindex="-1">{{ copy.title }}</h1>
<p>{{ copy.text }}</p>
<span
v-if="kind === 'article-not-found'"
aria-hidden="true"
class="font-mono text-sm text-muted"
>404</span
>
<h1 tabindex="-1" class="text-2xl font-semibold text-highlighted">
{{ copy.title }}
</h1>
<p v-if="copy.text" class="text-sm text-toned">{{ copy.text }}</p>
<div class="flex gap-2">
<UButton @click="onPrimary">{{ copy.primary }}</UButton>
<UButton variant="outline" color="neutral" @click="onSecondary">{{
copy.secondary
}}</UButton>
<UButton class="h-8" @click="onPrimary">{{ copy.primary }}</UButton>
<UButton
v-if="copy.secondary"
variant="outline"
color="neutral"
class="h-8"
@click="onSecondary"
>{{ copy.secondary }}</UButton
>
</div>
</div>
</template>
+13 -3
View File
@@ -16,16 +16,26 @@ function onCancel() {
<template #body>
<UForm :state="{ title, body }" class="space-y-4">
<UFormField label="Title" name="title">
<UInput v-model="title" placeholder="Article title" class="w-full" />
<UInput
v-model="title"
placeholder="Article title"
class="w-full"
/>
</UFormField>
<UFormField label="Body" name="body">
<UTextarea v-model="body" placeholder="Start writing…" class="w-full" />
<UTextarea
v-model="body"
placeholder="Start writing…"
class="w-full"
/>
</UFormField>
</UForm>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton color="neutral" variant="outline" @click="onCancel">Cancel</UButton>
<UButton color="neutral" variant="outline" @click="onCancel"
>Cancel</UButton
>
<UButton disabled>Create article</UButton>
</div>
</template>
+9 -2
View File
@@ -44,8 +44,15 @@ const items = computed(() =>
<template>
<ul class="flex flex-col gap-1.5">
<li v-for="item in items" :key="item.label" class="flex items-center gap-2 text-xs/[18px]">
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
<li
v-for="item in items"
:key="item.label"
class="flex items-center gap-2 text-xs/[18px]"
>
<UIcon
:name="item.icon"
:class="['size-3.5 shrink-0', item.iconClass]"
/>
<span :class="item.textClass">{{ item.label }}</span>
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
</li>
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
online: boolean
dirty: boolean
pending?: string
disabled?: boolean
hideIcon?: boolean
}>(),
{
pending: undefined,
disabled: false,
hideIcon: false,
},
)
defineEmits<{ publish: []; unpublish: [] }>()
const label = computed(() => {
if (props.pending) {
return props.pending
}
if (props.online) {
return 'Unpublish'
}
if (props.dirty) {
return 'Save and publish'
}
return 'Publish'
})
const icon = computed(() => {
if (props.hideIcon) {
return undefined
}
if (props.online) {
return 'i-lucide-globe-lock'
}
return 'i-lucide-globe'
})
const variant = computed(() => (props.online ? 'outline' : 'solid'))
</script>
<template>
<UButton
:color="online ? 'neutral' : 'primary'"
:variant="variant"
:icon="icon"
:disabled="disabled"
:loading="Boolean(pending)"
@click="online ? $emit('unpublish') : $emit('publish')"
>
{{ label }}
</UButton>
</template>
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps<{
state: 'clean' | 'dirty' | 'saving' | 'saved' | 'error' | 'offline'
savedAt?: Date | null
}>()
defineEmits<{ retry: [] }>()
const HOUR_MS = 60 * 60 * 1000
const TICK_MS = 15000
const now = ref(Date.now())
let ticker: ReturnType<typeof setInterval> | null = null
onMounted(() => {
ticker = setInterval(() => {
now.value = Date.now()
}, TICK_MS)
})
onUnmounted(() => {
if (ticker !== null) {
clearInterval(ticker)
}
})
function formatClock(date: Date) {
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${hours}:${minutes}`
}
const savedText = computed(() => {
if (!props.savedAt) {
return 'Saved'
}
const diffMs = now.value - props.savedAt.getTime()
if (diffMs < HOUR_MS) {
return `Saved at ${formatClock(props.savedAt)}`
}
const minutes = Math.floor(diffMs / 60000)
return minutes === 1 ? 'Saved 1 min ago' : `Saved ${minutes} min ago`
})
</script>
<template>
<span
aria-live="polite"
class="inline-flex shrink-0 items-center gap-1.5 text-sm whitespace-nowrap"
:class="{
'text-warning': state === 'dirty',
'text-error': state === 'error',
'text-muted': state !== 'dirty' && state !== 'error',
}"
>
<template v-if="state === 'clean' || state === 'saved'">
<UIcon name="i-lucide-check" class="size-3.5" />
<span>{{ savedText }}</span>
</template>
<template v-else-if="state === 'dirty'">
<UIcon name="i-lucide-circle-dot" class="size-3.5" />
<span>Unsaved changes</span>
</template>
<template v-else-if="state === 'saving'">
<UIcon
name="i-lucide-loader-circle"
class="size-3.5 animate-spin"
/>
<span>Saving…</span>
</template>
<template v-else-if="state === 'error'">
<UIcon name="i-lucide-circle-alert" class="size-3.5" />
<span>Save failed</span>
<UButton
size="xs"
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-6 gap-1 px-2 text-xs text-highlighted"
@click="$emit('retry')"
>
Retry
</UButton>
</template>
<template v-else-if="state === 'offline'">
<UIcon name="i-lucide-wifi-off" class="size-3.5" />
<span>Waiting for connection</span>
</template>
</span>
</template>
+9 -3
View File
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useColorMode, type ColorModePreference } from '@/composables/useColorMode'
import {
useColorMode,
type ColorModePreference,
} from '@/composables/useColorMode'
const props = defineProps<{
username: string
@@ -16,7 +19,9 @@ const colorMode = useColorMode()
const initials = computed(() => {
const parts = props.username.split(/[^a-zA-Z0-9]+/).filter(Boolean)
if (parts.length >= 2) {
return ((parts[0] ?? '').charAt(0) + (parts[1] ?? '').charAt(0)).toUpperCase()
return (
(parts[0] ?? '').charAt(0) + (parts[1] ?? '').charAt(0)
).toUpperCase()
}
return (parts[0] ?? '').slice(0, 2).toUpperCase()
})
@@ -70,7 +75,8 @@ const items = computed(() => [
size="sm"
:ui="{
root: 'size-6.5 bg-secondary-300',
fallback: 'text-[0.6875rem] font-semibold text-primary-900',
fallback:
'text-[0.6875rem] font-semibold text-primary-900',
}"
/>
</template>
+10 -3
View File
@@ -8,7 +8,10 @@ beforeEach(() => {
resetAuthShellNavigation()
})
function mountShell(props: Record<string, unknown>, slots: Record<string, unknown> = {}) {
function mountShell(
props: Record<string, unknown>,
slots: Record<string, unknown> = {},
) {
return mount(AuthShell, {
props,
slots,
@@ -63,13 +66,17 @@ describe('AuthShell renders its notice and footer slots', () => {
const notice = wrapper.find('span')
expect(notice.text()).toBe('Your session expired.')
expect(wrapper.findAll('[data-slot="root"][data-orientation]').length).toBe(1)
expect(
wrapper.findAll('[data-slot="root"][data-orientation]').length,
).toBe(1)
const card = wrapper.find('[data-testid="auth-card"]')
expect(card.element.contains(notice.element)).toBe(true)
const html = wrapper.html()
expect(html.indexOf('<h1')).toBeLessThan(html.indexOf('Your session expired.'))
expect(html.indexOf('<h1')).toBeLessThan(
html.indexOf('Your session expired.'),
)
const footerLink = wrapper.find('a[href="/register/reset/request"]')
expect(footerLink.exists()).toBe(true)
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import CharacterCounter from '../CharacterCounter.vue'
function mountCounter(value: number) {
return mount(CharacterCounter, {
props: { value, max: 10000 },
global: { plugins: [uiPlugin] },
})
}
describe('Below the warning threshold', () => {
it('shows the plain count with no icon', () => {
const wrapper = mountCounter(4318)
expect(wrapper.text()).toContain('4 318 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(false)
expect(
wrapper
.find('[data-testid="character-counter-over-icon"]')
.exists(),
).toBe(false)
})
})
describe('Near the limit — 90% or more', () => {
it('shows the warning icon without an over-limit message', () => {
const wrapper = mountCounter(9240)
expect(wrapper.text()).toContain('9 240 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(true)
expect(wrapper.text()).not.toContain('characters over')
})
})
describe('Too long — over the max', () => {
it('shows the over-limit icon and the exact overage copy', () => {
const wrapper = mountCounter(10214)
expect(wrapper.text()).toContain('10 214 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-over-icon"]')
.exists(),
).toBe(true)
expect(wrapper.text()).toContain(
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
)
})
})
@@ -74,9 +74,14 @@ describe('Cancelling — the action is in flight and the dialog cannot be dismis
await clickButton('Cancel registration')
expect(action).toHaveBeenCalledTimes(1)
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(true)
expect(
(findButtonElement('Keep my account') as HTMLButtonElement)
.disabled,
).toBe(true)
expect(findButtonElement('Cancelling…')).toBeTruthy()
expect((findButtonElement('Cancelling…') as HTMLButtonElement).disabled).toBe(true)
expect(
(findButtonElement('Cancelling…') as HTMLButtonElement).disabled,
).toBe(true)
expect(document.querySelector('[aria-busy="true"]')).toBeTruthy()
resolveAction?.()
@@ -112,18 +117,72 @@ describe('Cancelled — resolving the action closes the dialog', () => {
})
})
describe('Dismissing (Esc or the overlay) defaults to the cancel outcome', () => {
it('emits close(false) without calling the action', async () => {
const action = vi.fn(() => Promise.resolve())
const wrapper = mountDialog(action)
await flushPromises()
await wrapper.setProps({ open: false })
expect(action).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toEqual([[false]])
wrapper.unmount()
})
})
describe('dismissResolvesTo="confirm" makes Esc/overlay resolve as confirmed', () => {
it('emits close(true) on dismiss without calling the action', async () => {
const action = vi.fn(() => Promise.resolve())
const wrapper = mount(ConfirmDialog, {
attachTo: document.body,
props: {
title: 'Leave without saving?',
description:
'Your changes to "How we price a bathroom renovation" haven’t been saved.',
confirmLabel: 'Keep editing',
cancelLabel: 'Discard changes',
pendingLabel: 'Keep editing',
tone: 'neutral',
action,
dismissResolvesTo: 'confirm',
},
global: {
plugins: [uiPlugin],
},
})
await flushPromises()
await wrapper.setProps({ open: false })
expect(action).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toEqual([[true]])
wrapper.unmount()
})
})
describe('Cancel failed — the dialog stays open and shows the failure inline', () => {
it('re-enables both buttons and shows the rejection message inline', async () => {
const action = vi.fn(() => Promise.reject(new Error("Couldn't cancel registration.")))
const action = vi.fn(() =>
Promise.reject(new Error("Couldn't cancel registration.")),
)
const wrapper = mountDialog(action)
await flushPromises()
await clickButton('Cancel registration')
expect(wrapper.emitted('close')).toBeFalsy()
expect(document.body.textContent).toContain("Couldn't cancel registration.")
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(false)
expect((findButtonElement('Cancel registration') as HTMLButtonElement).disabled).toBe(false)
expect(document.body.textContent).toContain(
"Couldn't cancel registration.",
)
expect(
(findButtonElement('Keep my account') as HTMLButtonElement)
.disabled,
).toBe(false)
expect(
(findButtonElement('Cancel registration') as HTMLButtonElement)
.disabled,
).toBe(false)
wrapper.unmount()
})
})
@@ -56,7 +56,9 @@ describe('DashboardShell renders the three navigation destinations', () => {
describe('DashboardShell shows the offline banner when the browser reports offline', () => {
it('shows the banner while offline and hides it once back online', async () => {
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false)
const onLineSpy = vi
.spyOn(navigator, 'onLine', 'get')
.mockReturnValue(false)
const wrapper = await mountShell('/article')
@@ -77,12 +79,16 @@ describe('The sidebar header renders AppLogo and the "RenewCMS" wordmark', () =>
const wrapper = await mountShell('/home')
const header = wrapper.find('[data-slot="header"]')
expect(header.find('svg[role="img"][aria-label="RenewCMS"]').exists()).toBe(true)
expect(
header.find('svg[role="img"][aria-label="RenewCMS"]').exists(),
).toBe(true)
expect(header.text()).toContain('RenewCMS')
expect(header.find('a').exists()).toBe(false)
const headerHtml = header.html()
expect(headerHtml.indexOf('<svg')).toBeLessThan(headerHtml.indexOf('RenewCMS</span>'))
expect(headerHtml.indexOf('<svg')).toBeLessThan(
headerHtml.indexOf('RenewCMS</span>'),
)
})
})
@@ -91,7 +97,9 @@ describe('The sidebar collapse trigger toggles the rail and exposes aria-expande
await mountShell('/home')
const collapseButton = () =>
document.body.querySelector('[data-testid="sidebar-collapse"]') as HTMLButtonElement
document.body.querySelector(
'[data-testid="sidebar-collapse"]',
) as HTMLButtonElement
expect(collapseButton().getAttribute('aria-expanded')).toBe('true')
@@ -112,11 +120,13 @@ describe('A successful logout clears the session and navigates to /login', () =>
await mountShell('/home')
const logOutItem = () =>
Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(el) => el.textContent?.trim() === 'Log out',
) as HTMLElement
Array.from(
document.body.querySelectorAll('[role="menuitem"]'),
).find((el) => el.textContent?.trim() === 'Log out') as HTMLElement
const trigger = document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
const trigger = document.body.querySelector(
'button[aria-haspopup]',
) as HTMLButtonElement
trigger.click()
await flushPromises()
@@ -141,16 +151,28 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
collapseButton.click()
await flushPromises()
const header = document.body.querySelector('[data-slot="header"]') as HTMLElement
expect(header.querySelector('svg[role="img"][aria-label="RenewCMS"]')).toBeTruthy()
const header = document.body.querySelector(
'[data-slot="header"]',
) as HTMLElement
expect(
header.querySelector('svg[role="img"][aria-label="RenewCMS"]'),
).toBeTruthy()
expect(header.textContent).not.toContain('RenewCMS')
expect(header.querySelector('span[aria-hidden="true"]')).toBeNull()
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
const footer = document.body.querySelector(
'[data-slot="footer"]',
) as HTMLElement
expect(footer.textContent).not.toContain('marta')
const userMenuTrigger = footer.querySelector('button[aria-haspopup]') as HTMLButtonElement
expect(userMenuTrigger.getAttribute('aria-label')).toBe('marta — account menu')
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
const userMenuTrigger = footer.querySelector(
'button[aria-haspopup]',
) as HTMLButtonElement
expect(userMenuTrigger.getAttribute('aria-label')).toBe(
'marta — account menu',
)
expect(
footer.querySelector('[data-slot="fallback"]')?.textContent,
).toBe('MA')
})
})
+18 -6
View File
@@ -18,7 +18,9 @@ async function mountNotFound(breadcrumb?: string) {
const router = makeRouter()
await router.push('/')
return mount(ErrorView, {
props: breadcrumb ? { kind: 'not-found', breadcrumb } : { kind: 'not-found' },
props: breadcrumb
? { kind: 'not-found', breadcrumb }
: { kind: 'not-found' },
global: {
plugins: [router],
},
@@ -29,20 +31,30 @@ describe('ErrorView not-found kind', () => {
it('renders the not-found copy with a breadcrumb, primary and secondary actions', async () => {
const wrapper = await mountNotFound('Not found')
expect(wrapper.find('nav[aria-label="Breadcrumb"]').text()).toBe('Not found')
expect(wrapper.find('nav[aria-label="Breadcrumb"]').text()).toBe(
'Not found',
)
expect(wrapper.find('h1').text()).toBe('Page not found')
expect(wrapper.text()).toContain('The link may be mistyped, or the page may have moved.')
expect(wrapper.text()).toContain(
'The link may be mistyped, or the page may have moved.',
)
const buttons = wrapper.findAll('button')
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(true)
expect(buttons.some((button) => button.text() === 'All articles')).toBe(true)
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(
true,
)
expect(buttons.some((button) => button.text() === 'All articles')).toBe(
true,
)
})
it('routes the primary action to /home and the secondary action to /article', async () => {
const wrapper = await mountNotFound()
const router = wrapper.vm.$router
const primary = wrapper.findAll('button').find((button) => button.text() === 'Go to Home')
const primary = wrapper
.findAll('button')
.find((button) => button.text() === 'Go to Home')
await primary?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/home')
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import PublishControl from '../PublishControl.vue'
function mountControl(props: {
online: boolean
dirty: boolean
disabled?: boolean
}) {
return mount(PublishControl, {
props,
global: { plugins: [uiPlugin] },
})
}
describe('Draft, clean', () => {
it('reads "Publish"', () => {
const wrapper = mountControl({ online: false, dirty: false })
expect(wrapper.text()).toBe('Publish')
})
})
describe('Draft, dirty', () => {
it('reads "Save and publish"', () => {
const wrapper = mountControl({ online: false, dirty: true })
expect(wrapper.text()).toBe('Save and publish')
})
})
describe('Online', () => {
it('reads "Unpublish" regardless of dirty state', () => {
const wrapper = mountControl({ online: true, dirty: true })
expect(wrapper.text()).toBe('Unpublish')
})
})
describe('Disabled', () => {
it('disables the button', () => {
const wrapper = mountControl({
online: false,
dirty: false,
disabled: true,
})
expect(
(wrapper.find('button').element as HTMLButtonElement).disabled,
).toBe(true)
})
})
@@ -0,0 +1,87 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import SaveStatus from '../SaveStatus.vue'
afterEach(() => {
vi.useRealTimers()
})
describe('Saved within the hour', () => {
it('reads "Saved at HH:MM"', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-26T11:10:00Z'))
const wrapper = mount(SaveStatus, {
props: {
state: 'saved',
savedAt: new Date('2026-09-26T11:02:00Z'),
},
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Saved at 11:02')
})
})
describe('Saved over an hour ago', () => {
it('reads "Saved n min ago"', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-26T12:32:00Z'))
const wrapper = mount(SaveStatus, {
props: {
state: 'saved',
savedAt: new Date('2026-09-26T11:02:00Z'),
},
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Saved 90 min ago')
})
})
describe('Dirty', () => {
it('reads "Unsaved changes"', () => {
const wrapper = mount(SaveStatus, {
props: { state: 'dirty' },
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Unsaved changes')
})
})
describe('Saving', () => {
it('reads "Saving…"', () => {
const wrapper = mount(SaveStatus, {
props: { state: 'saving' },
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Saving…')
})
})
describe('Error', () => {
it('reads "Save failed" and emits retry from the Retry button', async () => {
const wrapper = mount(SaveStatus, {
props: { state: 'error' },
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Save failed')
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('retry')).toHaveLength(1)
})
})
describe('Offline', () => {
it('reads "Waiting for connection"', () => {
const wrapper = mount(SaveStatus, {
props: { state: 'offline' },
global: { plugins: [uiPlugin] },
})
expect(wrapper.text()).toContain('Waiting for connection')
})
})
+35 -15
View File
@@ -28,7 +28,9 @@ function findMenuItemsByRole(role: string) {
}
function findCheckboxItem(label: string) {
return findMenuItemsByRole('menuitemcheckbox').find((el) => el.textContent?.trim() === label)
return findMenuItemsByRole('menuitemcheckbox').find(
(el) => el.textContent?.trim() === label,
)
}
beforeEach(() => {
@@ -56,8 +58,8 @@ describe("UserMenu's dropdown lists the account label, color-mode checkboxes, th
expect(groups[0].textContent).toContain('marta')
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map((el) =>
el.textContent?.trim(),
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map(
(el) => el.textContent?.trim(),
)
expect(checkboxLabels).toEqual(['System', 'Light', 'Dark'])
@@ -82,9 +84,15 @@ describe("UserMenu's dropdown lists the account label, color-mode checkboxes, th
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu()
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe('true')
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe('false')
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe('false')
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe(
'true',
)
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe(
'false',
)
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe(
'false',
)
wrapper.unmount()
})
@@ -105,15 +113,23 @@ describe('Selecting a color-mode item updates the preference and its checked sta
const label = to.charAt(0).toUpperCase() + to.slice(1)
const fromLabel = from.charAt(0).toUpperCase() + from.slice(1)
findCheckboxItem(label)?.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
findCheckboxItem(label)?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
findCheckboxItem(label)?.dispatchEvent(
new MouseEvent('pointerup', { bubbles: true }),
)
findCheckboxItem(label)?.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
)
await flushPromises()
expect(colorMode.preference).toBe(to)
await openMenu()
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe('true')
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe('false')
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe(
'true',
)
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe(
'false',
)
wrapper.unmount()
})
@@ -124,9 +140,9 @@ describe('A successful logout clears the session and navigates to /login', () =>
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu()
const logOutItem = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(el) => el.textContent?.trim() === 'Log out',
)
const logOutItem = Array.from(
document.body.querySelectorAll('[role="menuitem"]'),
).find((el) => el.textContent?.trim() === 'Log out')
;(logOutItem as HTMLElement)?.click()
await flushPromises()
@@ -141,8 +157,12 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
const wrapper = mountMenu({ username: 'marta', collapsed: true })
expect(wrapper.text()).not.toContain('marta')
expect(findTriggerButton()?.getAttribute('aria-label')).toBe('marta — account menu')
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
expect(findTriggerButton()?.getAttribute('aria-label')).toBe(
'marta — account menu',
)
expect(
document.body.querySelector('[data-slot="fallback"]')?.textContent,
).toBe('MA')
await openMenu()
@@ -33,7 +33,8 @@ let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountLogin() {
setActivePinia(createPinia())
global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch
global.fetch = (() =>
mockResponse(200, { open: false })) as unknown as typeof fetch
await router.push('/login')
const wrapper = mount(LoginView, {
attachTo: document.body,
@@ -69,7 +70,9 @@ describe('AppLogo renders the brand mark as inline SVG, and theme-switches', ()
expect(card.element.contains(svg.element)).toBe(true)
const html = wrapper.html()
expect(html.indexOf('<svg')).toBeGreaterThan(html.indexOf('data-testid="auth-card"'))
expect(html.indexOf('<svg')).toBeGreaterThan(
html.indexOf('data-testid="auth-card"'),
)
expect(html.indexOf('<svg')).toBeLessThan(html.indexOf('<h1'))
let centeringAncestor = svg.element.parentElement
@@ -110,7 +113,9 @@ describe("UPageCard's title is the page h1, and its description uses UPageCard's
expect(heading.text()).toBe('Log in')
expect(heading.attributes('tabindex')).toBe('-1')
expect(wrapper.text()).toContain('Welcome back. Log in to manage your articles.')
expect(wrapper.text()).toContain(
'Welcome back. Log in to manage your articles.',
)
expect(wrapper.findAll('p').length).toBe(0)
})
})
@@ -175,8 +180,12 @@ describe('The login form is a UForm', () => {
const form = wrapper.find('form')
expect(form.exists()).toBe(true)
expect(form.attributes('id')).toMatch(/^v-/)
expect(form.find('input[placeholder="Your username"]').exists()).toBe(true)
expect(form.find('input[autocomplete="current-password"]').exists()).toBe(true)
expect(form.find('input[placeholder="Your username"]').exists()).toBe(
true,
)
expect(
form.find('input[autocomplete="current-password"]').exists(),
).toBe(true)
expect(form.find('button[type="submit"]').exists()).toBe(true)
})
})
@@ -185,14 +194,20 @@ describe('"Forgot password?" renders in the Password field\'s hint slot', () =>
it('renders the link through the hint slot, and the label reads only Password', async () => {
const wrapper = await mountLogin()
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const fieldRoot = passwordInput.element.closest('[data-slot="root"][data-orientation]')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
const fieldRoot = passwordInput.element.closest(
'[data-slot="root"][data-orientation]',
)
expect(fieldRoot).toBeTruthy()
const hint = fieldRoot!.querySelector('[data-slot="hint"]')
expect(hint).toBeTruthy()
expect(hint!.textContent?.trim()).toBe('Forgot password?')
expect(hint!.querySelector('a[href="/register/reset/request"]')).toBeTruthy()
expect(
hint!.querySelector('a[href="/register/reset/request"]'),
).toBeTruthy()
const label = fieldRoot!.querySelector('[data-slot="label"]')
expect(label!.textContent?.trim()).toBe('Password')
@@ -204,7 +219,9 @@ describe('Inputs are neutral by default and turn to the error edge only when the
const wrapper = await mountLogin()
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect(usernameInput.classes()).toContain('ring-accented')
expect(usernameInput.classes()).not.toContain('ring-error')
expect(passwordInput.classes()).toContain('ring-accented')
@@ -215,7 +232,9 @@ describe('Inputs are neutral by default and turn to the error edge only when the
await flushPromises()
const usernameAfter = wrapper.find('input[placeholder="Your username"]')
const passwordAfter = wrapper.find('input[autocomplete="current-password"]')
const passwordAfter = wrapper.find(
'input[autocomplete="current-password"]',
)
expect(usernameAfter.classes()).toContain('ring-error')
expect(usernameAfter.classes()).not.toContain('ring-accented')
expect(passwordAfter.classes()).toContain('ring-error')
@@ -33,7 +33,8 @@ let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountLogin(path = '/login') {
setActivePinia(createPinia())
global.fetch = (() => mockResponse(200, { open: false })) as unknown as typeof fetch
global.fetch = (() =>
mockResponse(200, { open: false })) as unknown as typeof fetch
await router.push(path)
const wrapper = mount(LoginView, {
attachTo: document.body,
@@ -77,7 +78,9 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
await wrapper.find('form').trigger('submit')
await flushPromises()
const fieldRoots = wrapper.findAll('[data-slot="root"][data-orientation]')
const fieldRoots = wrapper.findAll(
'[data-slot="root"][data-orientation]',
)
const usernameError = fieldRoots[0]!.find('[data-slot="error"]')
const passwordError = fieldRoots[1]!.find('[data-slot="error"]')
@@ -94,7 +97,9 @@ describe('A field error shows the circle-alert icon with no extra focus halo', (
expect(usernameInput.classes()).toContain('ring-error')
expect(usernameInput.classes()).toContain('focus-visible:ring-error')
expect(usernameInput.classes()).not.toContain('ring-primary')
expect(usernameInput.classes()).not.toContain('focus-visible:ring-primary')
expect(usernameInput.classes()).not.toContain(
'focus-visible:ring-primary',
)
})
})
@@ -141,19 +146,31 @@ describe('The alert renders inside the card, under the description', () => {
expect(card.element.contains(alert.element)).toBe(true)
expect(alert.text()).toContain(text)
const rootsInCard = card.findAll('[data-slot="root"][data-orientation]')
expect(rootsInCard.some((root) => root.classes().includes('bg-primary'))).toBe(false)
const rootsInCard = card.findAll(
'[data-slot="root"][data-orientation]',
)
expect(
rootsInCard.some((root) =>
root.classes().includes('bg-primary'),
),
).toBe(false)
const usernameInput = wrapper.find('input[placeholder="Your username"]')
const usernameInput = wrapper.find(
'input[placeholder="Your username"]',
)
const html = card.html()
expect(html.indexOf('Welcome back')).toBeGreaterThan(-1)
expect(html.indexOf('Welcome back')).toBeLessThan(html.indexOf(text))
expect(html.indexOf('Welcome back')).toBeLessThan(
html.indexOf(text),
)
expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
expect(card.element.contains(usernameInput.element)).toBe(true)
expect(alert.classes().some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c))).toBe(
true,
)
expect(
alert
.classes()
.some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c)),
).toBe(true)
},
)
})
+18 -5
View File
@@ -5,7 +5,11 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return {
ok: status >= 200 && status < 300,
status,
@@ -18,7 +22,10 @@ function setOnline(value: boolean) {
Object.defineProperty(navigator, 'onLine', { configurable: true, value })
}
async function expectApiError(call: Promise<unknown>, matcher: Record<string, unknown>) {
async function expectApiError(
call: Promise<unknown>,
matcher: Record<string, unknown>,
) {
await expect(call).rejects.toBeInstanceOf(ApiError)
await expect(call).rejects.toMatchObject(matcher)
}
@@ -102,7 +109,9 @@ describe('useApi global 401/403/429/offline handling', () => {
await expectApiError(call, { status: 403 })
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
expect(session.session).toEqual({ verified: false })
})
@@ -150,7 +159,9 @@ describe('useApi global 401/403/429/offline handling', () => {
})
it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => {
fetchMock.mockResolvedValue(mockResponse(429, null, { 'Retry-After': '30' }))
fetchMock.mockResolvedValue(
mockResponse(429, null, { 'Retry-After': '30' }),
)
const call = useApi().request('/v1/auth/login', { method: 'POST' })
@@ -160,7 +171,9 @@ describe('useApi global 401/403/429/offline handling', () => {
it('A 429 with no Retry-After header exposes an undefined retryAfter', async () => {
fetchMock.mockResolvedValue(mockResponse(429))
const call = useApi().request('/v1/auth/password-reset', { method: 'POST' })
const call = useApi().request('/v1/auth/password-reset', {
method: 'POST',
})
await expectApiError(call, { status: 429, retryAfter: undefined })
})
@@ -0,0 +1,127 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import {
useArticleDraft,
readMirroredDraft,
} from '@/composables/useArticleDraft'
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
afterEach(() => {
sessionStorage.clear()
vi.restoreAllMocks()
})
describe('Loading an article without a mirrored draft starts clean', () => {
it('sets the body and clean state from the given article', () => {
setActivePinia(createPinia())
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date('2026-09-26T11:02:00Z'))
expect(draft.body.value).toBe('<p>Original</p>')
expect(draft.state.value).toBe('clean')
expect(draft.savedAt.value).toEqual(new Date('2026-09-26T11:02:00Z'))
})
})
describe('Loading an article with a mirrored draft restores it as dirty', () => {
it('prefers the sessionStorage body over the loaded one', () => {
setActivePinia(createPinia())
sessionStorage.setItem('renewcms:article-draft:12', '<p>Restored</p>')
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date('2026-09-26T11:02:00Z'))
expect(draft.body.value).toBe('<p>Restored</p>')
expect(draft.state.value).toBe('dirty')
})
})
describe('Editing moves the state to dirty', () => {
it('updates the body and sets state to dirty', () => {
setActivePinia(createPinia())
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Changed</p>')
expect(draft.body.value).toBe('<p>Changed</p>')
expect(draft.state.value).toBe('dirty')
})
})
describe('A successful save sets state saved and records the time', () => {
it('resolves "saved" and clears any mirrored draft', async () => {
setActivePinia(createPinia())
global.fetch = vi
.fn()
.mockResolvedValue(
mockResponse(200, { id: 12, body: '<p>Changed</p>' }),
)
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Changed</p>')
const outcome = await draft.save()
expect(outcome).toBe('saved')
expect(draft.state.value).toBe('saved')
expect(draft.savedAt.value).toBeInstanceOf(Date)
})
})
describe('A 500 response sets state error and keeps the body', () => {
it('resolves "error" without touching the body', async () => {
setActivePinia(createPinia())
global.fetch = vi.fn().mockResolvedValue(mockResponse(500))
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Changed</p>')
const outcome = await draft.save()
expect(outcome).toBe('error')
expect(draft.state.value).toBe('error')
expect(draft.body.value).toBe('<p>Changed</p>')
})
})
describe('A 404 response keeps the body and does not enter the error state', () => {
it('resolves "not-found" and stays dirty', async () => {
setActivePinia(createPinia())
global.fetch = vi.fn().mockResolvedValue(mockResponse(404))
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Changed</p>')
const outcome = await draft.save()
expect(outcome).toBe('not-found')
expect(draft.state.value).toBe('dirty')
})
})
describe('A 401 response mirrors the body to sessionStorage', () => {
it('resolves "unauthorized" and stores the body keyed by the article id', async () => {
setActivePinia(createPinia())
global.fetch = vi.fn().mockResolvedValue(mockResponse(401))
const draft = useArticleDraft(12)
draft.load('<p>Original</p>', new Date())
draft.edit('<p>Paragraph added just before the session expired.</p>')
const outcome = await draft.save()
expect(outcome).toBe('unauthorized')
expect(readMirroredDraft(12)).toBe(
'<p>Paragraph added just before the session expired.</p>',
)
})
})
+29 -7
View File
@@ -33,6 +33,7 @@ export interface RequestOptions extends RequestInit {
}
export const isOffline = ref(false)
export const authRedirectPending = ref(false)
function isNavigatorOnLine() {
return typeof navigator === 'undefined' || navigator.onLine !== false
@@ -60,8 +61,15 @@ 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 {
@@ -77,13 +85,24 @@ export function useApi() {
if (response.status === 401 && onUnauthorized === 'redirect') {
const currentPath = router.currentRoute.value.fullPath
useSessionStore().clear()
router.push({ path: '/login', query: { redirect: currentPath, reason: 'expired' } })
authRedirectPending.value = true
router
.push({
path: '/login',
query: { redirect: currentPath, reason: 'expired' },
})
.finally(() => {
authRedirectPending.value = false
})
throw new ApiError(401)
}
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)
@@ -100,9 +119,12 @@ export function useApi() {
}
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 }
}
+107
View File
@@ -0,0 +1,107 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { ApiError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
export type ArticleDraftBusinessState =
| 'clean'
| 'dirty'
| 'saving'
| 'saved'
| 'error'
export type ArticleDraftState = ArticleDraftBusinessState | 'offline'
export type SaveOutcome = 'saved' | 'error' | 'not-found' | 'unauthorized'
function draftStorageKey(articleId: number) {
return `renewcms:article-draft:${articleId}`
}
export function readMirroredDraft(articleId: number): string | null {
return sessionStorage.getItem(draftStorageKey(articleId))
}
export function useArticleDraft(articleId: number) {
const { request } = useApi()
const body = ref('')
const businessState = ref<ArticleDraftBusinessState>('clean')
const savedAt = ref<Date | null>(null)
const offline = ref(
typeof navigator === 'undefined' ? false : !navigator.onLine,
)
function handleOffline() {
offline.value = true
}
function handleOnline() {
offline.value = false
}
onMounted(() => {
window.addEventListener('offline', handleOffline)
window.addEventListener('online', handleOnline)
})
onUnmounted(() => {
window.removeEventListener('offline', handleOffline)
window.removeEventListener('online', handleOnline)
})
const state = computed<ArticleDraftState>(() =>
offline.value ? 'offline' : businessState.value,
)
function mirrorToStorage() {
sessionStorage.setItem(draftStorageKey(articleId), body.value)
}
function clearStorage() {
sessionStorage.removeItem(draftStorageKey(articleId))
}
function load(initialBody: string, initialSavedAt: Date) {
const mirrored = readMirroredDraft(articleId)
savedAt.value = initialSavedAt
if (mirrored !== null) {
body.value = mirrored
businessState.value = 'dirty'
return
}
body.value = initialBody
businessState.value = 'clean'
}
function edit(newBody: string) {
body.value = newBody
businessState.value = 'dirty'
}
async function save(): Promise<SaveOutcome> {
businessState.value = 'saving'
try {
await request<Article>(`/v1/article/${articleId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: body.value }),
})
businessState.value = 'saved'
savedAt.value = new Date()
clearStorage()
return 'saved'
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
businessState.value = 'dirty'
return 'not-found'
}
if (err instanceof ApiError && err.status === 401) {
mirrorToStorage()
businessState.value = 'dirty'
return 'unauthorized'
}
businessState.value = 'error'
return 'error'
}
}
return { body, state, savedAt, load, edit, save }
}
+3 -1
View File
@@ -7,7 +7,9 @@ export function useColorMode() {
return {
get preference(): ColorModePreference {
return store.value === 'auto' ? 'system' : (store.value as ColorModePreference)
return store.value === 'auto'
? 'system'
: (store.value as ColorModePreference)
},
set preference(value: ColorModePreference) {
store.value = value === 'system' ? 'auto' : value
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { ArticleIndent } from '../articleIndent'
function createEditor(content: string) {
return new Editor({
extensions: [StarterKit, ArticleIndent],
content,
})
}
describe('Indenting a paragraph writes data-indent on the paragraph', () => {
it('increases the indent level up to the maximum', () => {
const editor = createEditor('<p>Hello</p>')
editor.commands.setTextSelection(2)
editor.commands.indent()
expect(editor.getHTML()).toContain('data-indent="1"')
editor.commands.indent()
expect(editor.getHTML()).toContain('data-indent="2"')
for (let i = 0; i < 10; i += 1) {
editor.commands.indent()
}
expect(editor.getHTML()).toContain('data-indent="4"')
expect(editor.getHTML()).not.toContain('data-indent="5"')
editor.destroy()
})
})
describe('Outdenting a paragraph decreases the indent level', () => {
it('stops at zero and drops the attribute', () => {
const editor = createEditor('<p>Hello</p>')
editor.commands.setTextSelection(2)
editor.commands.indent()
editor.commands.indent()
expect(editor.getHTML()).toContain('data-indent="2"')
editor.commands.outdent()
expect(editor.getHTML()).toContain('data-indent="1"')
editor.commands.outdent()
expect(editor.getHTML()).not.toContain('data-indent')
editor.commands.outdent()
expect(editor.getHTML()).not.toContain('data-indent')
editor.destroy()
})
})
describe('Indenting inside a list item sinks it instead of touching the paragraph', () => {
it('nests the list item under the previous one', () => {
const editor = createEditor(
'<ul><li><p>First</p></li><li><p>Second</p></li></ul>',
)
editor.commands.setTextSelection(editor.state.doc.content.size - 2)
const indented = editor.commands.indent()
expect(indented).toBe(true)
expect(editor.getHTML()).toContain('<p>First</p><ul>')
expect(editor.getHTML()).not.toContain('data-indent')
editor.destroy()
})
})
describe('Outdenting a heading decreases its indent level too', () => {
it('applies data-indent to headings', () => {
const editor = createEditor('<h2>Title</h2>')
editor.commands.setTextSelection(2)
editor.commands.indent()
expect(editor.getHTML()).toContain('data-indent="1"')
editor.commands.outdent()
expect(editor.getHTML()).not.toContain('data-indent')
editor.destroy()
})
})
+27 -9
View File
@@ -5,38 +5,56 @@ const now = new Date('2026-08-25T12:00:00Z')
describe('formatRelativeDate', () => {
it('formats under an hour in minutes', () => {
expect(formatRelativeDate('2026-08-25T11:45:00Z', now)).toBe('15 minutes ago')
expect(formatRelativeDate('2026-08-25T11:45:00Z', now)).toBe(
'15 minutes ago',
)
})
it('clamps a sub-minute difference to "1 minute ago"', () => {
expect(formatRelativeDate('2026-08-25T11:59:45Z', now)).toBe('1 minute ago')
expect(formatRelativeDate('2026-08-25T11:59:45Z', now)).toBe(
'1 minute ago',
)
})
it('uses the singular for exactly one minute ago', () => {
expect(formatRelativeDate('2026-08-25T11:59:00Z', now)).toBe('1 minute ago')
expect(formatRelativeDate('2026-08-25T11:59:00Z', now)).toBe(
'1 minute ago',
)
})
it('formats under a day in hours', () => {
expect(formatRelativeDate('2026-08-25T10:00:00Z', now)).toBe('2 hours ago')
expect(formatRelativeDate('2026-08-25T10:00:00Z', now)).toBe(
'2 hours ago',
)
})
it('uses the singular for exactly one hour ago', () => {
expect(formatRelativeDate('2026-08-25T11:00:00Z', now)).toBe('1 hour ago')
expect(formatRelativeDate('2026-08-25T11:00:00Z', now)).toBe(
'1 hour ago',
)
})
it('formats exactly one day back as Yesterday', () => {
expect(formatRelativeDate('2026-08-24T11:00:00Z', now)).toBe('Yesterday')
expect(formatRelativeDate('2026-08-24T11:00:00Z', now)).toBe(
'Yesterday',
)
})
it('formats within the week in days', () => {
expect(formatRelativeDate('2026-08-22T12:00:00Z', now)).toBe('3 days ago')
expect(formatRelativeDate('2026-08-22T12:00:00Z', now)).toBe(
'3 days ago',
)
})
it('formats exactly 7 days ago as an absolute date, not "7 days ago"', () => {
expect(formatRelativeDate('2026-08-18T12:00:00Z', now)).toBe('18 Aug 2026')
expect(formatRelativeDate('2026-08-18T12:00:00Z', now)).toBe(
'18 Aug 2026',
)
})
it('formats a week or more ago as an absolute date', () => {
expect(formatRelativeDate('2026-08-10T12:00:00Z', now)).toBe('10 Aug 2026')
expect(formatRelativeDate('2026-08-10T12:00:00Z', now)).toBe(
'10 Aug 2026',
)
})
})
+97
View File
@@ -0,0 +1,97 @@
import { Extension } from '@tiptap/core'
export const INDENT_MAX = 4
export const INDENT_TYPES = ['paragraph', 'heading']
declare module '@tiptap/core' {
interface Commands<ReturnType> {
articleIndent: {
indent: () => ReturnType
outdent: () => ReturnType
}
}
}
function currentIndentableNode(state: {
selection: { $from: { parent: unknown } }
}) {
return state.selection.$from.parent as {
type: { name: string }
attrs: { indent?: number }
}
}
export const ArticleIndent = Extension.create({
name: 'articleIndent',
addGlobalAttributes() {
return [
{
types: INDENT_TYPES,
attributes: {
indent: {
default: 0,
parseHTML: (element) =>
Number(element.getAttribute('data-indent')) || 0,
renderHTML: (attributes) => {
if (!attributes.indent) {
return {}
}
return { 'data-indent': String(attributes.indent) }
},
},
},
},
]
},
addCommands() {
return {
indent:
() =>
({ state, commands, tr, dispatch }) => {
if (commands.sinkListItem('listItem')) {
return true
}
const node = currentIndentableNode(state)
if (!INDENT_TYPES.includes(node.type.name)) {
return false
}
const level = Math.min(
INDENT_MAX,
(node.attrs.indent ?? 0) + 1,
)
if (dispatch) {
const { $from } = state.selection
tr.setNodeAttribute(
$from.before($from.depth),
'indent',
level,
)
dispatch(tr)
}
return true
},
outdent:
() =>
({ state, commands, tr, dispatch }) => {
if (commands.liftListItem('listItem')) {
return true
}
const node = currentIndentableNode(state)
if (!INDENT_TYPES.includes(node.type.name)) {
return false
}
const level = Math.max(0, (node.attrs.indent ?? 0) - 1)
if (dispatch) {
const { $from } = state.selection
tr.setNodeAttribute(
$from.before($from.depth),
'indent',
level,
)
dispatch(tr)
}
return true
},
}
},
})
+22 -7
View File
@@ -65,7 +65,10 @@ describe('Nuxt UI shell foundation and router guards', () => {
it('The first navigation to a non-public route resolves the session and shows AppBoot meanwhile', async () => {
let resolveMe: (value: { verified: boolean }) => void = () => {}
const getMe = vi.fn(
() => new Promise<{ verified: boolean }>((resolve) => (resolveMe = resolve)),
() =>
new Promise<{ verified: boolean }>(
(resolve) => (resolveMe = resolve),
),
)
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
@@ -121,7 +124,9 @@ describe('Nuxt UI shell foundation and router guards', () => {
await router.push('/article')
mountApp()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
})
it('A session-only route admits an unverified session', async () => {
@@ -196,7 +201,9 @@ describe('Nuxt UI shell foundation and router guards', () => {
)
expect(app.text()).toContain('Home')
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
const primaryButton = app
.findAll('button')
.find((button) => button.text() === 'Try again')
await primaryButton?.trigger('click')
await flushPromises()
@@ -212,12 +219,16 @@ describe('Nuxt UI shell foundation and router guards', () => {
const app = mountApp()
await vi.waitFor(() =>
expect(app.find('[role="alert"] h1').text()).toBe('Something went wrong'),
expect(app.find('[role="alert"] h1').text()).toBe(
'Something went wrong',
),
)
expect(router.currentRoute.value.path).toBe('/article')
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
const primaryButton = app
.findAll('button')
.find((button) => button.text() === 'Try again')
await primaryButton?.trigger('click')
await flushPromises()
@@ -240,10 +251,14 @@ describe('Nuxt UI shell foundation and router guards', () => {
await flushPromises()
const headings = app.findAll('h1')
expect(headings.some((heading) => heading.text() === title)).toBe(true)
expect(headings.some((heading) => heading.text() === title)).toBe(
true,
)
expect(app.find('nav[aria-label="breadcrumb"]').text()).toBe(title)
const activeLink = app.find(`a[href="${path}"][aria-current="page"]`)
const activeLink = app.find(
`a[href="${path}"][aria-current="page"]`,
)
expect(activeLink.exists()).toBe(true)
},
)
+13 -3
View File
@@ -11,7 +11,11 @@ export type AuthRequirement = 'public' | 'session' | 'verified' | 'any'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', component: () => import('@/views/RootView.vue'), meta: { auth: 'any' } },
{
path: '/',
component: () => import('@/views/RootView.vue'),
meta: { auth: 'any' },
},
{
path: '/login',
component: () => import('@/views/LoginView.vue'),
@@ -94,7 +98,10 @@ export function resolveAuthRedirect(
if (auth === 'any') {
return { path: '/login' }
}
return { path: '/login', query: { redirect: to.fullPath, reason: 'expired' } }
return {
path: '/login',
query: { redirect: to.fullPath, reason: 'expired' },
}
}
if (!account.verified) {
@@ -141,7 +148,10 @@ router.afterEach((to) => {
: Promise.resolve()
settled.then(() => {
const destination = resolveAuthRedirect(router.currentRoute.value, session)
const destination = resolveAuthRedirect(
router.currentRoute.value,
session,
)
if (destination !== true) {
router.replace(destination)
}
+32 -5
View File
@@ -153,6 +153,10 @@ export const renewUi = {
base: 'h-5.5 gap-1 px-1.75 py-0 text-xs',
leadingIcon: 'size-3',
},
lg: {
base: 'h-6.5 gap-1.25 px-2.25 py-0 text-sm',
leadingIcon: 'size-3.5',
},
},
},
},
@@ -225,7 +229,14 @@ export const renewUi = {
},
compoundVariants: [
{
color: ['primary', 'secondary', 'success', 'info', 'warning', 'neutral'],
color: [
'primary',
'secondary',
'success',
'info',
'warning',
'neutral',
],
class: { close: 'hidden' },
},
],
@@ -250,10 +261,26 @@ export const renewUi = {
},
},
compoundVariants: [
{ color: 'success', variant: 'soft', class: { root: 'bg-(--rc-success-soft)' } },
{ color: 'info', variant: 'soft', class: { root: 'bg-(--rc-info-soft)' } },
{ color: 'warning', variant: 'soft', class: { root: 'bg-(--rc-warning-soft)' } },
{ color: 'error', variant: 'soft', class: { root: 'bg-(--rc-error-soft)' } },
{
color: 'success',
variant: 'soft',
class: { root: 'bg-(--rc-success-soft)' },
},
{
color: 'info',
variant: 'soft',
class: { root: 'bg-(--rc-info-soft)' },
},
{
color: 'warning',
variant: 'soft',
class: { root: 'bg-(--rc-warning-soft)' },
},
{
color: 'error',
variant: 'soft',
class: { root: 'bg-(--rc-error-soft)' },
},
],
},
card: {
+358 -2
View File
@@ -1,9 +1,365 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import DashboardShell from '@/components/DashboardShell.vue'
import ErrorView from '@/components/ErrorView.vue'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import ArticleEditor from '@/components/ArticleEditor.vue'
import ArticleDetails from '@/components/ArticleDetails.vue'
import SaveStatus from '@/components/SaveStatus.vue'
import PublishControl from '@/components/PublishControl.vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import { useArticleDraft } from '@/composables/useArticleDraft'
const SKELETON_DELAY_MS = 150
const BODY_MAX_LENGTH = 10000
type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
const route = useRoute()
const router = useRouter()
const { request } = useApi()
const overlay = useOverlay()
const toast = useToast()
const articleId = Number(route.params.id)
const draft = useArticleDraft(articleId)
const loadState = ref<LoadState>('loading')
const showSkeleton = ref(false)
const article = ref<Article | null>(null)
const previewOn = ref(false)
const detailsOpen = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function loadArticle() {
loadState.value = 'loading'
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
const result = await request<Article>(`/v1/article/${articleId}`)
article.value = result
draft.load(result.body, new Date(result.updated_at))
loadState.value = 'loaded'
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
loadState.value = 'notFound'
return
}
loadState.value = 'error'
} finally {
clearSkeletonTimer()
showSkeleton.value = false
}
}
const bodyLength = computed(() => draft.body.value.length)
const overLimit = computed(() => bodyLength.value > BODY_MAX_LENGTH)
const saveEnabled = computed(
() =>
draft.state.value === 'dirty' &&
!overLimit.value &&
loadState.value === 'loaded',
)
const publishDisabled = computed(
() => overLimit.value || draft.state.value === 'offline',
)
async function runSave() {
const outcome = await draft.save()
if (outcome === 'not-found') {
toast.add({
color: 'error',
icon: 'i-lucide-circle-x',
title: 'That article no longer exists.',
})
}
}
async function performSave() {
if (!saveEnabled.value || !article.value) {
return
}
await runSave()
}
function retrySave() {
if (!article.value) {
return
}
runSave()
}
function goBack() {
router.push('/article')
}
function onKeydown(event: KeyboardEvent) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault()
performSave()
}
}
onMounted(() => {
window.addEventListener('keydown', onKeydown)
loadArticle()
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
clearSkeletonTimer()
})
onBeforeRouteLeave(async () => {
if (authRedirectPending.value || draft.state.value !== 'dirty') {
return true
}
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
title: 'Leave without saving?',
description: `Your changes to "${article.value?.title ?? ''}" haven't been saved.`,
confirmLabel: 'Keep editing',
cancelLabel: 'Discard changes',
pendingLabel: 'Keep editing',
tone: 'neutral',
action: async () => {},
dismissResolvesTo: 'confirm',
},
})
.open()
return !confirmed
})
</script>
<template>
<DashboardShell>
<p>Editor placeholder.</p>
<DashboardShell mobile-title="Edit article" full-bleed>
<template v-if="loadState === 'loaded'" #breadcrumb>
<UBreadcrumb
:items="[
{ label: 'Articles', to: '/article' },
{ label: article?.title ?? '' },
]"
color="neutral"
class="hidden lg:block"
/>
</template>
<ErrorView v-if="loadState === 'notFound'" kind="article-not-found" />
<ErrorView
v-else-if="loadState === 'error'"
kind="error"
@retry="loadArticle"
/>
<div
v-else-if="loadState === 'loading' && showSkeleton"
aria-busy="true"
class="flex min-h-0 flex-1 flex-col lg:flex-row"
>
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
<div
class="flex flex-shrink-0 items-center gap-2.5 border-b border-default bg-default px-6 py-3.5"
>
<UButton
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
aria-label="Back to Articles"
@click="goBack"
/>
<USkeleton
class="h-5 w-80 rounded-none"
data-testid="skeleton-title"
/>
<USkeleton class="h-5.5 w-15 rounded-none" />
</div>
<div
class="min-h-0 max-w-168 flex-1 space-y-3.5 px-12 py-10"
data-testid="skeleton-content"
>
<USkeleton class="h-3.5 w-full rounded-none" />
<USkeleton class="h-3.5 w-11/12 rounded-none" />
<USkeleton class="h-3.5 w-4/5 rounded-none" />
<USkeleton class="mt-4.5 h-5.5 w-2/5 rounded-none" />
<USkeleton class="h-3.5 w-full rounded-none" />
<USkeleton class="h-3.5 w-3/4 rounded-none" />
</div>
</div>
<div
class="w-70 flex-shrink-0 space-y-2.5 border-default p-5 lg:border-l"
data-testid="skeleton-details"
>
<USkeleton class="h-3 w-full rounded-none" />
<USkeleton class="h-3 w-full rounded-none" />
<USkeleton class="h-3 w-3/5 rounded-none" />
</div>
</div>
<div
v-else-if="loadState === 'loaded' && article"
class="flex min-h-0 flex-1 flex-col"
>
<div
class="flex flex-shrink-0 flex-wrap items-center gap-3 border-b border-default bg-default px-6 py-3.5"
>
<UButton
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
aria-label="Back to Articles"
@click="goBack"
/>
<div
class="flex min-w-50 flex-1 flex-wrap items-center gap-2.5"
>
<h1
class="text-[1.125rem]/6.5 font-semibold text-highlighted"
title="The title can't be changed after creation."
>
{{ article.title }}
</h1>
<ArticleStatusBadge :online="article.is_online" size="lg" />
</div>
<SaveStatus
:state="draft.state.value"
:saved-at="draft.savedAt.value"
@retry="retrySave"
/>
<div class="hidden items-center gap-3 lg:flex">
<USwitch
v-model="previewOn"
label="Preview"
:ui="{
root: 'items-center gap-2',
container: 'h-auto',
base: 'h-4.5 w-8 p-0.5',
thumb: 'size-3.5 bg-default data-[state=checked]:translate-x-3.5',
wrapper: 'ms-0',
label: 'text-sm font-normal text-toned',
}"
/>
<UButton
data-testid="save-button"
icon="i-lucide-save"
color="neutral"
variant="outline"
class="h-8 gap-1.5 px-3"
:disabled="!saveEnabled"
@click="performSave"
>
Save
</UButton>
<PublishControl
class="h-8 gap-1.5 px-3"
:online="article.is_online"
:dirty="draft.state.value === 'dirty'"
:disabled="publishDisabled"
/>
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
class="size-8 justify-center text-toned"
aria-label="More actions"
/>
</div>
</div>
<div v-if="previewOn" class="flex min-h-0 flex-1">
<div
class="min-h-0 flex-1 overflow-y-auto bg-muted px-12 py-10"
data-testid="article-preview"
>
<div class="article-content mx-auto">
<h2 class="text-2xl font-semibold text-highlighted">
{{ article.title }}
</h2>
<div v-html="draft.body.value" />
</div>
</div>
<div
class="hidden w-70 flex-shrink-0 border-l border-default bg-default p-5 lg:block"
>
<ArticleDetails
:article="article"
v-model:open="detailsOpen"
/>
</div>
</div>
<ArticleEditor
v-else
class="min-h-0 flex-1"
:model-value="draft.body.value"
:article-id="articleId"
@update:model-value="draft.edit"
>
<template #footer>
<CharacterCounter
variant="footer"
:value="bodyLength"
:max="BODY_MAX_LENGTH"
/>
</template>
<template #aside>
<div class="h-full border-l border-default bg-default p-5">
<ArticleDetails
:article="article"
v-model:open="detailsOpen"
/>
</div>
</template>
</ArticleEditor>
<div
class="flex flex-shrink-0 gap-2 border-t border-default bg-default p-3 lg:hidden"
>
<UButton
icon="i-lucide-panel-right"
color="neutral"
variant="outline"
class="size-11 justify-center"
aria-label="Details"
@click="detailsOpen = true"
/>
<UButton
data-testid="save-button-mobile"
color="neutral"
variant="outline"
class="h-11 flex-1 justify-center"
:disabled="!saveEnabled"
@click="performSave"
>
Save
</UButton>
<PublishControl
class="h-11 flex-1 justify-center"
hide-icon
:online="article.is_online"
:dirty="draft.state.value === 'dirty'"
:disabled="publishDisabled"
/>
</div>
</div>
</DashboardShell>
</template>
+60 -20
View File
@@ -60,7 +60,11 @@ const statTiles = computed(() => [
const recentArticles = computed(() =>
[...articles.value]
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.sort(
(a, b) =>
new Date(b.updated_at).getTime() -
new Date(a.updated_at).getTime(),
)
.slice(0, RECENT_LIST_SIZE),
)
@@ -115,7 +119,10 @@ onMounted(loadArticles)
<div class="flex max-w-244 flex-col gap-5 lg:gap-7">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-1">
<h1 data-testid="greeting" class="text-2xl font-semibold text-highlighted">
<h1
data-testid="greeting"
class="text-2xl font-semibold text-highlighted"
>
{{ greeting }}
</h1>
<p class="text-base/5 text-muted">{{ subtitle }}</p>
@@ -129,7 +136,12 @@ onMounted(loadArticles)
</UButton>
</div>
<template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'">
<template
v-if="
(loadState === 'loading' && showSkeleton) ||
loadState === 'default'
"
>
<div class="grid grid-cols-3 gap-3">
<div
v-for="tile in statTiles"
@@ -138,13 +150,18 @@ onMounted(loadArticles)
class="flex flex-col gap-1 border border-default bg-default p-4"
>
<template v-if="loadState === 'loading'">
<USkeleton class="h-7 w-10 rounded-none bg-accented" />
<USkeleton class="h-3 w-17.5 rounded-none bg-accented" />
<USkeleton
class="h-7 w-10 rounded-none bg-accented"
/>
<USkeleton
class="h-3 w-17.5 rounded-none bg-accented"
/>
</template>
<template v-else>
<span class="text-2xl font-semibold text-highlighted">{{
tile.value
}}</span>
<span
class="text-2xl font-semibold text-highlighted"
>{{ tile.value }}</span
>
<span class="text-muted">{{ tile.label }}</span>
</template>
</div>
@@ -152,8 +169,13 @@ onMounted(loadArticles)
<section class="flex flex-col gap-2.5">
<div class="flex items-baseline justify-between">
<h2 class="text-lg font-semibold text-highlighted">Recently updated</h2>
<RouterLink to="/article" class="text-toned underline underline-offset-3">
<h2 class="text-lg font-semibold text-highlighted">
Recently updated
</h2>
<RouterLink
to="/article"
class="text-toned underline underline-offset-3"
>
All articles
</RouterLink>
</div>
@@ -167,21 +189,35 @@ onMounted(loadArticles)
:key="n"
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
>
<USkeleton class="h-3 max-w-[60%] flex-1 rounded-none bg-accented" />
<USkeleton class="h-5 w-14 rounded-none bg-accented" />
<USkeleton
class="h-3 max-w-[60%] flex-1 rounded-none bg-accented"
/>
<USkeleton
class="h-5 w-14 rounded-none bg-accented"
/>
</div>
</div>
<ul v-else data-testid="recent-list" class="border border-default bg-default">
<ul
v-else
data-testid="recent-list"
class="border border-default bg-default"
>
<li v-for="article in recentArticles" :key="article.id">
<RouterLink
:to="`/article/${article.id}/edit`"
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
>
<span class="min-w-0 flex-1 truncate font-medium text-highlighted">
<span
class="min-w-0 flex-1 truncate font-medium text-highlighted"
>
{{ article.title }}
</span>
<ArticleStatusBadge :online="article.is_online" />
<span class="hidden w-27.5 text-right text-muted lg:inline">
<ArticleStatusBadge
:online="article.is_online"
/>
<span
class="hidden w-27.5 text-right text-muted lg:inline"
>
{{ formatRelativeDate(article.updated_at) }}
</span>
</RouterLink>
@@ -242,17 +278,21 @@ onMounted(loadArticles)
</template>
</UAlert>
<div class="flex items-start gap-3.5 border border-default bg-default p-4">
<div
class="flex items-start gap-3.5 border border-default bg-default p-4"
>
<div
class="flex size-9 shrink-0 items-center justify-center bg-secondary-200 text-primary-900 dark:bg-secondary-300"
>
<UIcon name="i-lucide-plug" class="size-4.5" />
</div>
<div class="flex flex-1 flex-col gap-1">
<h2 class="text-base/5 font-semibold text-highlighted">Connect your website</h2>
<h2 class="text-base/5 font-semibold text-highlighted">
Connect your website
</h2>
<p class="text-pretty text-muted">
Your site reads published articles from RenewCMS. Share this guide with
whoever builds it.
Your site reads published articles from RenewCMS. Share
this guide with whoever builds it.
</p>
<RouterLink
to="/integration"
+40 -12
View File
@@ -63,7 +63,8 @@ const passwordError = ref('')
const submitting = ref(false)
const passwordShown = ref(false)
const initialReason = typeof route.query.reason === 'string' ? route.query.reason : null
const initialReason =
typeof route.query.reason === 'string' ? route.query.reason : null
const alert = ref<AlertState | null>(
initialReason === 'expired'
@@ -73,7 +74,9 @@ const alert = ref<AlertState | null>(
: null,
)
const alertRole = computed(() => (alert.value?.tone === 'error' ? 'alert' : 'status'))
const alertRole = computed(() =>
alert.value?.tone === 'error' ? 'alert' : 'status',
)
const submitLabel = computed(() => {
if (submitting.value) {
@@ -85,7 +88,9 @@ const submitLabel = computed(() => {
return 'Log in'
})
const submitDisabled = computed(() => submitting.value || retryAfter.active.value)
const submitDisabled = computed(
() => submitting.value || retryAfter.active.value,
)
async function focusField(selector: string) {
await nextTick()
@@ -112,15 +117,18 @@ function focusFirstInvalid() {
}
function validate() {
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
usernameError.value =
username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value =
password.value.length === 0 ? 'Enter your password.' : ''
return !usernameError.value && !passwordError.value
}
async function afterLoginSuccess() {
await session.retry()
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : null
const redirect =
typeof route.query.redirect === 'string' ? route.query.redirect : null
if (redirect) {
await router.push(redirect)
return
@@ -166,7 +174,10 @@ async function onSubmit() {
await api.request('/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value }),
body: JSON.stringify({
username: username.value,
password: password.value,
}),
})
await afterLoginSuccess()
} catch (error) {
@@ -182,7 +193,9 @@ onMounted(async () => {
}
try {
const status = await api.request<{ open: boolean }>('/v1/auth/registration')
const status = await api.request<{ open: boolean }>(
'/v1/auth/registration',
)
registrationOpen.value = status.open
} catch {
registrationOpen.value = false
@@ -191,7 +204,10 @@ onMounted(async () => {
</script>
<template>
<AuthShell title="Log in" description="Welcome back. Log in to manage your articles.">
<AuthShell
title="Log in"
description="Welcome back. Log in to manage your articles."
>
<template v-if="alert" #notice>
<UAlert
:color="alert.tone"
@@ -205,7 +221,11 @@ onMounted(async () => {
</template>
<template #default="{ controlSize, controlClass, inputClass }">
<UForm :state="{ username, password }" class="flex flex-col gap-4.5" @submit="onSubmit">
<UForm
:state="{ username, password }"
class="flex flex-col gap-4.5"
@submit="onSubmit"
>
<UFormField
label="Username"
name="username"
@@ -275,13 +295,21 @@ onMounted(async () => {
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:icon="
passwordShown
? 'i-lucide-eye-off'
: 'i-lucide-eye'
"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-label="
passwordShown
? 'Hide password'
: 'Show password'
"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
+3 -3
View File
@@ -139,9 +139,9 @@ const actions = computed(() => [
:actions="actions"
>
<p class="text-sm text-pretty text-muted">
Opened it on another device? That works too; you'll log in there first. Can't find
it? Check your spam folder. If the link has expired, cancel your registration and
start again.
Opened it on another device? That works too; you'll log in there
first. Can't find it? Check your spam folder. If the link has
expired, cancel your registration and start again.
</p>
</ResultPanel>
</AuthShell>
+35 -10
View File
@@ -24,12 +24,16 @@ const passwordShown = ref(false)
const registrationClosed = ref(false)
const serverErrorAlert = ref(false)
const usernameValid = computed(() => username.value.length >= 3 && username.value.length <= 20)
const usernameValid = computed(
() => username.value.length >= 3 && username.value.length <= 20,
)
const emailValid = computed(() => EMAIL_PATTERN.test(email.value))
const passwordValid = computed(() => password.value.length >= 8)
const usernameError = computed(() =>
usernameTouched.value && !usernameValid.value ? 'Use 3 to 20 characters.' : '',
usernameTouched.value && !usernameValid.value
? 'Use 3 to 20 characters.'
: '',
)
const emailError = computed(() =>
@@ -38,9 +42,13 @@ const emailError = computed(() =>
: '',
)
const passwordRules = computed(() => [{ label: 'At least 8 characters', met: passwordValid.value }])
const passwordRules = computed(() => [
{ label: 'At least 8 characters', met: passwordValid.value },
])
const formValid = computed(() => usernameValid.value && emailValid.value && passwordValid.value)
const formValid = computed(
() => usernameValid.value && emailValid.value && passwordValid.value,
)
function touchAll() {
usernameTouched.value = true
@@ -85,7 +93,9 @@ async function onSubmit() {
}
}
const pageTitle = computed(() => (registrationClosed.value ? '' : 'Create the owner account'))
const pageTitle = computed(() =>
registrationClosed.value ? '' : 'Create the owner account',
)
const pageDescription = computed(() =>
registrationClosed.value
@@ -142,7 +152,8 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
</span>
</template>
<template v-else #help>
Letters and numbers, 3 to 20 characters. You'll use it to log in.
Letters and numbers, 3 to 20 characters. You'll use it
to log in.
</template>
<template #hint>
<span
@@ -184,7 +195,9 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
{{ error }}
</span>
</template>
<template v-else #help> We'll send a verification link here. </template>
<template v-else #help>
We'll send a verification link here.
</template>
<UInput
v-model="email"
type="email"
@@ -201,7 +214,11 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
/>
</UFormField>
<UFormField label="Password" name="password" :size="controlSize">
<UFormField
label="Password"
name="password"
:size="controlSize"
>
<template #help>
<PasswordStrengthHint
:rules="passwordRules"
@@ -225,13 +242,21 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:icon="
passwordShown
? 'i-lucide-eye-off'
: 'i-lucide-eye'
"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-label="
passwordShown
? 'Hide password'
: 'Show password'
"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
+5 -1
View File
@@ -10,5 +10,9 @@ function onRetry() {
</script>
<template>
<ErrorView v-if="session.status === 'error'" kind="error" @retry="onRetry" />
<ErrorView
v-if="session.status === 'error'"
kind="error"
@retry="onRetry"
/>
</template>
+36 -10
View File
@@ -70,7 +70,9 @@ const passwordShown = ref(false)
const loginSubmitting = ref(false)
const loginAlert = ref<AlertState | null>(null)
const shellTitle = computed(() => (state.value === 'noSession' ? 'Log in to finish verifying' : ''))
const shellTitle = computed(() =>
state.value === 'noSession' ? 'Log in to finish verifying' : '',
)
const shellDescription = computed(() =>
state.value === 'noSession'
? "You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after."
@@ -80,7 +82,9 @@ const shellDescription = computed(() =>
const usernameError = ref('')
const passwordError = ref('')
const loginSubmitDisabled = computed(() => loginSubmitting.value || retryAfter.active.value)
const loginSubmitDisabled = computed(
() => loginSubmitting.value || retryAfter.active.value,
)
const loginSubmitLabel = computed(() => {
if (loginSubmitting.value) {
@@ -95,7 +99,11 @@ const loginSubmitLabel = computed(() => {
async function focusPassword() {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')?.focus()
document
.querySelector<HTMLInputElement>(
'input[autocomplete="current-password"]',
)
?.focus()
}
async function performVerify(code: string) {
@@ -152,8 +160,10 @@ function handleLoginError(error: unknown) {
}
function validateLogin() {
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
usernameError.value =
username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
passwordError.value =
password.value.length === 0 ? 'Enter your password.' : ''
return !usernameError.value && !passwordError.value
}
@@ -170,7 +180,10 @@ async function onLoginSubmit() {
await api.request('/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.value, password: password.value }),
body: JSON.stringify({
username: username.value,
password: password.value,
}),
})
const code = typeof route.query.c === 'string' ? route.query.c : ''
await retryVerifying(code)
@@ -281,8 +294,13 @@ onMounted(() => {
role="status"
class="flex flex-col items-center gap-4 py-10 text-center"
>
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-highlighted" />
<p class="text-xl/7 font-semibold text-highlighted">Verifying your account…</p>
<UIcon
name="i-lucide-loader-2"
class="size-8 animate-spin text-highlighted"
/>
<p class="text-xl/7 font-semibold text-highlighted">
Verifying your account…
</p>
<p class="text-base text-muted">This only takes a moment.</p>
</div>
@@ -384,13 +402,21 @@ onMounted(() => {
>
<template #trailing>
<UButton
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:icon="
passwordShown
? 'i-lucide-eye-off'
: 'i-lucide-eye'
"
variant="ghost"
color="neutral"
size="sm"
square
class="size-8 justify-center text-toned"
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
:aria-label="
passwordShown
? 'Hide password'
: 'Show password'
"
:aria-pressed="passwordShown"
@click="passwordShown = !passwordShown"
/>
+709
View File
@@ -0,0 +1,709 @@
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 ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
const FIXED_NOW = '2026-09-26T11:02:00Z'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name: string) => headers[name] ?? 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 articleFixture(overrides: Partial<Article> = {}): Article {
return {
id: 12,
title: 'How we price a bathroom renovation',
body: 'a'.repeat(4318),
images: [
{ id: 1, url: '/images/1.png' },
{ id: 2, url: '/images/2.png' },
{ id: 3, url: '/images/3.png' },
],
is_online: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: FIXED_NOW,
...overrides,
}
}
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', () => ({
useOverlay: () => overlayMock,
useToast: () => ({ add: toastAdd }),
}))
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountEditor(
handlers: Record<string, () => Response | Promise<Response>>,
path = '/article/12/edit',
) {
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(path)
const wrapper = mount(RootComponent, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
function saveButton(wrapper: Awaited<ReturnType<typeof mount>>) {
return wrapper.find('[data-testid="save-button"]')
}
function articleEditorBody(wrapper: Awaited<ReturnType<typeof mount>>) {
return wrapper.findComponent(ArticleEditor).props('modelValue')
}
async function typeBody(
wrapper: Awaited<ReturnType<typeof mount>>,
body: string,
) {
await wrapper
.findComponent(ArticleEditor)
.vm.$emit('update:modelValue', body)
await flushPromises()
}
function paragraph(inner: string) {
return `<p>${inner}</p>`
}
function paragraphOfLength(totalLength: number) {
return paragraph('a'.repeat(totalLength - 7))
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(FIXED_NOW))
})
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
vi.useRealTimers()
sessionStorage.clear()
const onLineSpy = vi.spyOn(navigator, 'onLine', 'get')
onLineSpy.mockReturnValue(true)
window.dispatchEvent(new Event('online'))
onLineSpy.mockRestore()
await router.push('/login')
})
describe('Skeletons appear only after 150 ms, never for a fast response', () => {
it('shows no skeleton before 150 ms, a skeleton after, then the Saved state once resolved', async () => {
let resolveGet: (() => void) | null = null
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
new Promise((resolve) => {
resolveGet = () =>
resolve(mockResponse(200, articleFixture()))
}),
})
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
await vi.advanceTimersByTimeAsync(150)
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
true,
)
expect(wrapper.find('[data-testid="skeleton-content"]').exists()).toBe(
true,
)
expect(wrapper.find('[data-testid="skeleton-details"]').exists()).toBe(
true,
)
resolveGet?.()
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
expect(wrapper.text()).toContain('How we price a bathroom renovation')
})
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
Promise.resolve(mockResponse(200, articleFixture())),
})
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.find('[data-testid="skeleton-title"]').exists()).toBe(
false,
)
expect(wrapper.text()).toContain('How we price a bathroom renovation')
})
})
describe('Saved — a freshly loaded article, draft or online', () => {
it.each([
{ is_online: false, badge: 'Draft', publishLabel: 'Publish' },
{ is_online: true, badge: 'Online', publishLabel: 'Unpublish' },
])(
'reads the Saved state for is_online $is_online',
async ({ is_online, badge, publishLabel }) => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
mockResponse(200, articleFixture({ is_online })),
})
expect(wrapper.text()).toContain('Articles')
expect(wrapper.text()).toContain(
'How we price a bathroom renovation',
)
expect(wrapper.text()).toContain(badge)
expect(wrapper.text()).toContain('Saved at 11:02')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
expect(wrapper.text()).toContain(publishLabel)
expect(wrapper.text()).toContain('4 318 / 10 000')
},
)
})
describe('Unsaved changes — any edit marks the article dirty', () => {
it('shows Unsaved changes, enables Save and switches Publish to Save and publish', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
expect(wrapper.text()).toContain('Unsaved changes')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect(wrapper.text()).toContain('Save and publish')
})
})
describe('Saving — Save sends the PATCH and disables itself while in flight', () => {
it('sends the edited body and shows Saving…', async () => {
let patchBody: string | null = null
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
global.fetch = vi.fn((url: string, init?: RequestInit) => {
if (routeFor(url, init) === 'PATCH /v1/article/12') {
patchBody = init?.body as string
return new Promise(() => {})
}
throw new Error(`Unexpected fetch call: ${routeFor(url, init)}`)
}) as unknown as typeof fetch
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(JSON.parse(patchBody ?? '{}')).toEqual({
body: paragraphOfLength(4320),
})
expect(wrapper.text()).toContain('Saving…')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
})
})
describe('Ctrl/⌘ S saves the same way as clicking Save', () => {
it('sends the same PATCH on Ctrl+S', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
let patchBody: string | null = null
global.fetch = vi.fn((url: string, init?: RequestInit) => {
if (routeFor(url, init) === 'PATCH /v1/article/12') {
patchBody = init?.body as string
return new Promise(() => {})
}
throw new Error(`Unexpected fetch call: ${routeFor(url, init)}`)
}) as unknown as typeof fetch
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 's', ctrlKey: true }),
)
await flushPromises()
expect(JSON.parse(patchBody ?? '{}')).toEqual({
body: paragraphOfLength(4320),
})
expect(wrapper.text()).toContain('Saving…')
})
})
describe('A successful save returns to the Saved state with the new time', () => {
it('shows Saved at the current time and disables Save again', async () => {
vi.setSystemTime(new Date('2026-09-26T11:30:00Z'))
let resolvePatch: (() => void) | null = null
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () =>
new Promise((resolve) => {
resolvePatch = () =>
resolve(
mockResponse(
200,
articleFixture({
body: paragraphOfLength(4320),
}),
),
)
}),
})
await typeBody(wrapper, paragraphOfLength(4320))
await saveButton(wrapper).trigger('click')
await flushPromises()
resolvePatch?.()
await flushPromises()
expect(wrapper.text()).toContain('Saved at 11:30')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
})
})
describe('SaveStatus switches to a relative time after an hour', () => {
it('reads "Saved n min ago" once more than an hour has passed', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
expect(wrapper.text()).toContain('Saved at 11:02')
vi.setSystemTime(new Date('2026-09-26T12:32:00Z'))
await vi.advanceTimersByTimeAsync(20000)
await flushPromises()
expect(wrapper.text()).toContain('Saved 90 min ago')
})
})
describe('Save failed — a 500 or a network failure keeps the changes 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 Save failed with Retry for $name', async ({ handler }) => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': handler,
})
await typeBody(wrapper, paragraph('Revised opening paragraph.'))
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Save failed')
expect(
wrapper
.findAll('button')
.some((button) => button.text() === 'Retry'),
).toBe(true)
expect(articleEditorBody(wrapper)).toBe(
paragraph('Revised opening paragraph.'),
)
})
})
describe('Retry resends the same body and can recover', () => {
it('resends the same PATCH and shows Saved at the current time', async () => {
const patchCall = vi
.fn()
.mockResolvedValueOnce(mockResponse(500))
.mockResolvedValueOnce(
mockResponse(
200,
articleFixture({
body: paragraph('Revised opening paragraph.'),
}),
),
)
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': patchCall,
})
await typeBody(wrapper, paragraph('Revised opening paragraph.'))
await saveButton(wrapper).trigger('click')
await flushPromises()
const retryButton = wrapper
.findAll('button')
.find((button) => button.text() === 'Retry')
await retryButton?.trigger('click')
await flushPromises()
expect(patchCall).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toMatch(/Saved at \d{2}:\d{2}/)
})
})
describe('A save against an article deleted elsewhere shows a toast, not the error SaveStatus', () => {
it('shows the toast and never reads Save failed', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () => mockResponse(404),
})
await typeBody(wrapper, paragraphOfLength(4320))
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
title: 'That article no longer exists.',
}),
)
expect(wrapper.text()).not.toContain('Save failed')
})
})
describe('Near the limit — the counter warns at 9000 characters without blocking Save', () => {
it('shows the warning icon and keeps Save enabled', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(9240))
expect(wrapper.text()).toContain('9 240 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(true)
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
})
})
describe('Too long — over 10000 characters disables Save and Publish until shortened', () => {
it('shows the over-limit text and disables Save and Publish, then re-enables once shortened', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(10214))
expect(wrapper.text()).toContain('10 214 / 10 000')
expect(wrapper.text()).toContain(
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
)
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
const publishButton = wrapper
.findAll('button')
.find(
(button) =>
button.text() === 'Publish' ||
button.text() === 'Save and publish',
)
expect((publishButton?.element as HTMLButtonElement).disabled).toBe(
true,
)
await typeBody(wrapper, paragraphOfLength(9000))
expect(wrapper.text()).not.toContain('characters over')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
})
})
describe('Preview shows the rendered title and body without the editing surface', () => {
it('replaces the editor with a read-only rendering, then restores it unchanged', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () =>
mockResponse(200, articleFixture({ body: '<p>Some body</p>' })),
})
const previewSwitch = wrapper.find('button[role="switch"]')
await previewSwitch.trigger('click')
await flushPromises()
expect(wrapper.findComponent(ArticleEditor).exists()).toBe(false)
expect(
wrapper.find('[data-testid="article-preview"]').text(),
).toContain('How we price a bathroom renovation')
expect(
wrapper.find('[data-testid="article-preview"]').html(),
).toContain('Some body')
await previewSwitch.trigger('click')
await flushPromises()
expect(wrapper.findComponent(ArticleEditor).exists()).toBe(true)
expect(articleEditorBody(wrapper)).toBe('<p>Some body</p>')
})
})
describe('Offline blocks Save and Publish, and recovers when the connection returns', () => {
it('shows the offline SaveStatus and re-enables Save once back online', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
const onLineSpy = vi
.spyOn(navigator, 'onLine', 'get')
.mockReturnValue(false)
window.dispatchEvent(new Event('offline'))
await flushPromises()
expect(wrapper.text()).toContain('Waiting for connection')
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(true)
onLineSpy.mockReturnValue(true)
window.dispatchEvent(new Event('online'))
await flushPromises()
expect(
(saveButton(wrapper).element as HTMLButtonElement).disabled,
).toBe(false)
expect(wrapper.text()).toContain('Unsaved changes')
onLineSpy.mockRestore()
})
})
describe('Leaving with unsaved changes prompts, and "Discard changes" proceeds', () => {
it('opens ConfirmDialog with the exact copy and navigates away on Discard changes', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
expect(articlesLink).toBeTruthy()
const navigation = router.push('/article')
await flushPromises()
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Leave without saving?')
expect(props.description).toBe(
'Your changes to "How we price a bathroom renovation" haven\'t been saved.',
)
expect(props.cancelLabel).toBe('Discard changes')
expect(props.confirmLabel).toBe('Keep editing')
expect(props.dismissResolvesTo).toBe('confirm')
overlayMock.resolveOpen(false)
await navigation
expect(router.currentRoute.value.path).toBe('/article')
})
})
describe('Leaving with unsaved changes, "Keep editing" stays on the page', () => {
it('keeps the user on the editor with the unsaved body intact', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
expect(articlesLink).toBeTruthy()
const navigation = router.push('/article')
await flushPromises()
overlayMock.resolveOpen(true)
await navigation
expect(router.currentRoute.value.path).toBe('/article/12/edit')
expect(articleEditorBody(wrapper)).toBe(paragraphOfLength(4320))
})
})
describe('Esc or the overlay on the leave guard behaves like "Keep editing"', () => {
it('keeps the user on /article/12/edit with the unsaved body intact', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
await typeBody(wrapper, paragraphOfLength(4320))
const navigation = router.push('/article')
await flushPromises()
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.dismissResolvesTo).toBe('confirm')
overlayMock.resolveOpen(true)
await navigation
expect(router.currentRoute.value.path).toBe('/article/12/edit')
expect(articleEditorBody(wrapper)).toBe(paragraphOfLength(4320))
})
})
describe('Leaving a clean article never prompts', () => {
it('navigates directly with no ConfirmDialog', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
})
const articlesLink = wrapper
.findAll('a')
.find((a) => a.attributes('href') === '/article')
await articlesLink?.trigger('click')
await flushPromises()
expect(overlayMock.getProps()).toBeNull()
expect(router.currentRoute.value.path).toBe('/article')
})
})
describe('Session expired mid-save keeps the draft and restores it after logging back in', () => {
it('mirrors the body to sessionStorage and restores it on the next mount', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12': () => mockResponse(401),
})
const body = paragraph(
'Paragraph added just before the session expired.',
)
await typeBody(wrapper, body)
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBe(body)
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.redirect).toBe(
'/article/12/edit',
)
expect(router.currentRoute.value.query.reason).toBe('expired')
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/12': () =>
mockResponse(
200,
articleFixture({
body: 'Last saved body without that paragraph.',
}),
),
}) as unknown as typeof fetch
await router.push('/article/12/edit')
const secondWrapper = mount(RootComponent, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
await flushPromises()
expect(articleEditorBody(secondWrapper)).toBe(body)
expect(secondWrapper.text()).toContain('Unsaved changes')
secondWrapper.unmount()
})
})
describe('An unknown or deleted article id replaces the editor with an error state', () => {
it('shows the not-found error state and navigates to /article', async () => {
const wrapper = await mountEditor(
{
'GET /v1/article/9999': () => mockResponse(404),
},
'/article/9999/edit',
)
expect(wrapper.text()).toContain("This article doesn't exist.")
expect(wrapper.find('[data-testid="save-button"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('Publish')
const allArticlesButton = wrapper
.findAll('button')
.find((button) => button.text() === 'All articles')
await allArticlesButton?.trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/article')
})
})
+25 -8
View File
@@ -7,7 +7,11 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import HomeView from '../HomeView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return {
ok: status >= 200 && status < 300,
status,
@@ -21,7 +25,9 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -91,7 +97,9 @@ const sixArticles: Article[] = [
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountHome(handlers: Record<string, () => Response | Promise<Response>>) {
async function mountHome(
handlers: Record<string, () => Response | Promise<Response>>,
) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
@@ -149,7 +157,8 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
vi.useFakeTimers()
const wrapper = await mountHome({
'GET /v1/article/': () => Promise.resolve(mockResponse(200, sixArticles)),
'GET /v1/article/': () =>
Promise.resolve(mockResponse(200, sixArticles)),
})
await vi.advanceTimersByTimeAsync(0)
@@ -166,7 +175,9 @@ describe('Default — the list loaded with a mix of online and draft articles',
'GET /v1/article/': () => mockResponse(200, sixArticles),
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
expect(wrapper.text()).toContain(
"Here's where things stand on your site.",
)
const rows = wrapper.find('[data-testid="recent-list"]').findAll('li')
expect(rows).toHaveLength(5)
@@ -185,7 +196,9 @@ describe('Default — the list loaded with a mix of online and draft articles',
expect(wrapper.text()).toContain('2')
expect(wrapper.text()).toContain('drafts')
const guideLink = wrapper.findAll('a').find((a) => a.text() === 'Open the guide')
const guideLink = wrapper
.findAll('a')
.find((a) => a.text() === 'Open the guide')
expect(guideLink?.attributes('href')).toBe('/integration')
await findButton(wrapper, 'New article')?.trigger('click')
@@ -226,7 +239,9 @@ describe('Error — a 500 or a network failure shows the same inline alert', ()
'GET /v1/article/': handler,
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
expect(wrapper.text()).toContain(
"Here's where things stand on your site.",
)
expect(wrapper.text()).toContain('Good')
const alert = wrapper.find('[role="alert"]')
expect(alert.exists()).toBe(true)
@@ -267,7 +282,9 @@ describe('The list is fetched exactly once per navigation', () => {
expect(listCall).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('6')
expect(wrapper.find('[data-testid="recent-list"]').findAll('li')).toHaveLength(5)
expect(
wrapper.find('[data-testid="recent-list"]').findAll('li'),
).toHaveLength(5)
})
})
+96 -37
View File
@@ -5,7 +5,11 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import LoginView from '../LoginView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return {
ok: status >= 200 && status < 300,
status,
@@ -19,7 +23,9 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -59,7 +65,9 @@ async function fillAndSubmit(
password: string,
) {
await wrapper.find('input[placeholder="Your username"]').setValue(username)
await wrapper.find('input[autocomplete="current-password"]').setValue(password)
await wrapper
.find('input[autocomplete="current-password"]')
.setValue(password)
await wrapper.find('form').trigger('submit')
await flushPromises()
}
@@ -73,16 +81,21 @@ afterEach(async () => {
describe('Default — registration is closed', () => {
it('renders the default state with no create-account footer link', async () => {
const wrapper = await mountLogin('/login', {
'GET /v1/auth/registration': () => mockResponse(200, { open: false }),
'GET /v1/auth/registration': () =>
mockResponse(200, { open: false }),
})
expect(wrapper.find('h1').text()).toBe('Log in')
expect(wrapper.text()).toContain('Welcome back. Log in to manage your articles.')
expect(wrapper.text()).toContain(
'Welcome back. Log in to manage your articles.',
)
const usernameInput = wrapper.find('input[placeholder="Your username"]')
expect((usernameInput.element as HTMLInputElement).value).toBe('')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
@@ -93,7 +106,8 @@ describe('Default — registration is closed', () => {
describe('Fresh install — registration is open', () => {
it('renders the same fields plus the create-account footer link', async () => {
const wrapper = await mountLogin('/login', {
'GET /v1/auth/registration': () => mockResponse(200, { open: true }),
'GET /v1/auth/registration': () =>
mockResponse(200, { open: true }),
})
expect(wrapper.find('h1').text()).toBe('Log in')
@@ -129,18 +143,26 @@ describe('Submitting — fields and button are disabled while the request is in
}),
})
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
.disabled,
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).disabled,
).toBe(true)
expect(
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.disabled,
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).disabled,
).toBe(true)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Logging in…')
@@ -159,13 +181,19 @@ describe('Wrong credentials — the password is cleared and focused', () => {
await fillAndSubmit(wrapper, 'marta', 'wrong-password')
expect(wrapper.text()).toContain("That username and password don't match")
expect(wrapper.text()).toContain('Check both and try again. Passwords are case-sensitive.')
expect(wrapper.text()).toContain(
"That username and password don't match",
)
expect(wrapper.text()).toContain(
'Check both and try again. Passwords are case-sensitive.',
)
const usernameInput = wrapper.find('input[placeholder="Your username"]')
expect((usernameInput.element as HTMLInputElement).value).toBe('marta')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(passwordInput.element).toBe(document.activeElement)
@@ -179,20 +207,28 @@ describe('Too many attempts — the button is disabled with a live countdown', (
it('counts down from the Retry-After value and re-enables at zero', async () => {
vi.useFakeTimers()
const wrapper = await mountLogin('/login', {
'POST /v1/auth/login': () => mockResponse(429, null, { 'Retry-After': '42' }),
'POST /v1/auth/login': () =>
mockResponse(429, null, { 'Retry-After': '42' }),
})
await fillAndSubmit(wrapper, 'marta', 'limewash')
expect(wrapper.text()).toContain('Too many attempts')
expect(wrapper.text()).toContain('For your security, wait a moment before trying again.')
expect(wrapper.text()).toContain(
'For your security, wait a moment before trying again.',
)
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).value,
).toBe('marta')
expect(
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.value,
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).value,
).toBe('limewash')
const button = wrapper.find('button[type="submit"]')
@@ -200,8 +236,10 @@ describe('Too many attempts — the button is disabled with a live countdown', (
expect((button.element as HTMLButtonElement).disabled).toBe(true)
const fields = () => [
wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement,
wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement,
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement,
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement,
]
expect(fields().map((field) => field.disabled)).toEqual([true, true])
@@ -209,9 +247,10 @@ describe('Too many attempts — the button is disabled with a live countdown', (
await flushPromises()
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
false,
)
expect(
(wrapper.find('button[type="submit"]').element as HTMLButtonElement)
.disabled,
).toBe(false)
expect(fields().map((field) => field.disabled)).toEqual([false, false])
vi.useRealTimers()
})
@@ -219,14 +258,19 @@ describe('Too many attempts — the button is disabled with a live countdown', (
describe('Session expired — redirected from a 401 elsewhere', () => {
it('shows the session-expired alert and focuses the password field', async () => {
const wrapper = await mountLogin('/login?redirect=/article/12/edit&reason=expired', {})
const wrapper = await mountLogin(
'/login?redirect=/article/12/edit&reason=expired',
{},
)
expect(wrapper.text()).toContain('Your session expired')
expect(wrapper.text()).toContain(
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
)
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect(passwordInput.element).toBe(document.activeElement)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
})
@@ -247,7 +291,11 @@ describe('Password changed — arrived from a successful reset', () => {
describe('Server or network error — the fields keep their values', () => {
it.each([
{ name: 'a 500 response', password: 'limewash2026', handler: () => mockResponse(500) },
{
name: 'a 500 response',
password: 'limewash2026',
handler: () => mockResponse(500),
},
{
name: 'a network failure',
password: 'limewash2026',
@@ -270,20 +318,28 @@ describe('Server or network error — the fields keep their values', () => {
expect(login).toHaveBeenCalled()
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain('Check your connection and try again.')
expect(wrapper.text()).toContain(
'Check your connection and try again.',
)
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
.value,
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).value,
).toBe('marta')
expect(
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.value,
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).value,
).toBe(password)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Log in')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
expect((submitButton.element as HTMLButtonElement).disabled).toBe(
false,
)
},
)
})
@@ -302,12 +358,15 @@ describe('A successful log in navigates to the right destination', () => {
async ({ path, verified, destination }) => {
const wrapper = await mountLogin(path, {
'POST /v1/auth/login': () => mockResponse(200),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: verified }),
'GET /v1/auth/me': () =>
mockResponse(200, { is_verified: verified }),
})
await fillAndSubmit(wrapper, 'marta', 'limewash2026')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(destination))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe(destination),
)
},
)
})
+55 -29
View File
@@ -6,7 +6,11 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import PendingView from '../PendingView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return {
ok: status >= 200 && status < 300,
status,
@@ -20,7 +24,9 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -34,15 +40,17 @@ function fetchRouter(handlers: Record<string, () => Response | Promise<Response>
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
}),
}
})
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,
@@ -140,16 +148,24 @@ describe('Logging out', () => {
await findButton(wrapper, 'Log out')?.trigger('click')
await flushPromises()
expect((findButton(wrapper, 'Log out')?.element as HTMLButtonElement).disabled).toBe(true)
expect(
(findButton(wrapper, 'Cancel registration')?.element as HTMLButtonElement).disabled,
(findButton(wrapper, 'Log out')?.element as HTMLButtonElement)
.disabled,
).toBe(true)
expect(
(
findButton(wrapper, 'Cancel registration')
?.element as HTMLButtonElement
).disabled,
).toBe(true)
resolveLogout?.()
await flushPromises()
expect(session.session).toBeNull()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
})
})
@@ -229,7 +245,9 @@ describe('Cancelled — 200 navigates to /register with a toast', () => {
overlayMock.resolveOpen(true)
await flushPromises()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register'),
)
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -273,9 +291,12 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
})
expect(router.currentRoute.value.path).not.toBe('/home')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
})
await vi.waitFor(
() => expect(router.currentRoute.value.path).toBe('/home'),
{
timeout: 2000,
},
)
expect(toastAdd).toHaveBeenCalledTimes(1)
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
pushSpy.mockRestore()
@@ -289,18 +310,23 @@ describe('Cancel failed — the dialog stays open and shows the failure inline',
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('rejects the action instead of navigating for $name', async ({ handler }) => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': handler,
})
])(
'rejects the action instead of navigating for $name',
async ({ handler }) => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': handler,
})
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
await findButton(wrapper, 'Cancel registration')?.trigger('click')
await flushPromises()
const props = overlayMock.getProps() as { action: () => Promise<void> }
await expect(props.action()).rejects.toThrow()
const props = overlayMock.getProps() as {
action: () => Promise<void>
}
await expect(props.action()).rejects.toThrow()
expect(router.currentRoute.value.path).toBe('/register/pending')
expect(toastAdd).not.toHaveBeenCalled()
})
expect(router.currentRoute.value.path).toBe('/register/pending')
expect(toastAdd).not.toHaveBeenCalled()
},
)
})
+113 -54
View File
@@ -6,7 +6,11 @@ import router from '@/router'
import ResultPanel from '@/components/ResultPanel.vue'
import RegisterView from '../RegisterView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return {
ok: status >= 200 && status < 300,
status,
@@ -20,7 +24,9 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -33,7 +39,9 @@ function fetchRouter(handlers: Record<string, () => Response | Promise<Response>
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountRegister(handlers: Record<string, () => Response | Promise<Response>> = {}) {
async function mountRegister(
handlers: Record<string, () => Response | Promise<Response>> = {},
) {
setActivePinia(createPinia())
const fetchMock = fetchRouter(handlers)
global.fetch = fetchMock as unknown as typeof fetch
@@ -61,7 +69,9 @@ function passwordInput(wrapper: Awaited<ReturnType<typeof mountRegister>>) {
return wrapper.find('input[autocomplete="new-password"]')
}
async function fillValidForm(wrapper: Awaited<ReturnType<typeof mountRegister>>) {
async function fillValidForm(
wrapper: Awaited<ReturnType<typeof mountRegister>>,
) {
await usernameInput(wrapper).setValue('marta')
await emailInput(wrapper).setValue('marta@renew.example')
await passwordInput(wrapper).setValue('limewash2026')
@@ -82,15 +92,21 @@ describe('Default — a fresh, empty form', () => {
"This creates the single admin account for this site. Once it's verified, registration closes.",
)
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('')
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe(
'',
)
expect(wrapper.text()).toContain('0 / 20')
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('')
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('')
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe(
'',
)
expect(wrapper.text()).toContain('At least 8 characters')
expect(wrapper.find('button[type="submit"]').text()).toBe('Create account')
expect(wrapper.find('button[type="submit"]').text()).toBe(
'Create account',
)
expect(wrapper.text()).toContain('Already set up?')
const footerLink = wrapper.find('a[href="/login"]')
expect(footerLink.exists()).toBe(true)
@@ -116,14 +132,18 @@ describe('Validation errors — checked on blur and on submit', () => {
expect(wrapper.text()).toContain('2 / 20')
expect(wrapper.text()).toContain('Use 3 to 20 characters.')
expect(wrapper.text()).toContain('Enter a full email address, like name@example.com.')
expect(wrapper.text()).toContain(
'Enter a full email address, like name@example.com.',
)
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(register).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('Use 3 to 20 characters.')
expect(wrapper.text()).toContain('Enter a full email address, like name@example.com.')
expect(wrapper.text()).toContain(
'Enter a full email address, like name@example.com.',
)
})
})
@@ -136,9 +156,11 @@ describe('Filled, valid — every rule is met', () => {
const toggle = wrapper.find('button[aria-label="Show password"]')
await toggle.trigger('click')
expect(wrapper.find('button[aria-label="Hide password"]').attributes('aria-pressed')).toBe(
'true',
)
expect(
wrapper
.find('button[aria-label="Hide password"]')
.attributes('aria-pressed'),
).toBe('true')
expect(wrapper.text()).toContain('5 / 20')
expect(wrapper.text()).not.toContain('Use 3 to 20 characters.')
@@ -146,11 +168,17 @@ describe('Filled, valid — every rule is met', () => {
"Letters and numbers, 3 to 20 characters. You'll use it to log in.",
)
expect(wrapper.text()).not.toContain('Enter a full email address, like name@example.com.')
expect(wrapper.text()).not.toContain(
'Enter a full email address, like name@example.com.',
)
expect(wrapper.text()).toContain("We'll send a verification link here.")
expect((passwordInput(wrapper).element as HTMLInputElement).type).toBe('text')
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
expect((passwordInput(wrapper).element as HTMLInputElement).type).toBe(
'text',
)
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe(
'limewash2026',
)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Create account')
@@ -172,9 +200,15 @@ describe('Submitting — fields and button are disabled while the request is in
await wrapper.find('form').trigger('submit')
await flushPromises()
expect((usernameInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
expect((passwordInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
expect(
(usernameInput(wrapper).element as HTMLInputElement).disabled,
).toBe(true)
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(
true,
)
expect(
(passwordInput(wrapper).element as HTMLInputElement).disabled,
).toBe(true)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Creating account…')
@@ -221,7 +255,9 @@ describe('Registration closed — the form is replaced by a ResultPanel', () =>
expect(action.text()).toBe('Go to log in')
await action.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
})
})
@@ -232,44 +268,61 @@ describe('Server or network error — the form is kept and re-enabled', () => {
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the error alert and keeps the values for $name', async ({ handler }) => {
const register = vi.fn(handler)
const { wrapper, fetchMock } = await mountRegister({
'POST /v1/auth/register': register,
})
])(
'shows the error alert and keeps the values for $name',
async ({ handler }) => {
const register = vi.fn(handler)
const { wrapper, fetchMock } = await mountRegister({
'POST /v1/auth/register': register,
})
await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit')
await flushPromises()
await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
username: 'marta',
email: 'marta@renew.example',
password: 'limewash2026',
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
username: 'marta',
email: 'marta@renew.example',
password: 'limewash2026',
}),
}),
}),
)
expect(wrapper.text()).toContain("Couldn't create the account")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
)
expect(wrapper.text()).toContain("Couldn't create the account")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('marta')
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('marta@renew.example')
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
expect(
(usernameInput(wrapper).element as HTMLInputElement).value,
).toBe('marta')
expect(
(emailInput(wrapper).element as HTMLInputElement).value,
).toBe('marta@renew.example')
expect(
(passwordInput(wrapper).element as HTMLInputElement).value,
).toBe('limewash2026')
expect((usernameInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
expect((passwordInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
expect(
(usernameInput(wrapper).element as HTMLInputElement).disabled,
).toBe(false)
expect(
(emailInput(wrapper).element as HTMLInputElement).disabled,
).toBe(false)
expect(
(passwordInput(wrapper).element as HTMLInputElement).disabled,
).toBe(false)
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Create account')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
})
const submitButton = wrapper.find('button[type="submit"]')
expect(submitButton.text()).toBe('Create account')
expect((submitButton.element as HTMLButtonElement).disabled).toBe(
false,
)
},
)
})
describe('A successful registration goes straight to the pending page', () => {
@@ -282,7 +335,9 @@ describe('A successful registration goes straight to the pending page', () => {
await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
@@ -307,8 +362,12 @@ describe('A successful registration goes straight to the pending page', () => {
await fillValidForm(wrapper)
await wrapper.find('form').trigger('submit')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
expect(sessionStorage.getItem('renewcms:pendingEmail')).toBe('marta@renew.example')
expect(sessionStorage.getItem('renewcms:pendingEmail')).toBe(
'marta@renew.example',
)
})
})
+184 -81
View File
@@ -6,7 +6,11 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import VerifyView from '../VerifyView.vue'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
return {
ok: status >= 200 && status < 300,
status,
@@ -20,7 +24,9 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -75,15 +81,23 @@ afterEach(async () => {
describe('Verifying — the code submits automatically on load', () => {
it('shows the full-card spinner and calls verify once with the code', async () => {
const verify = vi.fn(() => new Promise(() => {}))
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': verify,
})
const { wrapper, fetchMock } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': verify,
},
)
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
expect(wrapper.find('[role="status"]').text()).toContain(
'Verifying your account…',
)
expect(verify).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ code: 'abc123' }) }),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ code: 'abc123' }),
}),
)
})
})
@@ -105,9 +119,12 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
description: 'Welcome to RenewCMS.',
})
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
})
await vi.waitFor(
() => expect(router.currentRoute.value.path).toBe('/home'),
{
timeout: 2000,
},
)
expect(toastAdd).toHaveBeenCalledTimes(1)
const session = useSessionStore()
@@ -135,7 +152,9 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
expect(wrapper.text()).toContain(
'Cancel your registration and start over to get a new link.',
)
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
expect(
findButton(wrapper, 'Cancel registration and start over'),
).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
@@ -146,19 +165,27 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
describe('Cancel registration and start over — success', () => {
it('sends DELETE with onForbidden passthrough, navigates to /register and toasts', async () => {
const deleteRegister = vi.fn(() => mockResponse(200))
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': deleteRegister,
})
const { wrapper, fetchMock } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': deleteRegister,
},
)
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
await findButton(
wrapper,
'Cancel registration and start over',
)?.trigger('click')
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({ method: 'DELETE' }),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register'),
)
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -170,31 +197,47 @@ describe('Cancel registration and start over — success', () => {
describe('Cancel registration and start over — failure shows an alert, not a redirect', () => {
it.each([
{ name: 'a 403 (already verified elsewhere)', handler: () => mockResponse(403) },
{
name: 'a 403 (already verified elsewhere)',
handler: () => mockResponse(403),
},
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the failure alert and keeps the panel for $name', async ({ handler }) => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': handler,
})
])(
'shows the failure alert and keeps the panel for $name',
async ({ handler }) => {
const { wrapper } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': handler,
},
)
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
await flushPromises()
await findButton(
wrapper,
'Cancel registration and start over',
)?.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain("Couldn't cancel registration")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
expect(wrapper.text()).toContain("This verification link doesn't work")
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(toastAdd).not.toHaveBeenCalled()
})
expect(wrapper.text()).toContain("Couldn't cancel registration")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
expect(wrapper.text()).toContain(
"This verification link doesn't work",
)
expect(
findButton(wrapper, 'Cancel registration and start over'),
).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(toastAdd).not.toHaveBeenCalled()
},
)
})
describe('Log out from the Invalid-or-expired state', () => {
@@ -211,7 +254,9 @@ describe('Log out from the Invalid-or-expired state', () => {
await flushPromises()
expect(session.session).toBeNull()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
})
})
@@ -225,9 +270,15 @@ describe('No session — the inline log-in form replaces the spinner', () => {
expect(wrapper.text()).toContain(
"You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after.",
)
expect(wrapper.find('input[placeholder="Your username"]').exists()).toBe(true)
expect(wrapper.find('input[autocomplete="current-password"]').exists()).toBe(true)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
expect(
wrapper.find('input[placeholder="Your username"]').exists(),
).toBe(true)
expect(
wrapper.find('input[autocomplete="current-password"]').exists(),
).toBe(true)
expect(wrapper.find('button[type="submit"]').text()).toBe(
'Log in and verify',
)
expect(wrapper.text()).not.toContain('Forgot password?')
expect(wrapper.text()).not.toContain('First time here?')
expect(router.currentRoute.value.path).toBe('/register/validate')
@@ -259,20 +310,32 @@ describe('No session — successful login retries verification with the same cod
const verify = vi.fn()
verify.mockResolvedValueOnce(mockResponse(401))
verify.mockImplementationOnce(() => new Promise(() => {}))
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': verify,
'POST /v1/auth/login': () => mockResponse(200),
})
const { wrapper, fetchMock } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': verify,
'POST /v1/auth/login': () => mockResponse(200),
},
)
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
expect(wrapper.find('[role="status"]').text()).toContain(
'Verifying your account…',
)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ code: 'abc123' }) }),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ code: 'abc123' }),
}),
)
expect(verify).toHaveBeenCalledTimes(2)
})
@@ -286,14 +349,24 @@ describe('No session — wrong credentials during the inline login', () => {
'POST /v1/auth/login': () => mockResponse(403),
})
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('wrong-password')
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('wrong-password')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain("That username and password don't match")
expect(wrapper.text()).toContain('Check both and try again. Passwords are case-sensitive.')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect(wrapper.text()).toContain(
"That username and password don't match",
)
expect(wrapper.text()).toContain(
'Check both and try again. Passwords are case-sensitive.',
)
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(passwordInput.element).toBe(document.activeElement)
expect(verify).toHaveBeenCalledTimes(1)
@@ -305,16 +378,23 @@ describe('No session — too many login attempts', () => {
vi.useFakeTimers()
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': () => mockResponse(429, null, { 'Retry-After': '30' }),
'POST /v1/auth/login': () =>
mockResponse(429, null, { 'Retry-After': '30' }),
})
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Too many attempts')
expect(wrapper.text()).toContain('For your security, wait a moment before trying again.')
expect(wrapper.text()).toContain(
'For your security, wait a moment before trying again.',
)
const button = wrapper.find('button[type="submit"]')
expect(button.text()).toBe('Try again in 0:30')
expect((button.element as HTMLButtonElement).disabled).toBe(true)
@@ -322,7 +402,9 @@ describe('No session — too many login attempts', () => {
await vi.advanceTimersByTimeAsync(30000)
await flushPromises()
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
expect(wrapper.find('button[type="submit"]').text()).toBe(
'Log in and verify',
)
})
})
@@ -333,31 +415,50 @@ describe('No session — server or network error during the inline login', () =>
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows the network/server alert and keeps the fields for $name', async ({ handler }) => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': handler,
})
])(
'shows the network/server alert and keeps the fields for $name',
async ({ handler }) => {
const { wrapper } = await mountVerify(
'/register/validate?c=abc123',
{
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': handler,
},
)
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain('Check your connection and try again.')
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
).toBe('marta')
expect(
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.value,
).toBe('limewash2026')
expect(
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
.disabled,
).toBe(false)
})
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain(
'Check your connection and try again.',
)
expect(
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).value,
).toBe('marta')
expect(
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).value,
).toBe('limewash2026')
expect(
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).disabled,
).toBe(false)
},
)
})
describe('Missing code — no request is made', () => {
@@ -374,6 +475,8 @@ describe('Missing code — no request is made', () => {
expect(action).toBeTruthy()
await action?.trigger('click')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
})
})
+8 -5
View File
@@ -206,12 +206,15 @@ async function captureDesign(
const section = [...document.querySelectorAll('section[data-screen-label]')].find(
(element) => element.getAttribute('data-screen-label') === group,
)
const title = section
? [...section.querySelectorAll('span')].find(
(span) => span.textContent?.trim() === state,
const headers = section
? [...section.querySelectorAll<HTMLElement>('div')].filter(
(div) => getComputedStyle(div).maxWidth === '1040px',
)
: undefined
const block = title?.closest('div')?.parentElement
: []
const title = headers.find(
(div) => div.querySelector('span')?.textContent?.trim() === state,
)
const block = title?.parentElement
const target = block
? [...block.querySelectorAll<HTMLElement>('div')].find(
(div) => div.style.width === `${width}px`,
+163
View File
@@ -153,6 +153,56 @@ const verifiedSession: ApiMock = {
body: { is_verified: true, username: 'marta' },
}
const editorNow = '2026-09-26T11:02:00Z'
const bathroomBody =
"<p>Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and <strong>write down what stays</strong>.</p>" +
'<h2>What goes into the price</h2>' +
'<ul><li>Stripping out and making good</li><li>Plumbing moves, if any</li><li>Tiles, fittings and a day for silicone</li></ul>' +
'<p>We don’t add a margin on materials you choose yourself.</p>'
const editorArticle = {
id: 12,
title: 'How we price a bathroom renovation',
body: bathroomBody,
images: [
{ id: 1, url: '/images/1.png' },
{ id: 2, url: '/images/2.png' },
{ id: 3, url: '/images/3.png' },
],
is_online: false,
created_at: '2026-03-12T09:14:00Z',
updated_at: editorNow,
}
function editorApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/12': { status: 200, body: editorArticle },
...overrides,
}
}
async function focusBoldSample(page: Page) {
await page.getByText('write down what stays').click()
}
async function markDirty(page: Page) {
await page.getByText('choose yourself').click()
await page.keyboard.press('End')
await page.keyboard.type('x')
await page.keyboard.press('Backspace')
}
async function typeExactBodyLength(page: Page, totalLength: number) {
const editable = page.locator('[contenteditable="true"]')
await editable.click()
await page.keyboard.press('Control+a')
await page.keyboard.press('Delete')
const inner = 'a'.repeat(Math.max(0, totalLength - 7))
await page.keyboard.insertText(inner)
}
export const visualStates: Record<string, Record<string, VisualState>> = {
home: {
Default: {
@@ -319,6 +369,119 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
},
},
editor: {
Loading: {
path: '/article/12/edit',
now: editorNow,
api: editorApi({
'GET /v1/article/12': { status: 200, body: editorArticle, hang: true },
}),
},
Saved: {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await focusBoldSample(page)
},
},
'Unsaved changes': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await focusBoldSample(page)
},
},
Saving: {
path: '/article/12/edit',
now: editorNow,
api: editorApi({
'PATCH /v1/article/12': { status: 200, body: editorArticle, hang: true },
}),
steps: async (page) => {
await markDirty(page)
await focusBoldSample(page)
await page.getByRole('button', { name: 'Save', exact: true }).click()
},
},
'Save failed': {
path: '/article/12/edit',
now: editorNow,
api: editorApi({ 'PATCH /v1/article/12': { status: 500 } }),
steps: async (page) => {
await markDirty(page)
await page.getByRole('button', { name: 'Save', exact: true }).click()
await page.getByText('Save failed').waitFor()
await focusBoldSample(page)
},
},
'Near the limit': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await typeExactBodyLength(page, 9240)
},
},
'Too long': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await typeExactBodyLength(page, 10214)
},
},
Preview: {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
const preview = page.getByRole('switch')
if (await preview.count()) {
await preview.click()
}
},
},
'Leave with unsaved changes': {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
paused: true,
steps: async (page) => {
await markDirty(page)
await focusBoldSample(page)
await page.getByRole('button', { name: 'Back to Articles' }).click()
await page.getByRole('alertdialog').waitFor()
},
},
Offline: {
path: '/article/12/edit',
now: editorNow,
api: editorApi(),
steps: async (page) => {
await markDirty(page)
await focusBoldSample(page)
await page.evaluate(() => {
Object.defineProperty(navigator, 'onLine', {
configurable: true,
get: () => false,
})
window.dispatchEvent(new Event('offline'))
})
await page.getByText('Waiting for connection').waitFor()
},
},
'Not found (404)': {
path: '/article/9999/edit',
now: editorNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/9999': { status: 404 },
},
},
},
verify: {
Verifying: {
path: '/register/validate?c=abc123',
+1
View File
@@ -7,6 +7,7 @@ export default mergeConfig(
defineConfig({
test: {
environment: 'jsdom',
env: { TZ: 'UTC' },
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},