feat: article editor core
This commit is contained in:
1 parent
d493a181e1
commit
a4734b2c91
55 files changed
+4186
-496
No files matched your search
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/ui": "^4.11.2",
|
"@nuxt/ui": "^4.11.2",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tiptap/core": "3.31.3",
|
||||||
"@vueuse/core": "14.4.0",
|
"@vueuse/core": "14.4.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
|||||||
+49
-48
@@ -1,50 +1,51 @@
|
|||||||
{
|
{
|
||||||
"name": "renewcms-frontend",
|
"name": "renewcms-frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "run-p type-check \"build-only {@}\" --",
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"build-only": "vite build",
|
"build-only": "vite build",
|
||||||
"type-check": "vue-tsc --build",
|
"type-check": "vue-tsc --build",
|
||||||
"lint": "eslint . --fix --cache",
|
"lint": "eslint . --fix --cache",
|
||||||
"format": "prettier --write --experimental-cli src/"
|
"format": "prettier --write --experimental-cli src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/ui": "^4.11.2",
|
"@nuxt/ui": "^4.11.2",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@vueuse/core": "14.4.0",
|
"@tiptap/core": "3.31.3",
|
||||||
"pinia": "^3.0.4",
|
"@vueuse/core": "14.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.25",
|
"tailwindcss": "^4.1.18",
|
||||||
"vue-router": "^4.6.3"
|
"vue": "^3.5.25",
|
||||||
},
|
"vue-router": "^4.6.3"
|
||||||
"devDependencies": {
|
},
|
||||||
"@iconify-json/lucide": "^1.2.136",
|
"devDependencies": {
|
||||||
"@iconify/utils": "^3.1.7",
|
"@iconify-json/lucide": "^1.2.136",
|
||||||
"@tsconfig/node24": "^24.0.3",
|
"@iconify/utils": "^3.1.7",
|
||||||
"@types/node": "^24.10.1",
|
"@tsconfig/node24": "^24.0.3",
|
||||||
"@vitejs/plugin-vue": "^6.0.2",
|
"@types/node": "^24.10.1",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vitejs/plugin-vue": "^6.0.2",
|
||||||
"@vue/eslint-config-typescript": "^14.6.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/test-utils": "^2.5.1",
|
"@vue/eslint-config-typescript": "^14.6.0",
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/test-utils": "^2.5.1",
|
||||||
"eslint": "^9.39.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"eslint-plugin-vue": "~10.5.1",
|
"eslint": "^9.39.1",
|
||||||
"jiti": "^2.6.1",
|
"eslint-plugin-vue": "~10.5.1",
|
||||||
"jsdom": "25",
|
"jiti": "^2.6.1",
|
||||||
"npm-run-all2": "^8.0.4",
|
"jsdom": "25",
|
||||||
"playwright": "^1.63.0",
|
"npm-run-all2": "^8.0.4",
|
||||||
"prettier": "3.6.2",
|
"playwright": "^1.63.0",
|
||||||
"typescript": "~5.9.0",
|
"prettier": "3.6.2",
|
||||||
"vite": "^7.2.4",
|
"typescript": "~5.9.0",
|
||||||
"vite-plugin-vue-devtools": "^8.0.5",
|
"vite": "^7.2.4",
|
||||||
"vitest": "^5.0.2",
|
"vite-plugin-vue-devtools": "^8.0.5",
|
||||||
"vue-tsc": "^3.1.5"
|
"vitest": "^5.0.2",
|
||||||
}
|
"vue-tsc": "^3.1.5"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+17
-1
@@ -112,7 +112,8 @@
|
|||||||
--breakpoint-lg: 64rem;
|
--breakpoint-lg: 64rem;
|
||||||
--breakpoint-xl: 80rem;
|
--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);
|
--shadow-modal: 0 16px 48px rgb(17 17 17 / 0.18);
|
||||||
|
|
||||||
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||||
@@ -260,9 +261,24 @@ body {
|
|||||||
.article-content li {
|
.article-content li {
|
||||||
margin: 0 0 0.375rem;
|
margin: 0 0 0.375rem;
|
||||||
}
|
}
|
||||||
|
.article-content li > p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
.article-content li::marker {
|
.article-content li::marker {
|
||||||
color: var(--ui-text-muted);
|
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 {
|
.article-content blockquote {
|
||||||
margin: 1.75rem 0;
|
margin: 1.75rem 0;
|
||||||
padding-left: 1.25rem;
|
padding-left: 1.25rem;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
withDefaults(
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
online: boolean
|
online: boolean
|
||||||
size?: 'sm' | 'md'
|
size?: 'sm' | 'md' | 'lg'
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
size: 'md',
|
size: 'md',
|
||||||
@@ -11,7 +11,13 @@ withDefaults(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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
|
Online
|
||||||
</UBadge>
|
</UBadge>
|
||||||
<UBadge
|
<UBadge
|
||||||
@@ -20,7 +26,7 @@ withDefaults(
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
icon="i-lucide-pencil-line"
|
icon="i-lucide-pencil-line"
|
||||||
:size="size"
|
:size="size"
|
||||||
class="px-2"
|
:class="size === 'lg' ? undefined : 'px-2'"
|
||||||
>
|
>
|
||||||
Draft
|
Draft
|
||||||
</UBadge>
|
</UBadge>
|
||||||
|
|||||||
@@ -33,18 +33,26 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
|
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
|
||||||
const controlClass = computed(() => (belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base'))
|
const controlClass = computed(() =>
|
||||||
const inputClass = computed(() => (belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base'))
|
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(() => [
|
const mainClass = computed(() => [
|
||||||
'flex min-h-screen flex-col items-center gap-5',
|
'flex min-h-screen flex-col items-center gap-5',
|
||||||
belowSm.value ? 'bg-default pt-10' : 'bg-muted pt-[12vh]',
|
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(() => ({
|
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
|
container: belowSm.value
|
||||||
? 'flex flex-col gap-6 px-5 pt-2 pb-0 lg:flex'
|
? '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',
|
: 'flex flex-col gap-6 p-10 sm:p-10 lg:flex',
|
||||||
@@ -102,7 +110,9 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="title" #title>
|
<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>
|
</template>
|
||||||
|
|
||||||
<slot v-if="slots.notice" name="notice" />
|
<slot v-if="slots.notice" name="notice" />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
title: string
|
defineProps<{
|
||||||
description: string
|
title: string
|
||||||
confirmLabel: string
|
description: string
|
||||||
cancelLabel: string
|
confirmLabel: string
|
||||||
pendingLabel: string
|
cancelLabel: string
|
||||||
tone: 'danger' | 'neutral'
|
pendingLabel: string
|
||||||
action: () => Promise<void>
|
tone: 'danger' | 'neutral'
|
||||||
}>()
|
action: () => Promise<void>
|
||||||
|
dismissResolvesTo?: 'cancel' | 'confirm'
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
dismissResolvesTo: 'cancel',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{ close: [confirmed: boolean] }>()
|
const emit = defineEmits<{ close: [confirmed: boolean] }>()
|
||||||
|
|
||||||
@@ -19,8 +25,12 @@ const error = ref('')
|
|||||||
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
|
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
|
||||||
let settled = false
|
let settled = false
|
||||||
|
|
||||||
const confirmColor = computed(() => (props.tone === 'danger' ? 'error' : 'primary'))
|
const confirmColor = computed(() =>
|
||||||
const confirmText = computed(() => (pending.value ? props.pendingLabel : props.confirmLabel))
|
props.tone === 'danger' ? 'error' : 'primary',
|
||||||
|
)
|
||||||
|
const confirmText = computed(() =>
|
||||||
|
pending.value ? props.pendingLabel : props.confirmLabel,
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -38,7 +48,7 @@ function settle(confirmed: boolean) {
|
|||||||
|
|
||||||
watch(open, (value) => {
|
watch(open, (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
settle(false)
|
settle(props.dismissResolvesTo === 'confirm')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -52,7 +62,10 @@ async function onConfirm() {
|
|||||||
await props.action()
|
await props.action()
|
||||||
settle(true)
|
settle(true)
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
pending.value = false
|
pending.value = false
|
||||||
}
|
}
|
||||||
@@ -73,13 +86,17 @@ function onCancel() {
|
|||||||
:description="description"
|
:description="description"
|
||||||
:dismissible="!pending"
|
:dismissible="!pending"
|
||||||
:close="false"
|
:close="false"
|
||||||
:content="{ role: 'alertdialog', 'aria-busy': pending ? 'true' : undefined }"
|
:content="{
|
||||||
|
role: 'alertdialog',
|
||||||
|
'aria-busy': pending ? 'true' : undefined,
|
||||||
|
}"
|
||||||
:ui="{
|
:ui="{
|
||||||
content:
|
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',
|
'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',
|
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',
|
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',
|
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',
|
footer: 'p-6 sm:px-6 max-sm:p-5',
|
||||||
}"
|
}"
|
||||||
@@ -95,7 +112,9 @@ function onCancel() {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<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
|
<UButton
|
||||||
ref="cancelButton"
|
ref="cancelButton"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
|
|||||||
@@ -9,18 +9,41 @@ import ErrorView from '@/components/ErrorView.vue'
|
|||||||
import OfflineBanner from '@/components/OfflineBanner.vue'
|
import OfflineBanner from '@/components/OfflineBanner.vue'
|
||||||
import UserMenu from '@/components/UserMenu.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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
const navItems = [
|
const navItems = computed(() =>
|
||||||
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
|
[
|
||||||
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
|
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
|
||||||
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
|
{ 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 sidebarCollapsed = ref(false)
|
||||||
const loggingOut = ref(false)
|
const loggingOut = ref(false)
|
||||||
|
|
||||||
@@ -42,7 +65,9 @@ async function onLogout() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UDashboardGroup unit="rem">
|
<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
|
<UDashboardSidebar
|
||||||
v-model:collapsed="sidebarCollapsed"
|
v-model:collapsed="sidebarCollapsed"
|
||||||
@@ -88,9 +113,7 @@ async function onLogout() {
|
|||||||
</template>
|
</template>
|
||||||
</UDashboardSidebar>
|
</UDashboardSidebar>
|
||||||
|
|
||||||
<UDashboardPanel
|
<UDashboardPanel :ui="{ body: bodyUi }">
|
||||||
: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' }"
|
|
||||||
>
|
|
||||||
<template #header>
|
<template #header>
|
||||||
<UDashboardNavbar
|
<UDashboardNavbar
|
||||||
:toggle="{ ui: { leadingIcon: 'size-5' } }"
|
:toggle="{ ui: { leadingIcon: 'size-5' } }"
|
||||||
@@ -115,12 +138,14 @@ async function onLogout() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="mx-1.5 hidden h-4 w-px bg-(--ui-border) lg:block"
|
class="mx-1.5 hidden h-4 w-px bg-(--ui-border) lg:block"
|
||||||
/>
|
/>
|
||||||
<UBreadcrumb
|
<slot name="breadcrumb">
|
||||||
:items="[{ label: pageTitle }]"
|
<UBreadcrumb
|
||||||
color="neutral"
|
:items="[{ label: routeTitle }]"
|
||||||
class="hidden lg:block"
|
color="neutral"
|
||||||
:ui="{ link: 'text-sm font-medium' }"
|
class="hidden lg:block"
|
||||||
/>
|
:ui="{ link: 'text-sm font-medium' }"
|
||||||
|
/>
|
||||||
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
<template #right>
|
<template #right>
|
||||||
<slot name="navbar-right" />
|
<slot name="navbar-right" />
|
||||||
@@ -129,7 +154,12 @@ async function onLogout() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #body>
|
<template #body>
|
||||||
<div id="dashboard-content">
|
<div
|
||||||
|
id="dashboard-content"
|
||||||
|
:class="
|
||||||
|
fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''
|
||||||
|
"
|
||||||
|
>
|
||||||
<OfflineBanner />
|
<OfflineBanner />
|
||||||
|
|
||||||
<AppBoot v-if="session.status === 'checking'" />
|
<AppBoot v-if="session.status === 'checking'" />
|
||||||
@@ -138,7 +168,10 @@ async function onLogout() {
|
|||||||
kind="error"
|
kind="error"
|
||||||
@retry="onRetry"
|
@retry="onRetry"
|
||||||
/>
|
/>
|
||||||
<div v-else>
|
<div
|
||||||
|
v-else
|
||||||
|
:class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''"
|
||||||
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
|||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
kind: 'not-found' | 'error'
|
kind: 'not-found' | 'error' | 'article-not-found'
|
||||||
breadcrumb?: string
|
breadcrumb?: string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
@@ -18,27 +18,40 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const copy = computed(() =>
|
const copy = computed(() => {
|
||||||
props.kind === 'not-found'
|
if (props.kind === 'not-found') {
|
||||||
? {
|
return {
|
||||||
title: 'Page not found',
|
title: 'Page not found',
|
||||||
text: 'The link may be mistyped, or the page may have moved.',
|
text: 'The link may be mistyped, or the page may have moved.',
|
||||||
primary: 'Go to Home',
|
primary: 'Go to Home',
|
||||||
secondary: 'All articles',
|
secondary: 'All articles',
|
||||||
}
|
}
|
||||||
: {
|
}
|
||||||
title: 'Something went wrong',
|
if (props.kind === 'article-not-found') {
|
||||||
text: "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
|
return {
|
||||||
primary: 'Try again',
|
title: "This article doesn't exist.",
|
||||||
secondary: 'Go to Home',
|
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() {
|
function onPrimary() {
|
||||||
if (props.kind === 'error') {
|
if (props.kind === 'error') {
|
||||||
emit('retry')
|
emit('retry')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (props.kind === 'article-not-found') {
|
||||||
|
router.push('/article')
|
||||||
|
return
|
||||||
|
}
|
||||||
router.push('/home')
|
router.push('/home')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,17 +65,30 @@ function onSecondary() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div role="alert" class="space-y-4 p-6">
|
<div role="alert" class="flex max-w-140 flex-col gap-3.5 p-8">
|
||||||
<nav aria-label="Breadcrumb">
|
<nav v-if="kind !== 'article-not-found'" aria-label="Breadcrumb">
|
||||||
<span>{{ breadcrumb }}</span>
|
<span>{{ breadcrumb }}</span>
|
||||||
</nav>
|
</nav>
|
||||||
<h1 tabindex="-1">{{ copy.title }}</h1>
|
<span
|
||||||
<p>{{ copy.text }}</p>
|
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">
|
<div class="flex gap-2">
|
||||||
<UButton @click="onPrimary">{{ copy.primary }}</UButton>
|
<UButton class="h-8" @click="onPrimary">{{ copy.primary }}</UButton>
|
||||||
<UButton variant="outline" color="neutral" @click="onSecondary">{{
|
<UButton
|
||||||
copy.secondary
|
v-if="copy.secondary"
|
||||||
}}</UButton>
|
variant="outline"
|
||||||
|
color="neutral"
|
||||||
|
class="h-8"
|
||||||
|
@click="onSecondary"
|
||||||
|
>{{ copy.secondary }}</UButton
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -16,16 +16,26 @@ function onCancel() {
|
|||||||
<template #body>
|
<template #body>
|
||||||
<UForm :state="{ title, body }" class="space-y-4">
|
<UForm :state="{ title, body }" class="space-y-4">
|
||||||
<UFormField label="Title" name="title">
|
<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>
|
||||||
<UFormField label="Body" name="body">
|
<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>
|
</UFormField>
|
||||||
</UForm>
|
</UForm>
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex w-full justify-end gap-2">
|
<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>
|
<UButton disabled>Create article</UButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -44,8 +44,15 @@ const items = computed(() =>
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ul class="flex flex-col gap-1.5">
|
<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]">
|
<li
|
||||||
<UIcon :name="item.icon" :class="['size-3.5 shrink-0', item.iconClass]" />
|
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="item.textClass">{{ item.label }}</span>
|
||||||
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
|
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useColorMode, type ColorModePreference } from '@/composables/useColorMode'
|
import {
|
||||||
|
useColorMode,
|
||||||
|
type ColorModePreference,
|
||||||
|
} from '@/composables/useColorMode'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
username: string
|
username: string
|
||||||
@@ -16,7 +19,9 @@ const colorMode = useColorMode()
|
|||||||
const initials = computed(() => {
|
const initials = computed(() => {
|
||||||
const parts = props.username.split(/[^a-zA-Z0-9]+/).filter(Boolean)
|
const parts = props.username.split(/[^a-zA-Z0-9]+/).filter(Boolean)
|
||||||
if (parts.length >= 2) {
|
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()
|
return (parts[0] ?? '').slice(0, 2).toUpperCase()
|
||||||
})
|
})
|
||||||
@@ -70,7 +75,8 @@ const items = computed(() => [
|
|||||||
size="sm"
|
size="sm"
|
||||||
:ui="{
|
:ui="{
|
||||||
root: 'size-6.5 bg-secondary-300',
|
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>
|
</template>
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ beforeEach(() => {
|
|||||||
resetAuthShellNavigation()
|
resetAuthShellNavigation()
|
||||||
})
|
})
|
||||||
|
|
||||||
function mountShell(props: Record<string, unknown>, slots: Record<string, unknown> = {}) {
|
function mountShell(
|
||||||
|
props: Record<string, unknown>,
|
||||||
|
slots: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
return mount(AuthShell, {
|
return mount(AuthShell, {
|
||||||
props,
|
props,
|
||||||
slots,
|
slots,
|
||||||
@@ -63,13 +66,17 @@ describe('AuthShell renders its notice and footer slots', () => {
|
|||||||
|
|
||||||
const notice = wrapper.find('span')
|
const notice = wrapper.find('span')
|
||||||
expect(notice.text()).toBe('Your session expired.')
|
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"]')
|
const card = wrapper.find('[data-testid="auth-card"]')
|
||||||
expect(card.element.contains(notice.element)).toBe(true)
|
expect(card.element.contains(notice.element)).toBe(true)
|
||||||
|
|
||||||
const html = wrapper.html()
|
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"]')
|
const footerLink = wrapper.find('a[href="/register/reset/request"]')
|
||||||
expect(footerLink.exists()).toBe(true)
|
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')
|
await clickButton('Cancel registration')
|
||||||
|
|
||||||
expect(action).toHaveBeenCalledTimes(1)
|
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…')).toBeTruthy()
|
||||||
expect((findButtonElement('Cancelling…') as HTMLButtonElement).disabled).toBe(true)
|
expect(
|
||||||
|
(findButtonElement('Cancelling…') as HTMLButtonElement).disabled,
|
||||||
|
).toBe(true)
|
||||||
expect(document.querySelector('[aria-busy="true"]')).toBeTruthy()
|
expect(document.querySelector('[aria-busy="true"]')).toBeTruthy()
|
||||||
|
|
||||||
resolveAction?.()
|
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', () => {
|
describe('Cancel failed — the dialog stays open and shows the failure inline', () => {
|
||||||
it('re-enables both buttons and shows the rejection message inline', async () => {
|
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)
|
const wrapper = mountDialog(action)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
await clickButton('Cancel registration')
|
await clickButton('Cancel registration')
|
||||||
|
|
||||||
expect(wrapper.emitted('close')).toBeFalsy()
|
expect(wrapper.emitted('close')).toBeFalsy()
|
||||||
expect(document.body.textContent).toContain("Couldn't cancel registration.")
|
expect(document.body.textContent).toContain(
|
||||||
expect((findButtonElement('Keep my account') as HTMLButtonElement).disabled).toBe(false)
|
"Couldn't cancel registration.",
|
||||||
expect((findButtonElement('Cancel registration') as HTMLButtonElement).disabled).toBe(false)
|
)
|
||||||
|
expect(
|
||||||
|
(findButtonElement('Keep my account') as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
(findButtonElement('Cancel registration') as HTMLButtonElement)
|
||||||
|
.disabled,
|
||||||
|
).toBe(false)
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -56,7 +56,9 @@ describe('DashboardShell renders the three navigation destinations', () => {
|
|||||||
|
|
||||||
describe('DashboardShell shows the offline banner when the browser reports offline', () => {
|
describe('DashboardShell shows the offline banner when the browser reports offline', () => {
|
||||||
it('shows the banner while offline and hides it once back online', async () => {
|
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')
|
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 wrapper = await mountShell('/home')
|
||||||
|
|
||||||
const header = wrapper.find('[data-slot="header"]')
|
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.text()).toContain('RenewCMS')
|
||||||
expect(header.find('a').exists()).toBe(false)
|
expect(header.find('a').exists()).toBe(false)
|
||||||
|
|
||||||
const headerHtml = header.html()
|
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')
|
await mountShell('/home')
|
||||||
|
|
||||||
const collapseButton = () =>
|
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')
|
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')
|
await mountShell('/home')
|
||||||
|
|
||||||
const logOutItem = () =>
|
const logOutItem = () =>
|
||||||
Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
|
Array.from(
|
||||||
(el) => el.textContent?.trim() === 'Log out',
|
document.body.querySelectorAll('[role="menuitem"]'),
|
||||||
) as HTMLElement
|
).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()
|
trigger.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -141,16 +151,28 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
|
|||||||
collapseButton.click()
|
collapseButton.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const header = document.body.querySelector('[data-slot="header"]') as HTMLElement
|
const header = document.body.querySelector(
|
||||||
expect(header.querySelector('svg[role="img"][aria-label="RenewCMS"]')).toBeTruthy()
|
'[data-slot="header"]',
|
||||||
|
) as HTMLElement
|
||||||
|
expect(
|
||||||
|
header.querySelector('svg[role="img"][aria-label="RenewCMS"]'),
|
||||||
|
).toBeTruthy()
|
||||||
expect(header.textContent).not.toContain('RenewCMS')
|
expect(header.textContent).not.toContain('RenewCMS')
|
||||||
expect(header.querySelector('span[aria-hidden="true"]')).toBeNull()
|
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')
|
expect(footer.textContent).not.toContain('marta')
|
||||||
|
|
||||||
const userMenuTrigger = footer.querySelector('button[aria-haspopup]') as HTMLButtonElement
|
const userMenuTrigger = footer.querySelector(
|
||||||
expect(userMenuTrigger.getAttribute('aria-label')).toBe('marta — account menu')
|
'button[aria-haspopup]',
|
||||||
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
) as HTMLButtonElement
|
||||||
|
expect(userMenuTrigger.getAttribute('aria-label')).toBe(
|
||||||
|
'marta — account menu',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
footer.querySelector('[data-slot="fallback"]')?.textContent,
|
||||||
|
).toBe('MA')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -18,7 +18,9 @@ async function mountNotFound(breadcrumb?: string) {
|
|||||||
const router = makeRouter()
|
const router = makeRouter()
|
||||||
await router.push('/')
|
await router.push('/')
|
||||||
return mount(ErrorView, {
|
return mount(ErrorView, {
|
||||||
props: breadcrumb ? { kind: 'not-found', breadcrumb } : { kind: 'not-found' },
|
props: breadcrumb
|
||||||
|
? { kind: 'not-found', breadcrumb }
|
||||||
|
: { kind: 'not-found' },
|
||||||
global: {
|
global: {
|
||||||
plugins: [router],
|
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 () => {
|
it('renders the not-found copy with a breadcrumb, primary and secondary actions', async () => {
|
||||||
const wrapper = await mountNotFound('Not found')
|
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.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')
|
const buttons = wrapper.findAll('button')
|
||||||
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(true)
|
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(
|
||||||
expect(buttons.some((button) => button.text() === 'All articles')).toBe(true)
|
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 () => {
|
it('routes the primary action to /home and the secondary action to /article', async () => {
|
||||||
const wrapper = await mountNotFound()
|
const wrapper = await mountNotFound()
|
||||||
const router = wrapper.vm.$router
|
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 primary?.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(router.currentRoute.value.path).toBe('/home')
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -28,7 +28,9 @@ function findMenuItemsByRole(role: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findCheckboxItem(label: string) {
|
function findCheckboxItem(label: string) {
|
||||||
return findMenuItemsByRole('menuitemcheckbox').find((el) => el.textContent?.trim() === label)
|
return findMenuItemsByRole('menuitemcheckbox').find(
|
||||||
|
(el) => el.textContent?.trim() === label,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -56,8 +58,8 @@ describe("UserMenu's dropdown lists the account label, color-mode checkboxes, th
|
|||||||
|
|
||||||
expect(groups[0].textContent).toContain('marta')
|
expect(groups[0].textContent).toContain('marta')
|
||||||
|
|
||||||
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map((el) =>
|
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map(
|
||||||
el.textContent?.trim(),
|
(el) => el.textContent?.trim(),
|
||||||
)
|
)
|
||||||
expect(checkboxLabels).toEqual(['System', 'Light', 'Dark'])
|
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 })
|
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||||
await openMenu()
|
await openMenu()
|
||||||
|
|
||||||
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe('true')
|
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe(
|
||||||
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe('false')
|
'true',
|
||||||
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe('false')
|
)
|
||||||
|
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe(
|
||||||
|
'false',
|
||||||
|
)
|
||||||
|
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe(
|
||||||
|
'false',
|
||||||
|
)
|
||||||
|
|
||||||
wrapper.unmount()
|
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 label = to.charAt(0).toUpperCase() + to.slice(1)
|
||||||
const fromLabel = from.charAt(0).toUpperCase() + from.slice(1)
|
const fromLabel = from.charAt(0).toUpperCase() + from.slice(1)
|
||||||
|
|
||||||
findCheckboxItem(label)?.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
findCheckboxItem(label)?.dispatchEvent(
|
||||||
findCheckboxItem(label)?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
new MouseEvent('pointerup', { bubbles: true }),
|
||||||
|
)
|
||||||
|
findCheckboxItem(label)?.dispatchEvent(
|
||||||
|
new MouseEvent('click', { bubbles: true }),
|
||||||
|
)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(colorMode.preference).toBe(to)
|
expect(colorMode.preference).toBe(to)
|
||||||
|
|
||||||
await openMenu()
|
await openMenu()
|
||||||
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe('true')
|
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe(
|
||||||
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe('false')
|
'true',
|
||||||
|
)
|
||||||
|
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe(
|
||||||
|
'false',
|
||||||
|
)
|
||||||
|
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
@@ -124,9 +140,9 @@ describe('A successful logout clears the session and navigates to /login', () =>
|
|||||||
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||||
await openMenu()
|
await openMenu()
|
||||||
|
|
||||||
const logOutItem = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
|
const logOutItem = Array.from(
|
||||||
(el) => el.textContent?.trim() === 'Log out',
|
document.body.querySelectorAll('[role="menuitem"]'),
|
||||||
)
|
).find((el) => el.textContent?.trim() === 'Log out')
|
||||||
;(logOutItem as HTMLElement)?.click()
|
;(logOutItem as HTMLElement)?.click()
|
||||||
await flushPromises()
|
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 })
|
const wrapper = mountMenu({ username: 'marta', collapsed: true })
|
||||||
|
|
||||||
expect(wrapper.text()).not.toContain('marta')
|
expect(wrapper.text()).not.toContain('marta')
|
||||||
expect(findTriggerButton()?.getAttribute('aria-label')).toBe('marta — account menu')
|
expect(findTriggerButton()?.getAttribute('aria-label')).toBe(
|
||||||
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
|
'marta — account menu',
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-slot="fallback"]')?.textContent,
|
||||||
|
).toBe('MA')
|
||||||
|
|
||||||
await openMenu()
|
await openMenu()
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
|||||||
|
|
||||||
async function mountLogin() {
|
async function mountLogin() {
|
||||||
setActivePinia(createPinia())
|
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')
|
await router.push('/login')
|
||||||
const wrapper = mount(LoginView, {
|
const wrapper = mount(LoginView, {
|
||||||
attachTo: document.body,
|
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)
|
expect(card.element.contains(svg.element)).toBe(true)
|
||||||
|
|
||||||
const html = wrapper.html()
|
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'))
|
expect(html.indexOf('<svg')).toBeLessThan(html.indexOf('<h1'))
|
||||||
|
|
||||||
let centeringAncestor = svg.element.parentElement
|
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.text()).toBe('Log in')
|
||||||
expect(heading.attributes('tabindex')).toBe('-1')
|
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)
|
expect(wrapper.findAll('p').length).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -175,8 +180,12 @@ describe('The login form is a UForm', () => {
|
|||||||
const form = wrapper.find('form')
|
const form = wrapper.find('form')
|
||||||
expect(form.exists()).toBe(true)
|
expect(form.exists()).toBe(true)
|
||||||
expect(form.attributes('id')).toMatch(/^v-/)
|
expect(form.attributes('id')).toMatch(/^v-/)
|
||||||
expect(form.find('input[placeholder="Your username"]').exists()).toBe(true)
|
expect(form.find('input[placeholder="Your username"]').exists()).toBe(
|
||||||
expect(form.find('input[autocomplete="current-password"]').exists()).toBe(true)
|
true,
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
form.find('input[autocomplete="current-password"]').exists(),
|
||||||
|
).toBe(true)
|
||||||
expect(form.find('button[type="submit"]').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 () => {
|
it('renders the link through the hint slot, and the label reads only Password', async () => {
|
||||||
const wrapper = await mountLogin()
|
const wrapper = await mountLogin()
|
||||||
|
|
||||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
const passwordInput = wrapper.find(
|
||||||
const fieldRoot = passwordInput.element.closest('[data-slot="root"][data-orientation]')
|
'input[autocomplete="current-password"]',
|
||||||
|
)
|
||||||
|
const fieldRoot = passwordInput.element.closest(
|
||||||
|
'[data-slot="root"][data-orientation]',
|
||||||
|
)
|
||||||
expect(fieldRoot).toBeTruthy()
|
expect(fieldRoot).toBeTruthy()
|
||||||
|
|
||||||
const hint = fieldRoot!.querySelector('[data-slot="hint"]')
|
const hint = fieldRoot!.querySelector('[data-slot="hint"]')
|
||||||
expect(hint).toBeTruthy()
|
expect(hint).toBeTruthy()
|
||||||
expect(hint!.textContent?.trim()).toBe('Forgot password?')
|
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"]')
|
const label = fieldRoot!.querySelector('[data-slot="label"]')
|
||||||
expect(label!.textContent?.trim()).toBe('Password')
|
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 wrapper = await mountLogin()
|
||||||
|
|
||||||
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
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()).toContain('ring-accented')
|
||||||
expect(usernameInput.classes()).not.toContain('ring-error')
|
expect(usernameInput.classes()).not.toContain('ring-error')
|
||||||
expect(passwordInput.classes()).toContain('ring-accented')
|
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()
|
await flushPromises()
|
||||||
|
|
||||||
const usernameAfter = wrapper.find('input[placeholder="Your username"]')
|
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()).toContain('ring-error')
|
||||||
expect(usernameAfter.classes()).not.toContain('ring-accented')
|
expect(usernameAfter.classes()).not.toContain('ring-accented')
|
||||||
expect(passwordAfter.classes()).toContain('ring-error')
|
expect(passwordAfter.classes()).toContain('ring-error')
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
|||||||
|
|
||||||
async function mountLogin(path = '/login') {
|
async function mountLogin(path = '/login') {
|
||||||
setActivePinia(createPinia())
|
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)
|
await router.push(path)
|
||||||
const wrapper = mount(LoginView, {
|
const wrapper = mount(LoginView, {
|
||||||
attachTo: document.body,
|
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 wrapper.find('form').trigger('submit')
|
||||||
await flushPromises()
|
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 usernameError = fieldRoots[0]!.find('[data-slot="error"]')
|
||||||
const passwordError = fieldRoots[1]!.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('ring-error')
|
||||||
expect(usernameInput.classes()).toContain('focus-visible:ring-error')
|
expect(usernameInput.classes()).toContain('focus-visible:ring-error')
|
||||||
expect(usernameInput.classes()).not.toContain('ring-primary')
|
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(card.element.contains(alert.element)).toBe(true)
|
||||||
expect(alert.text()).toContain(text)
|
expect(alert.text()).toContain(text)
|
||||||
|
|
||||||
const rootsInCard = card.findAll('[data-slot="root"][data-orientation]')
|
const rootsInCard = card.findAll(
|
||||||
expect(rootsInCard.some((root) => root.classes().includes('bg-primary'))).toBe(false)
|
'[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()
|
const html = card.html()
|
||||||
expect(html.indexOf('Welcome back')).toBeGreaterThan(-1)
|
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(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
|
||||||
expect(card.element.contains(usernameInput.element)).toBe(true)
|
expect(card.element.contains(usernameInput.element)).toBe(true)
|
||||||
|
|
||||||
expect(alert.classes().some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c))).toBe(
|
expect(
|
||||||
true,
|
alert
|
||||||
)
|
.classes()
|
||||||
|
.some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c)),
|
||||||
|
).toBe(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import router from '@/router'
|
|||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
|
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 {
|
return {
|
||||||
ok: status >= 200 && status < 300,
|
ok: status >= 200 && status < 300,
|
||||||
status,
|
status,
|
||||||
@@ -18,7 +22,10 @@ function setOnline(value: boolean) {
|
|||||||
Object.defineProperty(navigator, 'onLine', { configurable: true, value })
|
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.toBeInstanceOf(ApiError)
|
||||||
await expect(call).rejects.toMatchObject(matcher)
|
await expect(call).rejects.toMatchObject(matcher)
|
||||||
}
|
}
|
||||||
@@ -102,7 +109,9 @@ describe('useApi global 401/403/429/offline handling', () => {
|
|||||||
|
|
||||||
await expectApiError(call, { status: 403 })
|
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 })
|
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 () => {
|
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' })
|
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 () => {
|
it('A 429 with no Retry-After header exposes an undefined retryAfter', async () => {
|
||||||
fetchMock.mockResolvedValue(mockResponse(429))
|
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 })
|
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>',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -33,6 +33,7 @@ export interface RequestOptions extends RequestInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const isOffline = ref(false)
|
export const isOffline = ref(false)
|
||||||
|
export const authRedirectPending = ref(false)
|
||||||
|
|
||||||
function isNavigatorOnLine() {
|
function isNavigatorOnLine() {
|
||||||
return typeof navigator === 'undefined' || navigator.onLine !== false
|
return typeof navigator === 'undefined' || navigator.onLine !== false
|
||||||
@@ -60,8 +61,15 @@ if (typeof window !== 'undefined') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useApi() {
|
export function useApi() {
|
||||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
async function request<T>(
|
||||||
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
|
path: string,
|
||||||
|
options: RequestOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
onUnauthorized = 'redirect',
|
||||||
|
onForbidden = 'redirect',
|
||||||
|
...init
|
||||||
|
} = options
|
||||||
|
|
||||||
let response: Response
|
let response: Response
|
||||||
try {
|
try {
|
||||||
@@ -77,13 +85,24 @@ export function useApi() {
|
|||||||
if (response.status === 401 && onUnauthorized === 'redirect') {
|
if (response.status === 401 && onUnauthorized === 'redirect') {
|
||||||
const currentPath = router.currentRoute.value.fullPath
|
const currentPath = router.currentRoute.value.fullPath
|
||||||
useSessionStore().clear()
|
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)
|
throw new ApiError(401)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
|
if (
|
||||||
|
onForbidden !== 'passthrough' &&
|
||||||
|
session.session?.verified === false
|
||||||
|
) {
|
||||||
router.push('/register/pending')
|
router.push('/register/pending')
|
||||||
}
|
}
|
||||||
throw new ApiError(403)
|
throw new ApiError(403)
|
||||||
@@ -100,9 +119,12 @@ export function useApi() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getMe(): Promise<MeResult> {
|
async function getMe(): Promise<MeResult> {
|
||||||
const body = await request<{ is_verified: boolean; username?: string }>('/v1/auth/me', {
|
const body = await request<{ is_verified: boolean; username?: string }>(
|
||||||
onUnauthorized: 'passthrough',
|
'/v1/auth/me',
|
||||||
})
|
{
|
||||||
|
onUnauthorized: 'passthrough',
|
||||||
|
},
|
||||||
|
)
|
||||||
return { verified: Boolean(body.is_verified), username: body.username }
|
return { verified: Boolean(body.is_verified), username: body.username }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ export function useColorMode() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
get preference(): ColorModePreference {
|
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) {
|
set preference(value: ColorModePreference) {
|
||||||
store.value = value === 'system' ? 'auto' : value
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,38 +5,56 @@ const now = new Date('2026-08-25T12:00:00Z')
|
|||||||
|
|
||||||
describe('formatRelativeDate', () => {
|
describe('formatRelativeDate', () => {
|
||||||
it('formats under an hour in minutes', () => {
|
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"', () => {
|
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', () => {
|
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', () => {
|
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', () => {
|
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', () => {
|
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', () => {
|
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"', () => {
|
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', () => {
|
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',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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 () => {
|
it('The first navigation to a non-public route resolves the session and shows AppBoot meanwhile', async () => {
|
||||||
let resolveMe: (value: { verified: boolean }) => void = () => {}
|
let resolveMe: (value: { verified: boolean }) => void = () => {}
|
||||||
const getMe = vi.fn(
|
const getMe = vi.fn(
|
||||||
() => new Promise<{ verified: boolean }>((resolve) => (resolveMe = resolve)),
|
() =>
|
||||||
|
new Promise<{ verified: boolean }>(
|
||||||
|
(resolve) => (resolveMe = resolve),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||||
setUpSession()
|
setUpSession()
|
||||||
@@ -121,7 +124,9 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
|||||||
await router.push('/article')
|
await router.push('/article')
|
||||||
mountApp()
|
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 () => {
|
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')
|
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 primaryButton?.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -212,12 +219,16 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
|||||||
const app = mountApp()
|
const app = mountApp()
|
||||||
|
|
||||||
await vi.waitFor(() =>
|
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')
|
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 primaryButton?.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
@@ -240,10 +251,14 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const headings = app.findAll('h1')
|
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)
|
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)
|
expect(activeLink.exists()).toBe(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+13
-3
@@ -11,7 +11,11 @@ export type AuthRequirement = 'public' | 'session' | 'verified' | 'any'
|
|||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', component: () => import('@/views/RootView.vue'), meta: { auth: 'any' } },
|
{
|
||||||
|
path: '/',
|
||||||
|
component: () => import('@/views/RootView.vue'),
|
||||||
|
meta: { auth: 'any' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
component: () => import('@/views/LoginView.vue'),
|
component: () => import('@/views/LoginView.vue'),
|
||||||
@@ -94,7 +98,10 @@ export function resolveAuthRedirect(
|
|||||||
if (auth === 'any') {
|
if (auth === 'any') {
|
||||||
return { path: '/login' }
|
return { path: '/login' }
|
||||||
}
|
}
|
||||||
return { path: '/login', query: { redirect: to.fullPath, reason: 'expired' } }
|
return {
|
||||||
|
path: '/login',
|
||||||
|
query: { redirect: to.fullPath, reason: 'expired' },
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!account.verified) {
|
if (!account.verified) {
|
||||||
@@ -141,7 +148,10 @@ router.afterEach((to) => {
|
|||||||
: Promise.resolve()
|
: Promise.resolve()
|
||||||
|
|
||||||
settled.then(() => {
|
settled.then(() => {
|
||||||
const destination = resolveAuthRedirect(router.currentRoute.value, session)
|
const destination = resolveAuthRedirect(
|
||||||
|
router.currentRoute.value,
|
||||||
|
session,
|
||||||
|
)
|
||||||
if (destination !== true) {
|
if (destination !== true) {
|
||||||
router.replace(destination)
|
router.replace(destination)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ export const renewUi = {
|
|||||||
base: 'h-5.5 gap-1 px-1.75 py-0 text-xs',
|
base: 'h-5.5 gap-1 px-1.75 py-0 text-xs',
|
||||||
leadingIcon: 'size-3',
|
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: [
|
compoundVariants: [
|
||||||
{
|
{
|
||||||
color: ['primary', 'secondary', 'success', 'info', 'warning', 'neutral'],
|
color: [
|
||||||
|
'primary',
|
||||||
|
'secondary',
|
||||||
|
'success',
|
||||||
|
'info',
|
||||||
|
'warning',
|
||||||
|
'neutral',
|
||||||
|
],
|
||||||
class: { close: 'hidden' },
|
class: { close: 'hidden' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -250,10 +261,26 @@ export const renewUi = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
compoundVariants: [
|
compoundVariants: [
|
||||||
{ color: 'success', variant: 'soft', class: { root: 'bg-(--rc-success-soft)' } },
|
{
|
||||||
{ color: 'info', variant: 'soft', class: { root: 'bg-(--rc-info-soft)' } },
|
color: 'success',
|
||||||
{ color: 'warning', variant: 'soft', class: { root: 'bg-(--rc-warning-soft)' } },
|
variant: 'soft',
|
||||||
{ color: 'error', variant: 'soft', class: { root: 'bg-(--rc-error-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: {
|
card: {
|
||||||
|
|||||||
@@ -1,9 +1,365 @@
|
|||||||
<script setup lang="ts">
|
<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 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardShell>
|
<DashboardShell mobile-title="Edit article" full-bleed>
|
||||||
<p>Editor placeholder.</p>
|
<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>
|
</DashboardShell>
|
||||||
</template>
|
</template>
|
||||||
+60
-20
@@ -60,7 +60,11 @@ const statTiles = computed(() => [
|
|||||||
|
|
||||||
const recentArticles = computed(() =>
|
const recentArticles = computed(() =>
|
||||||
[...articles.value]
|
[...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),
|
.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 max-w-244 flex-col gap-5 lg:gap-7">
|
||||||
<div class="flex flex-wrap items-end gap-4">
|
<div class="flex flex-wrap items-end gap-4">
|
||||||
<div class="flex min-w-50 flex-1 flex-col gap-1">
|
<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 }}
|
{{ greeting }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-base/5 text-muted">{{ subtitle }}</p>
|
<p class="text-base/5 text-muted">{{ subtitle }}</p>
|
||||||
@@ -129,7 +136,12 @@ onMounted(loadArticles)
|
|||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</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 class="grid grid-cols-3 gap-3">
|
||||||
<div
|
<div
|
||||||
v-for="tile in statTiles"
|
v-for="tile in statTiles"
|
||||||
@@ -138,13 +150,18 @@ onMounted(loadArticles)
|
|||||||
class="flex flex-col gap-1 border border-default bg-default p-4"
|
class="flex flex-col gap-1 border border-default bg-default p-4"
|
||||||
>
|
>
|
||||||
<template v-if="loadState === 'loading'">
|
<template v-if="loadState === 'loading'">
|
||||||
<USkeleton class="h-7 w-10 rounded-none bg-accented" />
|
<USkeleton
|
||||||
<USkeleton class="h-3 w-17.5 rounded-none bg-accented" />
|
class="h-7 w-10 rounded-none bg-accented"
|
||||||
|
/>
|
||||||
|
<USkeleton
|
||||||
|
class="h-3 w-17.5 rounded-none bg-accented"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="text-2xl font-semibold text-highlighted">{{
|
<span
|
||||||
tile.value
|
class="text-2xl font-semibold text-highlighted"
|
||||||
}}</span>
|
>{{ tile.value }}</span
|
||||||
|
>
|
||||||
<span class="text-muted">{{ tile.label }}</span>
|
<span class="text-muted">{{ tile.label }}</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,8 +169,13 @@ onMounted(loadArticles)
|
|||||||
|
|
||||||
<section class="flex flex-col gap-2.5">
|
<section class="flex flex-col gap-2.5">
|
||||||
<div class="flex items-baseline justify-between">
|
<div class="flex items-baseline justify-between">
|
||||||
<h2 class="text-lg font-semibold text-highlighted">Recently updated</h2>
|
<h2 class="text-lg font-semibold text-highlighted">
|
||||||
<RouterLink to="/article" class="text-toned underline underline-offset-3">
|
Recently updated
|
||||||
|
</h2>
|
||||||
|
<RouterLink
|
||||||
|
to="/article"
|
||||||
|
class="text-toned underline underline-offset-3"
|
||||||
|
>
|
||||||
All articles
|
All articles
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,21 +189,35 @@ onMounted(loadArticles)
|
|||||||
:key="n"
|
:key="n"
|
||||||
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
|
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
|
||||||
<USkeleton class="h-5 w-14 rounded-none bg-accented" />
|
class="h-3 max-w-[60%] flex-1 rounded-none bg-accented"
|
||||||
|
/>
|
||||||
|
<USkeleton
|
||||||
|
class="h-5 w-14 rounded-none bg-accented"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<li v-for="article in recentArticles" :key="article.id">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="`/article/${article.id}/edit`"
|
: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"
|
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 }}
|
{{ article.title }}
|
||||||
</span>
|
</span>
|
||||||
<ArticleStatusBadge :online="article.is_online" />
|
<ArticleStatusBadge
|
||||||
<span class="hidden w-27.5 text-right text-muted lg:inline">
|
:online="article.is_online"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="hidden w-27.5 text-right text-muted lg:inline"
|
||||||
|
>
|
||||||
{{ formatRelativeDate(article.updated_at) }}
|
{{ formatRelativeDate(article.updated_at) }}
|
||||||
</span>
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -242,17 +278,21 @@ onMounted(loadArticles)
|
|||||||
</template>
|
</template>
|
||||||
</UAlert>
|
</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
|
<div
|
||||||
class="flex size-9 shrink-0 items-center justify-center bg-secondary-200 text-primary-900 dark:bg-secondary-300"
|
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" />
|
<UIcon name="i-lucide-plug" class="size-4.5" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-1">
|
<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">
|
<p class="text-pretty text-muted">
|
||||||
Your site reads published articles from RenewCMS. Share this guide with
|
Your site reads published articles from RenewCMS. Share
|
||||||
whoever builds it.
|
this guide with whoever builds it.
|
||||||
</p>
|
</p>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/integration"
|
to="/integration"
|
||||||
|
|||||||
+40
-12
@@ -63,7 +63,8 @@ const passwordError = ref('')
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const passwordShown = 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>(
|
const alert = ref<AlertState | null>(
|
||||||
initialReason === 'expired'
|
initialReason === 'expired'
|
||||||
@@ -73,7 +74,9 @@ const alert = ref<AlertState | null>(
|
|||||||
: null,
|
: null,
|
||||||
)
|
)
|
||||||
|
|
||||||
const alertRole = computed(() => (alert.value?.tone === 'error' ? 'alert' : 'status'))
|
const alertRole = computed(() =>
|
||||||
|
alert.value?.tone === 'error' ? 'alert' : 'status',
|
||||||
|
)
|
||||||
|
|
||||||
const submitLabel = computed(() => {
|
const submitLabel = computed(() => {
|
||||||
if (submitting.value) {
|
if (submitting.value) {
|
||||||
@@ -85,7 +88,9 @@ const submitLabel = computed(() => {
|
|||||||
return 'Log in'
|
return 'Log in'
|
||||||
})
|
})
|
||||||
|
|
||||||
const submitDisabled = computed(() => submitting.value || retryAfter.active.value)
|
const submitDisabled = computed(
|
||||||
|
() => submitting.value || retryAfter.active.value,
|
||||||
|
)
|
||||||
|
|
||||||
async function focusField(selector: string) {
|
async function focusField(selector: string) {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -112,15 +117,18 @@ function focusFirstInvalid() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validate() {
|
function validate() {
|
||||||
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
|
usernameError.value =
|
||||||
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
|
username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
|
||||||
|
passwordError.value =
|
||||||
|
password.value.length === 0 ? 'Enter your password.' : ''
|
||||||
return !usernameError.value && !passwordError.value
|
return !usernameError.value && !passwordError.value
|
||||||
}
|
}
|
||||||
|
|
||||||
async function afterLoginSuccess() {
|
async function afterLoginSuccess() {
|
||||||
await session.retry()
|
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) {
|
if (redirect) {
|
||||||
await router.push(redirect)
|
await router.push(redirect)
|
||||||
return
|
return
|
||||||
@@ -166,7 +174,10 @@ async function onSubmit() {
|
|||||||
await api.request('/v1/auth/login', {
|
await api.request('/v1/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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()
|
await afterLoginSuccess()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -182,7 +193,9 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
registrationOpen.value = status.open
|
||||||
} catch {
|
} catch {
|
||||||
registrationOpen.value = false
|
registrationOpen.value = false
|
||||||
@@ -191,7 +204,10 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<template v-if="alert" #notice>
|
||||||
<UAlert
|
<UAlert
|
||||||
:color="alert.tone"
|
:color="alert.tone"
|
||||||
@@ -205,7 +221,11 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #default="{ controlSize, controlClass, inputClass }">
|
<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
|
<UFormField
|
||||||
label="Username"
|
label="Username"
|
||||||
name="username"
|
name="username"
|
||||||
@@ -275,13 +295,21 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
<template #trailing>
|
<template #trailing>
|
||||||
<UButton
|
<UButton
|
||||||
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
:icon="
|
||||||
|
passwordShown
|
||||||
|
? 'i-lucide-eye-off'
|
||||||
|
: 'i-lucide-eye'
|
||||||
|
"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
size="sm"
|
size="sm"
|
||||||
square
|
square
|
||||||
class="size-8 justify-center text-toned"
|
class="size-8 justify-center text-toned"
|
||||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
:aria-label="
|
||||||
|
passwordShown
|
||||||
|
? 'Hide password'
|
||||||
|
: 'Show password'
|
||||||
|
"
|
||||||
:aria-pressed="passwordShown"
|
:aria-pressed="passwordShown"
|
||||||
@click="passwordShown = !passwordShown"
|
@click="passwordShown = !passwordShown"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -139,9 +139,9 @@ const actions = computed(() => [
|
|||||||
:actions="actions"
|
:actions="actions"
|
||||||
>
|
>
|
||||||
<p class="text-sm text-pretty text-muted">
|
<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
|
Opened it on another device? That works too; you'll log in there
|
||||||
it? Check your spam folder. If the link has expired, cancel your registration and
|
first. Can't find it? Check your spam folder. If the link has
|
||||||
start again.
|
expired, cancel your registration and start again.
|
||||||
</p>
|
</p>
|
||||||
</ResultPanel>
|
</ResultPanel>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
|
|||||||
@@ -24,12 +24,16 @@ const passwordShown = ref(false)
|
|||||||
const registrationClosed = ref(false)
|
const registrationClosed = ref(false)
|
||||||
const serverErrorAlert = 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 emailValid = computed(() => EMAIL_PATTERN.test(email.value))
|
||||||
const passwordValid = computed(() => password.value.length >= 8)
|
const passwordValid = computed(() => password.value.length >= 8)
|
||||||
|
|
||||||
const usernameError = computed(() =>
|
const usernameError = computed(() =>
|
||||||
usernameTouched.value && !usernameValid.value ? 'Use 3 to 20 characters.' : '',
|
usernameTouched.value && !usernameValid.value
|
||||||
|
? 'Use 3 to 20 characters.'
|
||||||
|
: '',
|
||||||
)
|
)
|
||||||
|
|
||||||
const emailError = computed(() =>
|
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() {
|
function touchAll() {
|
||||||
usernameTouched.value = true
|
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(() =>
|
const pageDescription = computed(() =>
|
||||||
registrationClosed.value
|
registrationClosed.value
|
||||||
@@ -142,7 +152,8 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else #help>
|
<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>
|
||||||
<template #hint>
|
<template #hint>
|
||||||
<span
|
<span
|
||||||
@@ -184,7 +195,9 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
|||||||
{{ error }}
|
{{ error }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</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
|
<UInput
|
||||||
v-model="email"
|
v-model="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -201,7 +214,11 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
|||||||
/>
|
/>
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField label="Password" name="password" :size="controlSize">
|
<UFormField
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
:size="controlSize"
|
||||||
|
>
|
||||||
<template #help>
|
<template #help>
|
||||||
<PasswordStrengthHint
|
<PasswordStrengthHint
|
||||||
:rules="passwordRules"
|
:rules="passwordRules"
|
||||||
@@ -225,13 +242,21 @@ const closedPanelActions = [{ label: 'Go to log in', onClick: goToLogin }]
|
|||||||
>
|
>
|
||||||
<template #trailing>
|
<template #trailing>
|
||||||
<UButton
|
<UButton
|
||||||
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
:icon="
|
||||||
|
passwordShown
|
||||||
|
? 'i-lucide-eye-off'
|
||||||
|
: 'i-lucide-eye'
|
||||||
|
"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
size="sm"
|
size="sm"
|
||||||
square
|
square
|
||||||
class="size-8 justify-center text-toned"
|
class="size-8 justify-center text-toned"
|
||||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
:aria-label="
|
||||||
|
passwordShown
|
||||||
|
? 'Hide password'
|
||||||
|
: 'Show password'
|
||||||
|
"
|
||||||
:aria-pressed="passwordShown"
|
:aria-pressed="passwordShown"
|
||||||
@click="passwordShown = !passwordShown"
|
@click="passwordShown = !passwordShown"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,5 +10,9 @@ function onRetry() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ErrorView v-if="session.status === 'error'" kind="error" @retry="onRetry" />
|
<ErrorView
|
||||||
|
v-if="session.status === 'error'"
|
||||||
|
kind="error"
|
||||||
|
@retry="onRetry"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -70,7 +70,9 @@ const passwordShown = ref(false)
|
|||||||
const loginSubmitting = ref(false)
|
const loginSubmitting = ref(false)
|
||||||
const loginAlert = ref<AlertState | null>(null)
|
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(() =>
|
const shellDescription = computed(() =>
|
||||||
state.value === 'noSession'
|
state.value === 'noSession'
|
||||||
? "You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after."
|
? "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 usernameError = ref('')
|
||||||
const passwordError = ref('')
|
const passwordError = ref('')
|
||||||
|
|
||||||
const loginSubmitDisabled = computed(() => loginSubmitting.value || retryAfter.active.value)
|
const loginSubmitDisabled = computed(
|
||||||
|
() => loginSubmitting.value || retryAfter.active.value,
|
||||||
|
)
|
||||||
|
|
||||||
const loginSubmitLabel = computed(() => {
|
const loginSubmitLabel = computed(() => {
|
||||||
if (loginSubmitting.value) {
|
if (loginSubmitting.value) {
|
||||||
@@ -95,7 +99,11 @@ const loginSubmitLabel = computed(() => {
|
|||||||
async function focusPassword() {
|
async function focusPassword() {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
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) {
|
async function performVerify(code: string) {
|
||||||
@@ -152,8 +160,10 @@ function handleLoginError(error: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateLogin() {
|
function validateLogin() {
|
||||||
usernameError.value = username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
|
usernameError.value =
|
||||||
passwordError.value = password.value.length === 0 ? 'Enter your password.' : ''
|
username.value.trim().length < 3 ? 'Use at least 3 characters.' : ''
|
||||||
|
passwordError.value =
|
||||||
|
password.value.length === 0 ? 'Enter your password.' : ''
|
||||||
return !usernameError.value && !passwordError.value
|
return !usernameError.value && !passwordError.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +180,10 @@ async function onLoginSubmit() {
|
|||||||
await api.request('/v1/auth/login', {
|
await api.request('/v1/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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 : ''
|
const code = typeof route.query.c === 'string' ? route.query.c : ''
|
||||||
await retryVerifying(code)
|
await retryVerifying(code)
|
||||||
@@ -281,8 +294,13 @@ onMounted(() => {
|
|||||||
role="status"
|
role="status"
|
||||||
class="flex flex-col items-center gap-4 py-10 text-center"
|
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" />
|
<UIcon
|
||||||
<p class="text-xl/7 font-semibold text-highlighted">Verifying your account…</p>
|
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>
|
<p class="text-base text-muted">This only takes a moment.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -384,13 +402,21 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<template #trailing>
|
<template #trailing>
|
||||||
<UButton
|
<UButton
|
||||||
:icon="passwordShown ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
:icon="
|
||||||
|
passwordShown
|
||||||
|
? 'i-lucide-eye-off'
|
||||||
|
: 'i-lucide-eye'
|
||||||
|
"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
size="sm"
|
size="sm"
|
||||||
square
|
square
|
||||||
class="size-8 justify-center text-toned"
|
class="size-8 justify-center text-toned"
|
||||||
:aria-label="passwordShown ? 'Hide password' : 'Show password'"
|
:aria-label="
|
||||||
|
passwordShown
|
||||||
|
? 'Hide password'
|
||||||
|
: 'Show password'
|
||||||
|
"
|
||||||
:aria-pressed="passwordShown"
|
:aria-pressed="passwordShown"
|
||||||
@click="passwordShown = !passwordShown"
|
@click="passwordShown = !passwordShown"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,7 +7,11 @@ import { useSessionStore } from '@/stores/session'
|
|||||||
import type { Article } from '@/composables/useArticles'
|
import type { Article } from '@/composables/useArticles'
|
||||||
import HomeView from '../HomeView.vue'
|
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 {
|
return {
|
||||||
ok: status >= 200 && status < 300,
|
ok: status >= 200 && status < 300,
|
||||||
status,
|
status,
|
||||||
@@ -21,7 +25,9 @@ function routeFor(url: string, init?: RequestInit) {
|
|||||||
return `${method} ${url}`
|
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) => {
|
return vi.fn((url: string, init?: RequestInit) => {
|
||||||
const key = routeFor(url, init)
|
const key = routeFor(url, init)
|
||||||
const handler = handlers[key]
|
const handler = handlers[key]
|
||||||
@@ -91,7 +97,9 @@ const sixArticles: Article[] = [
|
|||||||
|
|
||||||
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
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())
|
setActivePinia(createPinia())
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
session.session = { verified: true, username: 'marta' }
|
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 () => {
|
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
const wrapper = await mountHome({
|
const wrapper = await mountHome({
|
||||||
'GET /v1/article/': () => Promise.resolve(mockResponse(200, sixArticles)),
|
'GET /v1/article/': () =>
|
||||||
|
Promise.resolve(mockResponse(200, sixArticles)),
|
||||||
})
|
})
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(0)
|
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),
|
'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')
|
const rows = wrapper.find('[data-testid="recent-list"]').findAll('li')
|
||||||
expect(rows).toHaveLength(5)
|
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('2')
|
||||||
expect(wrapper.text()).toContain('drafts')
|
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')
|
expect(guideLink?.attributes('href')).toBe('/integration')
|
||||||
|
|
||||||
await findButton(wrapper, 'New article')?.trigger('click')
|
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,
|
'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')
|
expect(wrapper.text()).toContain('Good')
|
||||||
const alert = wrapper.find('[role="alert"]')
|
const alert = wrapper.find('[role="alert"]')
|
||||||
expect(alert.exists()).toBe(true)
|
expect(alert.exists()).toBe(true)
|
||||||
@@ -267,7 +282,9 @@ describe('The list is fetched exactly once per navigation', () => {
|
|||||||
|
|
||||||
expect(listCall).toHaveBeenCalledTimes(1)
|
expect(listCall).toHaveBeenCalledTimes(1)
|
||||||
expect(wrapper.text()).toContain('6')
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
|
|||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import LoginView from '../LoginView.vue'
|
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 {
|
return {
|
||||||
ok: status >= 200 && status < 300,
|
ok: status >= 200 && status < 300,
|
||||||
status,
|
status,
|
||||||
@@ -19,7 +23,9 @@ function routeFor(url: string, init?: RequestInit) {
|
|||||||
return `${method} ${url}`
|
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) => {
|
return vi.fn((url: string, init?: RequestInit) => {
|
||||||
const key = routeFor(url, init)
|
const key = routeFor(url, init)
|
||||||
const handler = handlers[key]
|
const handler = handlers[key]
|
||||||
@@ -59,7 +65,9 @@ async function fillAndSubmit(
|
|||||||
password: string,
|
password: string,
|
||||||
) {
|
) {
|
||||||
await wrapper.find('input[placeholder="Your username"]').setValue(username)
|
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 wrapper.find('form').trigger('submit')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
}
|
}
|
||||||
@@ -73,16 +81,21 @@ afterEach(async () => {
|
|||||||
describe('Default — registration is closed', () => {
|
describe('Default — registration is closed', () => {
|
||||||
it('renders the default state with no create-account footer link', async () => {
|
it('renders the default state with no create-account footer link', async () => {
|
||||||
const wrapper = await mountLogin('/login', {
|
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.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"]')
|
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
||||||
expect((usernameInput.element as HTMLInputElement).value).toBe('')
|
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((passwordInput.element as HTMLInputElement).value).toBe('')
|
||||||
|
|
||||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
|
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', () => {
|
describe('Fresh install — registration is open', () => {
|
||||||
it('renders the same fields plus the create-account footer link', async () => {
|
it('renders the same fields plus the create-account footer link', async () => {
|
||||||
const wrapper = await mountLogin('/login', {
|
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')
|
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
|
||||||
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
|
.find('input[placeholder="Your username"]')
|
||||||
|
.setValue('marta')
|
||||||
|
await wrapper
|
||||||
|
.find('input[autocomplete="current-password"]')
|
||||||
|
.setValue('limewash2026')
|
||||||
await wrapper.find('form').trigger('submit')
|
await wrapper.find('form').trigger('submit')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
|
(
|
||||||
.disabled,
|
wrapper.find('input[placeholder="Your username"]')
|
||||||
|
.element as HTMLInputElement
|
||||||
|
).disabled,
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
expect(
|
expect(
|
||||||
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
|
(
|
||||||
.disabled,
|
wrapper.find('input[autocomplete="current-password"]')
|
||||||
|
.element as HTMLInputElement
|
||||||
|
).disabled,
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
const submitButton = wrapper.find('button[type="submit"]')
|
const submitButton = wrapper.find('button[type="submit"]')
|
||||||
expect(submitButton.text()).toBe('Logging in…')
|
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')
|
await fillAndSubmit(wrapper, 'marta', 'wrong-password')
|
||||||
|
|
||||||
expect(wrapper.text()).toContain("That username and password don't match")
|
expect(wrapper.text()).toContain(
|
||||||
expect(wrapper.text()).toContain('Check both and try again. Passwords are case-sensitive.')
|
"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"]')
|
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
||||||
expect((usernameInput.element as HTMLInputElement).value).toBe('marta')
|
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 as HTMLInputElement).value).toBe('')
|
||||||
expect(passwordInput.element).toBe(document.activeElement)
|
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 () => {
|
it('counts down from the Retry-After value and re-enables at zero', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
const wrapper = await mountLogin('/login', {
|
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')
|
await fillAndSubmit(wrapper, 'marta', 'limewash')
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Too many attempts')
|
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(
|
expect(
|
||||||
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
|
(
|
||||||
|
wrapper.find('input[placeholder="Your username"]')
|
||||||
|
.element as HTMLInputElement
|
||||||
|
).value,
|
||||||
).toBe('marta')
|
).toBe('marta')
|
||||||
expect(
|
expect(
|
||||||
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
|
(
|
||||||
.value,
|
wrapper.find('input[autocomplete="current-password"]')
|
||||||
|
.element as HTMLInputElement
|
||||||
|
).value,
|
||||||
).toBe('limewash')
|
).toBe('limewash')
|
||||||
|
|
||||||
const button = wrapper.find('button[type="submit"]')
|
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)
|
expect((button.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
|
||||||
const fields = () => [
|
const fields = () => [
|
||||||
wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement,
|
wrapper.find('input[placeholder="Your username"]')
|
||||||
wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement,
|
.element as HTMLInputElement,
|
||||||
|
wrapper.find('input[autocomplete="current-password"]')
|
||||||
|
.element as HTMLInputElement,
|
||||||
]
|
]
|
||||||
expect(fields().map((field) => field.disabled)).toEqual([true, true])
|
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()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
|
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
|
||||||
expect((wrapper.find('button[type="submit"]').element as HTMLButtonElement).disabled).toBe(
|
expect(
|
||||||
false,
|
(wrapper.find('button[type="submit"]').element as HTMLButtonElement)
|
||||||
)
|
.disabled,
|
||||||
|
).toBe(false)
|
||||||
expect(fields().map((field) => field.disabled)).toEqual([false, false])
|
expect(fields().map((field) => field.disabled)).toEqual([false, false])
|
||||||
vi.useRealTimers()
|
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', () => {
|
describe('Session expired — redirected from a 401 elsewhere', () => {
|
||||||
it('shows the session-expired alert and focuses the password field', async () => {
|
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('Your session expired')
|
||||||
expect(wrapper.text()).toContain(
|
expect(wrapper.text()).toContain(
|
||||||
'Log in again to pick up where you left off. Unsaved changes are kept on this device.',
|
'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(passwordInput.element).toBe(document.activeElement)
|
||||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in')
|
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', () => {
|
describe('Server or network error — the fields keep their values', () => {
|
||||||
it.each([
|
it.each([
|
||||||
{ name: 'a 500 response', password: 'limewash2026', handler: () => mockResponse(500) },
|
{
|
||||||
|
name: 'a 500 response',
|
||||||
|
password: 'limewash2026',
|
||||||
|
handler: () => mockResponse(500),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'a network failure',
|
name: 'a network failure',
|
||||||
password: 'limewash2026',
|
password: 'limewash2026',
|
||||||
@@ -270,20 +318,28 @@ describe('Server or network error — the fields keep their values', () => {
|
|||||||
|
|
||||||
expect(login).toHaveBeenCalled()
|
expect(login).toHaveBeenCalled()
|
||||||
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
|
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(
|
expect(
|
||||||
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
|
(
|
||||||
.value,
|
wrapper.find('input[placeholder="Your username"]')
|
||||||
|
.element as HTMLInputElement
|
||||||
|
).value,
|
||||||
).toBe('marta')
|
).toBe('marta')
|
||||||
expect(
|
expect(
|
||||||
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
|
(
|
||||||
.value,
|
wrapper.find('input[autocomplete="current-password"]')
|
||||||
|
.element as HTMLInputElement
|
||||||
|
).value,
|
||||||
).toBe(password)
|
).toBe(password)
|
||||||
|
|
||||||
const submitButton = wrapper.find('button[type="submit"]')
|
const submitButton = wrapper.find('button[type="submit"]')
|
||||||
expect(submitButton.text()).toBe('Log in')
|
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 }) => {
|
async ({ path, verified, destination }) => {
|
||||||
const wrapper = await mountLogin(path, {
|
const wrapper = await mountLogin(path, {
|
||||||
'POST /v1/auth/login': () => mockResponse(200),
|
'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 fillAndSubmit(wrapper, 'marta', 'limewash2026')
|
||||||
|
|
||||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe(destination))
|
await vi.waitFor(() =>
|
||||||
|
expect(router.currentRoute.value.path).toBe(destination),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -6,7 +6,11 @@ import router from '@/router'
|
|||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import PendingView from '../PendingView.vue'
|
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 {
|
return {
|
||||||
ok: status >= 200 && status < 300,
|
ok: status >= 200 && status < 300,
|
||||||
status,
|
status,
|
||||||
@@ -20,7 +24,9 @@ function routeFor(url: string, init?: RequestInit) {
|
|||||||
return `${method} ${url}`
|
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) => {
|
return vi.fn((url: string, init?: RequestInit) => {
|
||||||
const key = routeFor(url, init)
|
const key = routeFor(url, init)
|
||||||
const handler = handlers[key]
|
const handler = handlers[key]
|
||||||
@@ -34,15 +40,17 @@ function fetchRouter(handlers: Record<string, () => Response | Promise<Response>
|
|||||||
function createOverlayMock() {
|
function createOverlayMock() {
|
||||||
let props: Record<string, unknown> | null = null
|
let props: Record<string, unknown> | null = null
|
||||||
let resolveOpen: ((value: unknown) => void) | null = null
|
let resolveOpen: ((value: unknown) => void) | null = null
|
||||||
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
|
const create = vi.fn(
|
||||||
props = options.props
|
(_component: unknown, options: { props: Record<string, unknown> }) => {
|
||||||
return {
|
props = options.props
|
||||||
open: () =>
|
return {
|
||||||
new Promise((resolve) => {
|
open: () =>
|
||||||
resolveOpen = resolve
|
new Promise((resolve) => {
|
||||||
}),
|
resolveOpen = resolve
|
||||||
}
|
}),
|
||||||
})
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
create,
|
create,
|
||||||
getProps: () => props,
|
getProps: () => props,
|
||||||
@@ -140,16 +148,24 @@ describe('Logging out', () => {
|
|||||||
await findButton(wrapper, 'Log out')?.trigger('click')
|
await findButton(wrapper, 'Log out')?.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect((findButton(wrapper, 'Log out')?.element as HTMLButtonElement).disabled).toBe(true)
|
|
||||||
expect(
|
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)
|
).toBe(true)
|
||||||
|
|
||||||
resolveLogout?.()
|
resolveLogout?.()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(session.session).toBeNull()
|
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)
|
overlayMock.resolveOpen(true)
|
||||||
await flushPromises()
|
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({
|
expect(toastAdd).toHaveBeenCalledWith({
|
||||||
color: 'success',
|
color: 'success',
|
||||||
icon: 'i-lucide-circle-check',
|
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')
|
expect(router.currentRoute.value.path).not.toBe('/home')
|
||||||
|
|
||||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
|
await vi.waitFor(
|
||||||
timeout: 2000,
|
() => expect(router.currentRoute.value.path).toBe('/home'),
|
||||||
})
|
{
|
||||||
|
timeout: 2000,
|
||||||
|
},
|
||||||
|
)
|
||||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||||
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
|
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
|
||||||
pushSpy.mockRestore()
|
pushSpy.mockRestore()
|
||||||
@@ -289,18 +310,23 @@ describe('Cancel failed — the dialog stays open and shows the failure inline',
|
|||||||
name: 'a network failure',
|
name: 'a network failure',
|
||||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||||
},
|
},
|
||||||
])('rejects the action instead of navigating for $name', async ({ handler }) => {
|
])(
|
||||||
const { wrapper } = await mountPending({
|
'rejects the action instead of navigating for $name',
|
||||||
'DELETE /v1/auth/register': handler,
|
async ({ handler }) => {
|
||||||
})
|
const { wrapper } = await mountPending({
|
||||||
|
'DELETE /v1/auth/register': handler,
|
||||||
|
})
|
||||||
|
|
||||||
await findButton(wrapper, 'Cancel registration')?.trigger('click')
|
await findButton(wrapper, 'Cancel registration')?.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
const props = overlayMock.getProps() as { action: () => Promise<void> }
|
const props = overlayMock.getProps() as {
|
||||||
await expect(props.action()).rejects.toThrow()
|
action: () => Promise<void>
|
||||||
|
}
|
||||||
|
await expect(props.action()).rejects.toThrow()
|
||||||
|
|
||||||
expect(router.currentRoute.value.path).toBe('/register/pending')
|
expect(router.currentRoute.value.path).toBe('/register/pending')
|
||||||
expect(toastAdd).not.toHaveBeenCalled()
|
expect(toastAdd).not.toHaveBeenCalled()
|
||||||
})
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
@@ -6,7 +6,11 @@ import router from '@/router'
|
|||||||
import ResultPanel from '@/components/ResultPanel.vue'
|
import ResultPanel from '@/components/ResultPanel.vue'
|
||||||
import RegisterView from '../RegisterView.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 {
|
return {
|
||||||
ok: status >= 200 && status < 300,
|
ok: status >= 200 && status < 300,
|
||||||
status,
|
status,
|
||||||
@@ -20,7 +24,9 @@ function routeFor(url: string, init?: RequestInit) {
|
|||||||
return `${method} ${url}`
|
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) => {
|
return vi.fn((url: string, init?: RequestInit) => {
|
||||||
const key = routeFor(url, init)
|
const key = routeFor(url, init)
|
||||||
const handler = handlers[key]
|
const handler = handlers[key]
|
||||||
@@ -33,7 +39,9 @@ function fetchRouter(handlers: Record<string, () => Response | Promise<Response>
|
|||||||
|
|
||||||
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
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())
|
setActivePinia(createPinia())
|
||||||
const fetchMock = fetchRouter(handlers)
|
const fetchMock = fetchRouter(handlers)
|
||||||
global.fetch = fetchMock as unknown as typeof fetch
|
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"]')
|
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 usernameInput(wrapper).setValue('marta')
|
||||||
await emailInput(wrapper).setValue('marta@renew.example')
|
await emailInput(wrapper).setValue('marta@renew.example')
|
||||||
await passwordInput(wrapper).setValue('limewash2026')
|
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.",
|
"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(wrapper.text()).toContain('0 / 20')
|
||||||
|
|
||||||
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('')
|
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.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?')
|
expect(wrapper.text()).toContain('Already set up?')
|
||||||
const footerLink = wrapper.find('a[href="/login"]')
|
const footerLink = wrapper.find('a[href="/login"]')
|
||||||
expect(footerLink.exists()).toBe(true)
|
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('2 / 20')
|
||||||
expect(wrapper.text()).toContain('Use 3 to 20 characters.')
|
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 wrapper.find('form').trigger('submit')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(register).not.toHaveBeenCalled()
|
expect(register).not.toHaveBeenCalled()
|
||||||
expect(wrapper.text()).toContain('Use 3 to 20 characters.')
|
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"]')
|
const toggle = wrapper.find('button[aria-label="Show password"]')
|
||||||
await toggle.trigger('click')
|
await toggle.trigger('click')
|
||||||
|
|
||||||
expect(wrapper.find('button[aria-label="Hide password"]').attributes('aria-pressed')).toBe(
|
expect(
|
||||||
'true',
|
wrapper
|
||||||
)
|
.find('button[aria-label="Hide password"]')
|
||||||
|
.attributes('aria-pressed'),
|
||||||
|
).toBe('true')
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('5 / 20')
|
expect(wrapper.text()).toContain('5 / 20')
|
||||||
expect(wrapper.text()).not.toContain('Use 3 to 20 characters.')
|
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.",
|
"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(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).type).toBe(
|
||||||
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
|
'text',
|
||||||
|
)
|
||||||
|
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe(
|
||||||
|
'limewash2026',
|
||||||
|
)
|
||||||
|
|
||||||
const submitButton = wrapper.find('button[type="submit"]')
|
const submitButton = wrapper.find('button[type="submit"]')
|
||||||
expect(submitButton.text()).toBe('Create account')
|
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 wrapper.find('form').trigger('submit')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect((usernameInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
|
expect(
|
||||||
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
|
(usernameInput(wrapper).element as HTMLInputElement).disabled,
|
||||||
expect((passwordInput(wrapper).element as HTMLInputElement).disabled).toBe(true)
|
).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"]')
|
const submitButton = wrapper.find('button[type="submit"]')
|
||||||
expect(submitButton.text()).toBe('Creating account…')
|
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')
|
expect(action.text()).toBe('Go to log in')
|
||||||
await action.trigger('click')
|
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',
|
name: 'a network failure',
|
||||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
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)
|
'shows the error alert and keeps the values for $name',
|
||||||
const { wrapper, fetchMock } = await mountRegister({
|
async ({ handler }) => {
|
||||||
'POST /v1/auth/register': register,
|
const register = vi.fn(handler)
|
||||||
})
|
const { wrapper, fetchMock } = await mountRegister({
|
||||||
|
'POST /v1/auth/register': register,
|
||||||
|
})
|
||||||
|
|
||||||
await fillValidForm(wrapper)
|
await fillValidForm(wrapper)
|
||||||
await wrapper.find('form').trigger('submit')
|
await wrapper.find('form').trigger('submit')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
'/v1/auth/register',
|
'/v1/auth/register',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
username: 'marta',
|
username: 'marta',
|
||||||
email: 'marta@renew.example',
|
email: 'marta@renew.example',
|
||||||
password: 'limewash2026',
|
password: 'limewash2026',
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
)
|
||||||
)
|
expect(wrapper.text()).toContain("Couldn't create the account")
|
||||||
expect(wrapper.text()).toContain("Couldn't create the account")
|
expect(wrapper.text()).toContain(
|
||||||
expect(wrapper.text()).toContain(
|
'Something went wrong on the server. Try again in a moment.',
|
||||||
'Something went wrong on the server. Try again in a moment.',
|
)
|
||||||
)
|
|
||||||
|
|
||||||
expect((usernameInput(wrapper).element as HTMLInputElement).value).toBe('marta')
|
expect(
|
||||||
expect((emailInput(wrapper).element as HTMLInputElement).value).toBe('marta@renew.example')
|
(usernameInput(wrapper).element as HTMLInputElement).value,
|
||||||
expect((passwordInput(wrapper).element as HTMLInputElement).value).toBe('limewash2026')
|
).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(
|
||||||
expect((emailInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
|
(usernameInput(wrapper).element as HTMLInputElement).disabled,
|
||||||
expect((passwordInput(wrapper).element as HTMLInputElement).disabled).toBe(false)
|
).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"]')
|
const submitButton = wrapper.find('button[type="submit"]')
|
||||||
expect(submitButton.text()).toBe('Create account')
|
expect(submitButton.text()).toBe('Create account')
|
||||||
expect((submitButton.element as HTMLButtonElement).disabled).toBe(false)
|
expect((submitButton.element as HTMLButtonElement).disabled).toBe(
|
||||||
})
|
false,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('A successful registration goes straight to the pending page', () => {
|
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 fillValidForm(wrapper)
|
||||||
await wrapper.find('form').trigger('submit')
|
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(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
'/v1/auth/register',
|
'/v1/auth/register',
|
||||||
@@ -307,8 +362,12 @@ describe('A successful registration goes straight to the pending page', () => {
|
|||||||
await fillValidForm(wrapper)
|
await fillValidForm(wrapper)
|
||||||
await wrapper.find('form').trigger('submit')
|
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',
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -6,7 +6,11 @@ import router from '@/router'
|
|||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import VerifyView from '../VerifyView.vue'
|
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 {
|
return {
|
||||||
ok: status >= 200 && status < 300,
|
ok: status >= 200 && status < 300,
|
||||||
status,
|
status,
|
||||||
@@ -20,7 +24,9 @@ function routeFor(url: string, init?: RequestInit) {
|
|||||||
return `${method} ${url}`
|
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) => {
|
return vi.fn((url: string, init?: RequestInit) => {
|
||||||
const key = routeFor(url, init)
|
const key = routeFor(url, init)
|
||||||
const handler = handlers[key]
|
const handler = handlers[key]
|
||||||
@@ -75,15 +81,23 @@ afterEach(async () => {
|
|||||||
describe('Verifying — the code submits automatically on load', () => {
|
describe('Verifying — the code submits automatically on load', () => {
|
||||||
it('shows the full-card spinner and calls verify once with the code', async () => {
|
it('shows the full-card spinner and calls verify once with the code', async () => {
|
||||||
const verify = vi.fn(() => new Promise(() => {}))
|
const verify = vi.fn(() => new Promise(() => {}))
|
||||||
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
|
const { wrapper, fetchMock } = await mountVerify(
|
||||||
'POST /v1/auth/verify': verify,
|
'/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(verify).toHaveBeenCalledTimes(1)
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
'/v1/auth/verify',
|
'/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.',
|
description: 'Welcome to RenewCMS.',
|
||||||
})
|
})
|
||||||
|
|
||||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
|
await vi.waitFor(
|
||||||
timeout: 2000,
|
() => expect(router.currentRoute.value.path).toBe('/home'),
|
||||||
})
|
{
|
||||||
|
timeout: 2000,
|
||||||
|
},
|
||||||
|
)
|
||||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
@@ -135,7 +152,9 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
|
|||||||
expect(wrapper.text()).toContain(
|
expect(wrapper.text()).toContain(
|
||||||
'Cancel your registration and start over to get a new link.',
|
'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(findButton(wrapper, 'Log out')).toBeTruthy()
|
||||||
expect(router.currentRoute.value.path).toBe('/register/validate')
|
expect(router.currentRoute.value.path).toBe('/register/validate')
|
||||||
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
|
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', () => {
|
describe('Cancel registration and start over — success', () => {
|
||||||
it('sends DELETE with onForbidden passthrough, navigates to /register and toasts', async () => {
|
it('sends DELETE with onForbidden passthrough, navigates to /register and toasts', async () => {
|
||||||
const deleteRegister = vi.fn(() => mockResponse(200))
|
const deleteRegister = vi.fn(() => mockResponse(200))
|
||||||
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
|
const { wrapper, fetchMock } = await mountVerify(
|
||||||
'POST /v1/auth/verify': () => mockResponse(403),
|
'/register/validate?c=abc123',
|
||||||
'DELETE /v1/auth/register': deleteRegister,
|
{
|
||||||
})
|
'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()
|
await flushPromises()
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
'/v1/auth/register',
|
'/v1/auth/register',
|
||||||
expect.objectContaining({ method: 'DELETE' }),
|
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({
|
expect(toastAdd).toHaveBeenCalledWith({
|
||||||
color: 'success',
|
color: 'success',
|
||||||
icon: 'i-lucide-circle-check',
|
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', () => {
|
describe('Cancel registration and start over — failure shows an alert, not a redirect', () => {
|
||||||
it.each([
|
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 500 response', handler: () => mockResponse(500) },
|
||||||
{
|
{
|
||||||
name: 'a network failure',
|
name: 'a network failure',
|
||||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
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', {
|
'shows the failure alert and keeps the panel for $name',
|
||||||
'POST /v1/auth/verify': () => mockResponse(403),
|
async ({ handler }) => {
|
||||||
'DELETE /v1/auth/register': 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 findButton(
|
||||||
await flushPromises()
|
wrapper,
|
||||||
|
'Cancel registration and start over',
|
||||||
|
)?.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain("Couldn't cancel registration")
|
expect(wrapper.text()).toContain("Couldn't cancel registration")
|
||||||
expect(wrapper.text()).toContain(
|
expect(wrapper.text()).toContain(
|
||||||
'Something went wrong on the server. Try again in a moment.',
|
'Something went wrong on the server. Try again in a moment.',
|
||||||
)
|
)
|
||||||
expect(wrapper.text()).toContain("This verification link doesn't work")
|
expect(wrapper.text()).toContain(
|
||||||
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
|
"This verification link doesn't work",
|
||||||
expect(findButton(wrapper, 'Log out')).toBeTruthy()
|
)
|
||||||
expect(router.currentRoute.value.path).toBe('/register/validate')
|
expect(
|
||||||
expect(toastAdd).not.toHaveBeenCalled()
|
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', () => {
|
describe('Log out from the Invalid-or-expired state', () => {
|
||||||
@@ -211,7 +254,9 @@ describe('Log out from the Invalid-or-expired state', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(session.session).toBeNull()
|
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(
|
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.",
|
"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(
|
||||||
expect(wrapper.find('input[autocomplete="current-password"]').exists()).toBe(true)
|
wrapper.find('input[placeholder="Your username"]').exists(),
|
||||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
|
).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('Forgot password?')
|
||||||
expect(wrapper.text()).not.toContain('First time here?')
|
expect(wrapper.text()).not.toContain('First time here?')
|
||||||
expect(router.currentRoute.value.path).toBe('/register/validate')
|
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()
|
const verify = vi.fn()
|
||||||
verify.mockResolvedValueOnce(mockResponse(401))
|
verify.mockResolvedValueOnce(mockResponse(401))
|
||||||
verify.mockImplementationOnce(() => new Promise(() => {}))
|
verify.mockImplementationOnce(() => new Promise(() => {}))
|
||||||
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
|
const { wrapper, fetchMock } = await mountVerify(
|
||||||
'POST /v1/auth/verify': verify,
|
'/register/validate?c=abc123',
|
||||||
'POST /v1/auth/login': () => mockResponse(200),
|
{
|
||||||
})
|
'POST /v1/auth/verify': verify,
|
||||||
|
'POST /v1/auth/login': () => mockResponse(200),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
|
await wrapper
|
||||||
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
|
.find('input[placeholder="Your username"]')
|
||||||
|
.setValue('marta')
|
||||||
|
await wrapper
|
||||||
|
.find('input[autocomplete="current-password"]')
|
||||||
|
.setValue('limewash2026')
|
||||||
await wrapper.find('form').trigger('submit')
|
await wrapper.find('form').trigger('submit')
|
||||||
await flushPromises()
|
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(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
'/v1/auth/verify',
|
'/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)
|
expect(verify).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
@@ -286,14 +349,24 @@ describe('No session — wrong credentials during the inline login', () => {
|
|||||||
'POST /v1/auth/login': () => mockResponse(403),
|
'POST /v1/auth/login': () => mockResponse(403),
|
||||||
})
|
})
|
||||||
|
|
||||||
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
|
await wrapper
|
||||||
await wrapper.find('input[autocomplete="current-password"]').setValue('wrong-password')
|
.find('input[placeholder="Your username"]')
|
||||||
|
.setValue('marta')
|
||||||
|
await wrapper
|
||||||
|
.find('input[autocomplete="current-password"]')
|
||||||
|
.setValue('wrong-password')
|
||||||
await wrapper.find('form').trigger('submit')
|
await wrapper.find('form').trigger('submit')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain("That username and password don't match")
|
expect(wrapper.text()).toContain(
|
||||||
expect(wrapper.text()).toContain('Check both and try again. Passwords are case-sensitive.')
|
"That username and password don't match",
|
||||||
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
|
)
|
||||||
|
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 as HTMLInputElement).value).toBe('')
|
||||||
expect(passwordInput.element).toBe(document.activeElement)
|
expect(passwordInput.element).toBe(document.activeElement)
|
||||||
expect(verify).toHaveBeenCalledTimes(1)
|
expect(verify).toHaveBeenCalledTimes(1)
|
||||||
@@ -305,16 +378,23 @@ describe('No session — too many login attempts', () => {
|
|||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
|
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
|
||||||
'POST /v1/auth/verify': () => mockResponse(401),
|
'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
|
||||||
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
|
.find('input[placeholder="Your username"]')
|
||||||
|
.setValue('marta')
|
||||||
|
await wrapper
|
||||||
|
.find('input[autocomplete="current-password"]')
|
||||||
|
.setValue('limewash2026')
|
||||||
await wrapper.find('form').trigger('submit')
|
await wrapper.find('form').trigger('submit')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('Too many attempts')
|
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"]')
|
const button = wrapper.find('button[type="submit"]')
|
||||||
expect(button.text()).toBe('Try again in 0:30')
|
expect(button.text()).toBe('Try again in 0:30')
|
||||||
expect((button.element as HTMLButtonElement).disabled).toBe(true)
|
expect((button.element as HTMLButtonElement).disabled).toBe(true)
|
||||||
@@ -322,7 +402,9 @@ describe('No session — too many login attempts', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(30000)
|
await vi.advanceTimersByTimeAsync(30000)
|
||||||
await flushPromises()
|
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',
|
name: 'a network failure',
|
||||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
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', {
|
'shows the network/server alert and keeps the fields for $name',
|
||||||
'POST /v1/auth/verify': () => mockResponse(401),
|
async ({ handler }) => {
|
||||||
'POST /v1/auth/login': 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
|
||||||
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
|
.find('input[placeholder="Your username"]')
|
||||||
await wrapper.find('form').trigger('submit')
|
.setValue('marta')
|
||||||
await flushPromises()
|
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("Couldn't reach RenewCMS")
|
||||||
expect(wrapper.text()).toContain('Check your connection and try again.')
|
expect(wrapper.text()).toContain(
|
||||||
expect(
|
'Check your connection and try again.',
|
||||||
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
|
)
|
||||||
).toBe('marta')
|
expect(
|
||||||
expect(
|
(
|
||||||
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
|
wrapper.find('input[placeholder="Your username"]')
|
||||||
.value,
|
.element as HTMLInputElement
|
||||||
).toBe('limewash2026')
|
).value,
|
||||||
expect(
|
).toBe('marta')
|
||||||
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
|
expect(
|
||||||
.disabled,
|
(
|
||||||
).toBe(false)
|
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', () => {
|
describe('Missing code — no request is made', () => {
|
||||||
@@ -374,6 +475,8 @@ describe('Missing code — no request is made', () => {
|
|||||||
expect(action).toBeTruthy()
|
expect(action).toBeTruthy()
|
||||||
await action?.trigger('click')
|
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
@@ -206,12 +206,15 @@ async function captureDesign(
|
|||||||
const section = [...document.querySelectorAll('section[data-screen-label]')].find(
|
const section = [...document.querySelectorAll('section[data-screen-label]')].find(
|
||||||
(element) => element.getAttribute('data-screen-label') === group,
|
(element) => element.getAttribute('data-screen-label') === group,
|
||||||
)
|
)
|
||||||
const title = section
|
const headers = section
|
||||||
? [...section.querySelectorAll('span')].find(
|
? [...section.querySelectorAll<HTMLElement>('div')].filter(
|
||||||
(span) => span.textContent?.trim() === state,
|
(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
|
const target = block
|
||||||
? [...block.querySelectorAll<HTMLElement>('div')].find(
|
? [...block.querySelectorAll<HTMLElement>('div')].find(
|
||||||
(div) => div.style.width === `${width}px`,
|
(div) => div.style.width === `${width}px`,
|
||||||
|
|||||||
@@ -153,6 +153,56 @@ const verifiedSession: ApiMock = {
|
|||||||
body: { is_verified: true, username: 'marta' },
|
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>> = {
|
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||||
home: {
|
home: {
|
||||||
Default: {
|
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: {
|
verify: {
|
||||||
Verifying: {
|
Verifying: {
|
||||||
path: '/register/validate?c=abc123',
|
path: '/register/validate?c=abc123',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export default mergeConfig(
|
|||||||
defineConfig({
|
defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
env: { TZ: 'UTC' },
|
||||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user