Compare commits
16
Commits
24f95a8fd7
...
2.0.0-beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad55137883 | ||
|
|
a9a208b80a | ||
|
|
f6d0d01092 | ||
|
|
ca3fe20519 | ||
|
|
c47d33eaa9 | ||
|
|
a4734b2c91 | ||
|
|
d493a181e1 | ||
|
|
930b836241 | ||
|
|
30a4df47b1 | ||
|
|
4e41d60142 | ||
|
|
3c3f7c6194 | ||
|
|
a5873f8886 | ||
|
|
f1e22f7073 | ||
|
|
001f9f4774 | ||
|
|
12faab7f92 | ||
|
|
eed661bb24 |
No files matched your search
+3
-2
@@ -1,9 +1,10 @@
|
||||
PUBLIC_URL=http://localhost:5173
|
||||
JWT_SECRET=change-me-to-a-long-random-string
|
||||
SMTP_EMAIL=a@b.c
|
||||
SMTP_PASSWORD=12341234
|
||||
SMTP_HOST=mail.service.abc
|
||||
SMTP_PORT=465
|
||||
SMTP_LOGIN=a@b.c
|
||||
SMTP_PASSWORD=12341234
|
||||
SMTP_SENDER=a@b.c
|
||||
|
||||
ENVIRONMENT=development
|
||||
PORT=8080
|
||||
|
||||
@@ -66,6 +66,13 @@ tasks:
|
||||
cmds:
|
||||
- bun run vitest run
|
||||
|
||||
visual:check:
|
||||
desc: Screenshot a design screen's states next to the built SPA (Playwright) → tmp/visual/<screen>/
|
||||
dir: web
|
||||
deps: [web:install]
|
||||
cmds:
|
||||
- bun visual/check.ts {{.CLI_ARGS}}
|
||||
|
||||
lint:
|
||||
desc: gofmt check, go vet, eslint (--fix) and vue-tsc
|
||||
cmds:
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@ var possibleEnvFileLocations = []string{".env", "../.env"}
|
||||
var requiredEnvVars = []string{
|
||||
"PUBLIC_URL",
|
||||
"JWT_SECRET",
|
||||
"SMTP_EMAIL",
|
||||
"SMTP_LOGIN",
|
||||
"SMTP_SENDER",
|
||||
"SMTP_PASSWORD",
|
||||
"SMTP_HOST",
|
||||
"SMTP_PORT",
|
||||
|
||||
@@ -12,7 +12,8 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
|
||||
|-----------------|---------------------------------------------------------------------------------------------------------------------|----------------------------|
|
||||
| `PUBLIC_URL` | Public base URL of the site, including scheme and no trailing slash. Used for links in emails and in the startup log. | `https://cms.example.com` |
|
||||
| `JWT_SECRET` | Secret used to sign the auth JWT. Use a long random value; changing it logs everyone out. | `openssl rand -hex 32` |
|
||||
| `SMTP_EMAIL` | Sender address and SMTP username. | `noreply@example.com` |
|
||||
| `SMTP_LOGIN` | SMTP username used to authenticate with the server. Some providers (e.g. Brevo) issue a login distinct from the sender address. | `noreply@example.com` |
|
||||
| `SMTP_SENDER` | Sender address used in the `From` header. Must be an address/domain the SMTP provider allows that login to send as. | `noreply@example.com` |
|
||||
| `SMTP_PASSWORD` | SMTP password. | |
|
||||
| `SMTP_HOST` | SMTP server host. | `smtp.example.com` |
|
||||
| `SMTP_PORT` | SMTP server port. | `465` |
|
||||
|
||||
+2
-1
@@ -47,7 +47,8 @@ PORT=8080
|
||||
DB_FILE=/var/lib/renewcms/renewcms.db
|
||||
UPLOAD_DIR=/var/lib/renewcms/uploads
|
||||
JWT_SECRET=<openssl rand -hex 32>
|
||||
SMTP_EMAIL=noreply@example.com
|
||||
SMTP_LOGIN=noreply@example.com
|
||||
SMTP_SENDER=noreply@example.com
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=465
|
||||
|
||||
+19
-1
@@ -57,6 +57,8 @@ task --list # everything available
|
||||
task test # Go tests; extra args pass through: task test -- -run TestCreateArticle -v
|
||||
task cover # coverage of the use cases → tmp/coverage.out
|
||||
task mutate # gremlins mutation testing → tmp/gremlins.json
|
||||
task test:web # SPA component tests (Vitest)
|
||||
task visual:check -- log-in # screenshot design vs SPA for a screen → tmp/visual/log-in/
|
||||
task lint # gofmt check, go vet, eslint --fix, vue-tsc
|
||||
task fmt # gofmt -w + prettier
|
||||
task run # build the production binary and run it (SPA embedded) on :8080
|
||||
@@ -67,7 +69,23 @@ task clean # remove bin/, tmp/ and the built SPA
|
||||
|
||||
- All Go tests live in [`test/`](../test) (package `test`, black-box). They use in-memory fakes of the domain `Repository` interfaces, so they need no database, SMTP server or network.
|
||||
- HTTP-level tests use `net/http/httptest` against a chi router. Call `validator.InitValidator()` before invoking a handler.
|
||||
- The frontend has no test runner yet. `task lint:web` runs eslint and a type check.
|
||||
- SPA component tests use Vitest + Vue Test Utils (`task test:web`). `task lint:web` runs eslint and a type check.
|
||||
|
||||
## Visual check against the design
|
||||
|
||||
`task visual:check -- <screen> [state]` compares the SPA with the Claude Design boards in [`docs/design/source`](design/source). The screen is a file name from [`docs/design/screens`](design/screens) (`log-in`, `home`, …), and the state is one of its headings.
|
||||
|
||||
- It builds the SPA into `tmp/visual/dist` and serves both the build and the boards locally, so no API or dev server is needed.
|
||||
- For every state it screenshots:
|
||||
- the board's frame for that state;
|
||||
- the SPA in the same state, at 1440×900 and 390×844, in light and dark.
|
||||
- It saves `design-*.png`, `app-*.png` and `diff-*.png` (differing pixels in red) to `tmp/visual/<screen>/<state>/`, and a summary to `tmp/visual/<screen>/report.md`.
|
||||
- [`web/visual/states.ts`](../web/visual/states.ts) says how to reach each state in the SPA: the route, the mocked `/v1` responses (keyed `'METHOD /v1/path'`) and any user steps. A state without an entry is captured on the design side only, and marked "design only" in the report.
|
||||
- Narrow a run with `--viewport=desktop|mobile` and `--theme=light|dark`.
|
||||
- Lucide icons are served from the local `@iconify-json/lucide` set, for both the boards and the SPA. api.iconify.design rate-limits repeated runs.
|
||||
- The first time on a machine, run `bunx playwright install chromium` in `web/`.
|
||||
|
||||
The pixel percentage only points at where to look, because font anti-aliasing always differs a little. Judge by comparing the image pairs.
|
||||
|
||||
## Data
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
Feature: Typed API client and session store with global 401, unverified-403, 429 and offline handling
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want useApi to expose one generic authenticated request method that every future screen call
|
||||
site builds on, that turns a mid-session 401 into a redirect to /login with the expired reason
|
||||
and clears the session, turns a 403 from a known-unverified session into a redirect to
|
||||
/register/pending, surfaces the Retry-After seconds from a 429 so screens can show a countdown,
|
||||
and marks the app offline on a network failure
|
||||
So that later screen slices (login, articles, editor, ...) get consistent global session and
|
||||
connectivity handling instead of each reimplementing 401/403/429/offline handling ad hoc
|
||||
|
||||
Background:
|
||||
Given useApi exports a generic request(path, options) method, used by getMe and by every future
|
||||
endpoint call, that always sends credentials: 'include' and parses a JSON response body on
|
||||
success
|
||||
And a non-2xx response makes request(...) throw an ApiError carrying the numeric status; a 429
|
||||
response also carries retryAfter, the integer number of seconds parsed from the response's
|
||||
Retry-After header, or undefined if the header is absent or not a plain integer
|
||||
And a fetch that rejects instead of resolving (no connectivity, DNS failure, the request never
|
||||
reaches a server) makes request(...) throw a NetworkError instead of an ApiError, so callers
|
||||
can tell "the server answered with an error" apart from "the request never got an answer" with
|
||||
instanceof
|
||||
And useApi exports a shared reactive isOffline ref, module-scoped so every caller of useApi() and
|
||||
every component observes the same value: a NetworkError sets it true; it is also true whenever
|
||||
navigator.onLine is false; a request that completes (2xx or a non-network ApiError) while
|
||||
navigator.onLine is true sets it back to false
|
||||
And getMe() calls request('/v1/auth/me', { onUnauthorized: 'passthrough' }): a 401 from this one
|
||||
call still throws a plain ApiError with status 401, exactly as before, and does not trigger the
|
||||
redirect below, because this call only ever runs as part of session.check() during router
|
||||
navigation (features/nuxt_ui_shell_foundation.feature), and the router's own guard already
|
||||
redirects to /login?redirect=<target>&reason=expired for a "no session" boot result
|
||||
And every other call to request(...) uses the default onUnauthorized: 'redirect': on a 401,
|
||||
before the ApiError reaches the caller, useApi calls the session store's clear() action (session
|
||||
= null, status stays 'ready') and navigates the router to
|
||||
/login?redirect=<currentPath>&reason=expired, where <currentPath> is
|
||||
router.currentRoute.value.fullPath at the moment the 401 is received
|
||||
And on a 403, useApi reads the session store: if it holds a session whose verified is false, this
|
||||
is the global "unverified account" case from docs/design/routes.md and states.md, so useApi
|
||||
navigates the router to /register/pending; the ApiError still reaches the caller as a plain 403,
|
||||
and the session store is left untouched, since it already correctly records verified: false; any
|
||||
other 403 (no session, or a session whose verified is true) is not the global case: useApi does
|
||||
nothing extra and the ApiError propagates to the caller unchanged, for the screen to interpret
|
||||
(wrong-credentials at login, already-verified at cancel-registration, an expired verify or reset
|
||||
link, and registration-closed are all screen-level, out of scope here)
|
||||
And the session store gains a clear() action (session = null, status = 'ready', independent of
|
||||
check()/retry()) used by the global 401 handling above
|
||||
|
||||
Scenario: A successful authenticated request returns the parsed body
|
||||
Given a mocked fetch that resolves 200 with JSON body {"id": 5}
|
||||
When useApi().request('/v1/article/5') is called
|
||||
Then the returned value is {"id": 5}
|
||||
And isOffline is false
|
||||
|
||||
Scenario: A 401 received mid-session redirects to login with the expired reason and clears the session
|
||||
Given the session store holds session {"verified": true} and status "ready"
|
||||
And the current route is /article/12/edit
|
||||
And a mocked fetch that resolves 401
|
||||
When useApi().request('/v1/article/12') is called
|
||||
Then the call rejects with an ApiError whose status is 401
|
||||
And the session store's session becomes null and its status stays "ready"
|
||||
And the router navigates to "/login?redirect=/article/12/edit&reason=expired"
|
||||
|
||||
Scenario: The boot call to getMe does not trigger the global 401 redirect
|
||||
Given the session store holds session null and status "checking"
|
||||
And a mocked fetch that resolves 401
|
||||
When useApi().getMe() is called directly, outside of router navigation
|
||||
Then the call rejects with an ApiError whose status is 401
|
||||
And the router does not navigate anywhere
|
||||
|
||||
Scenario: A 403 while the session is known to be unverified redirects to the pending page
|
||||
Given the session store holds session {"verified": false} and status "ready"
|
||||
And a mocked fetch that resolves 403
|
||||
When useApi().request('/v1/article/') is called
|
||||
Then the call rejects with an ApiError whose status is 403
|
||||
And the router navigates to "/register/pending"
|
||||
And the session store's session is still {"verified": false}
|
||||
|
||||
Scenario Outline: A 403 that is not the global unverified case passes through untouched
|
||||
Given the session store holds session <session> and status "ready"
|
||||
And a mocked fetch that resolves 403
|
||||
When useApi().request('/v1/some/endpoint') is called
|
||||
Then the call rejects with an ApiError whose status is 403
|
||||
And the router does not navigate anywhere
|
||||
And the session store is unchanged
|
||||
|
||||
Examples:
|
||||
| session |
|
||||
| null |
|
||||
| {"verified": true} |
|
||||
|
||||
Scenario: A 429 on a rate-limited endpoint exposes the Retry-After seconds
|
||||
Given a mocked fetch that resolves 429 with a Retry-After header of "30"
|
||||
When useApi().request('/v1/auth/login', { method: 'POST' }) is called
|
||||
Then the call rejects with an ApiError whose status is 429 and whose retryAfter is 30
|
||||
|
||||
Scenario: A 429 with no Retry-After header exposes an undefined retryAfter
|
||||
Given a mocked fetch that resolves 429 with no Retry-After header
|
||||
When useApi().request('/v1/auth/password-reset', { method: 'POST' }) is called
|
||||
Then the call rejects with an ApiError whose status is 429 and whose retryAfter is undefined
|
||||
|
||||
Scenario: A network failure throws a NetworkError and marks the app offline
|
||||
Given navigator.onLine is true
|
||||
And a mocked fetch that rejects with a TypeError, as a real fetch does when the network is
|
||||
unreachable
|
||||
When useApi().request('/v1/article/') is called
|
||||
Then the call rejects with a NetworkError, not an ApiError
|
||||
And isOffline becomes true
|
||||
|
||||
Scenario: A later successful request clears the offline flag
|
||||
Given isOffline is true from a previous network failure
|
||||
And navigator.onLine is true
|
||||
And a mocked fetch that resolves 200 with JSON body {}
|
||||
When useApi().request('/v1/article/') is called
|
||||
Then isOffline becomes false
|
||||
|
||||
Scenario: The browser's own online/offline events also drive the shared flag
|
||||
Given isOffline is false
|
||||
When the browser fires a "offline" event on window
|
||||
Then isOffline becomes true
|
||||
When the browser fires a "online" event on window
|
||||
Then isOffline becomes false
|
||||
@@ -0,0 +1,76 @@
|
||||
# QA: Typed API client and session store with global 401, unverified-403, 429 and offline handling
|
||||
|
||||
This is a composable/store-only slice with no new screen and no new `/v1` handler, so there is no
|
||||
Go `httptest` procedure. Verification is entirely Vitest (`task test:web`), unit-testing
|
||||
`web/src/composables/useApi.ts` and `web/src/stores/session.ts` directly, plus a small check that
|
||||
the existing router guard suite still passes unmodified.
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `global.fetch` per case (`vi.fn()`), install a Pinia instance (`createPinia()` /
|
||||
`setActivePinia`) before each test, and import the real `router` singleton from `@/router` so
|
||||
`request(...)`'s navigation calls are observable on `router.currentRoute`. No real backend, no
|
||||
httptest server, no live SMTP.
|
||||
|
||||
1. **Successful request returns parsed JSON and leaves isOffline false** — mock fetch resolving
|
||||
`{ ok: true, status: 200, json: async () => ({ id: 5 }) }`; call
|
||||
`useApi().request('/v1/article/5')`; assert it resolves to `{ id: 5 }`, assert `fetch` was called
|
||||
with `credentials: 'include'`, and assert the exported `isOffline` ref is `false`.
|
||||
2. **401 mid-session redirects and clears the session** — seed the session store with
|
||||
`session = { verified: true }`, `status = 'ready'`; navigate the router to `/article/12/edit`
|
||||
first so `router.currentRoute.value.fullPath` matches; mock fetch resolving status 401; call
|
||||
`useApi().request('/v1/article/12')`; assert the promise rejects with an `ApiError` whose
|
||||
`status === 401`; assert `session.session === null` and `session.status === 'ready'`; assert
|
||||
`router.currentRoute.value.fullPath` is
|
||||
`/login?redirect=%2Farticle%2F12%2Fedit&reason=expired` (or the equivalent decoded query object).
|
||||
3. **getMe's own 401 does not trigger the global redirect** — seed
|
||||
`session = null, status = 'checking'`; mock fetch resolving status 401; record
|
||||
`router.currentRoute.value.fullPath` before the call; call `useApi().getMe()`; assert it rejects
|
||||
with `ApiError` status 401; assert the router's current route is unchanged from before the call.
|
||||
4. **403 with a known-unverified session redirects to /register/pending** — seed
|
||||
`session = { verified: false }, status = 'ready'`; mock fetch resolving status 403; call
|
||||
`useApi().request('/v1/article/')`; assert the promise rejects with `ApiError` status 403; assert
|
||||
`router.currentRoute.value.path === '/register/pending'`; assert
|
||||
`session.session` is still `{ verified: false }`.
|
||||
5. **403 with no session, or a verified session, passes through untouched** — two cases,
|
||||
`session = null` and `session = { verified: true }`, both `status = 'ready'`; mock fetch resolving
|
||||
status 403; navigate the router to a known path first; call `useApi().request(...)`; assert the
|
||||
promise rejects with `ApiError` status 403; assert the router's current route did not change;
|
||||
assert the session store's state object is unchanged (`toEqual` the pre-call snapshot).
|
||||
6. **429 exposes retryAfter parsed from the Retry-After header** — mock fetch resolving
|
||||
`{ ok: false, status: 429, headers: new Headers({ 'Retry-After': '30' }) }`; call
|
||||
`useApi().request('/v1/auth/login', { method: 'POST' })`; assert the rejection is an `ApiError`
|
||||
with `status === 429` and `retryAfter === 30`.
|
||||
7. **429 with no Retry-After header exposes retryAfter as undefined** — same as above with no header
|
||||
set; assert `retryAfter === undefined`.
|
||||
8. **Network failure throws NetworkError, not ApiError, and sets isOffline true** — set
|
||||
`Object.defineProperty(navigator, 'onLine', { value: true, configurable: true })`; mock fetch to
|
||||
`mockRejectedValue(new TypeError('Failed to fetch'))`; call `useApi().request('/v1/article/')`;
|
||||
assert the rejection is instanceof `NetworkError` and `not instanceof ApiError`; assert `isOffline`
|
||||
is `true` after the rejection settles.
|
||||
9. **A later successful request clears isOffline** — starting from `isOffline === true` (reuse the
|
||||
state from test 8, or set it directly if the module exposes a test seam), keep
|
||||
`navigator.onLine === true`, mock fetch resolving 200; call `useApi().request('/v1/article/')`;
|
||||
assert `isOffline` becomes `false` after the call resolves.
|
||||
10. **window online/offline events drive isOffline directly** — dispatch
|
||||
`window.dispatchEvent(new Event('offline'))`; assert `isOffline === true`; dispatch
|
||||
`window.dispatchEvent(new Event('online'))`; assert `isOffline === false`. If `useApi`'s offline
|
||||
tracking is implemented as a composable that needs to be "activated" (e.g. listeners attached on
|
||||
first `useApi()` call or app mount), call `useApi()` once before dispatching the events.
|
||||
11. **Regression: existing getMe/session/router-guard suites stay green** — run
|
||||
`web/src/router/__tests__/routerGuard.spec.ts` (and any existing `useApi`/`session` spec files)
|
||||
unmodified alongside the new tests in the same `task test:web` run; assert no existing test's
|
||||
assertions changed and all still pass. This is not a new scenario to author, just a check that the
|
||||
new request()/isOffline/clear() additions don't regress boot, session caching or the
|
||||
public/session/verified/any redirect matrix already covered by
|
||||
`features/nuxt_ui_shell_foundation.feature`.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real network offline test** — disable the machine's network adapter (or use browser devtools'
|
||||
"Offline" throttling) against a `bun dev` instance, trigger a real authenticated call, and confirm
|
||||
`isOffline` flips and a consuming banner-style UI would show, since Vitest only simulates a
|
||||
rejected `fetch`, never a real dropped connection.
|
||||
- **429 countdown UI** — this slice only proves `useApi` surfaces `retryAfter`; the actual countdown
|
||||
display (`useRetryAfter`, per `docs/design/components.md`) belongs to the login and
|
||||
password-reset screen slices and is out of scope here.
|
||||
@@ -0,0 +1,259 @@
|
||||
Feature: Articles list screen
|
||||
|
||||
As a verified RenewCMS owner
|
||||
I want /article to list every article with its status, let me search and filter it, and let me
|
||||
publish, unpublish or delete a row without leaving the page
|
||||
So that I can manage my whole set of articles from one place, with the list always reflecting
|
||||
what the server actually holds
|
||||
|
||||
Background:
|
||||
Given /article is a verified route (meta.auth "verified") rendered as ArticlesPage inside
|
||||
DashboardShell, per docs/design/routes.md
|
||||
And ArticlesPage calls GET /v1/article/ once per navigation to load the full list (all
|
||||
articles, online and draft, because the request carries the jwt cookie), and every row
|
||||
action goes over PATCH /v1/article/{id}/publish, PATCH /v1/article/{id}/unpublish or
|
||||
DELETE /v1/article/{id}; all three endpoints already exist and this slice wires the SPA to
|
||||
them unchanged, except for one persistence-layer change: ArticleRepository.FindByFilters
|
||||
(which backs GET /v1/article/) gains a .Preload("Images") call, mirroring the one
|
||||
ArticleRepository.Get already has, so every article in the list response carries its real
|
||||
Images array instead of coming back empty; no domain or use-case change is needed, since
|
||||
article.Article.Images already exists
|
||||
And the PageHeader shows breadcrumb "Articles" (mobile title "Articles")
|
||||
And search and the active tab live in the URL query string (?q=, ?status=), so the filtered
|
||||
view can be linked and survives a reload; the list is always sorted by last updated, newest
|
||||
first, with no interactive sort control in this slice; search is case-insensitive, ignores
|
||||
accents, matches anywhere in the title, and is debounced 120 ms; tab counts (All, Online,
|
||||
Drafts) are always computed from the full list fetched by GET /v1/article/, so they never
|
||||
change while the user is only searching
|
||||
And the list is rendered as ArticleTable (desktop, md and up) or ArticleCardList (below md),
|
||||
both fed by the same articles array, loading flag and per-row busy state, and both routed
|
||||
through the same row actions
|
||||
And each row shows its title (a real link to /article/:id/edit), an ArticleStatusBadge (online
|
||||
or draft), and its updated date (relative within 7 days, e.g. "5 days ago"; an absolute date
|
||||
beyond that, e.g. "18 Aug 2026", with the full timestamp in a title attribute)
|
||||
And clicking anywhere on a row except its menu button navigates to /article/:id/edit
|
||||
And each row's ellipsis button (or Enter while it is focused) opens that row's menu, named
|
||||
"Actions for {title}", listing Edit (to /article/:id/edit), Publish or Unpublish (whichever
|
||||
applies to that row's current status), Copy API URL, and, below a separator, "Delete…" in
|
||||
brick red, always last
|
||||
And "Copy API URL" writes the absolute URL "{origin}/v1/article/{id}" to the clipboard; on
|
||||
success it raises a success toast (icon circle-check, id "article-link-copied-{id}"): title
|
||||
"Link copied" text "The API URL for \"{title}\" is on your clipboard."; if the clipboard
|
||||
write itself fails, it raises an error toast instead (icon circle-x, id
|
||||
"article-link-copy-failed-{id}"): title "Couldn't copy the link", with no text line
|
||||
And row actions are never optimistic: a row only changes once the server responds; while a
|
||||
publish or unpublish request for a row is in flight, that row alone is disabled and shows a
|
||||
busy label ("Publishing…" or "Unpublishing…"), and every other row stays interactive
|
||||
And a successful publish or unpublish (200) updates that row's ArticleStatusBadge and updated
|
||||
date in place and raises a success toast (icon circle-check, id
|
||||
"article-published-{id}" or "article-unpublished-{id}" per components.md's action-plus-id
|
||||
toast convention): title "Article published" text "\"{title}\" is now online." for a
|
||||
publish, or title "Article unpublished" text "\"{title}\" is now offline." for an unpublish
|
||||
And a 500 or network failure on a publish or unpublish leaves that row exactly as it was
|
||||
before the request and raises an error toast (icon circle-x, duration 0 so it stays until
|
||||
dismissed or retried, id "article-publish-failed-{id}" or "article-unpublish-failed-{id}"):
|
||||
title "Couldn't publish" or "Couldn't unpublish", text "The server didn't respond. Nothing
|
||||
changed.", with an action "Retry" that re-sends the same request
|
||||
And a 404 on any row action (publish, unpublish or delete) closes any open row menu or delete
|
||||
dialog for that row, removes the row from the list, recomputes the tab counts from the
|
||||
remaining articles, and raises an info toast (icon info, id "article-not-found-{id}"): title
|
||||
"That article no longer exists" text "It was deleted somewhere else, so we removed it from
|
||||
the list."
|
||||
And "Delete…" opens ConfirmDialog: title "Delete this article?" text "\"{title}\" and its
|
||||
{n} images will be deleted. This can't be undone." (a components.md convention: the row's
|
||||
title plus its image count, "and its image" for exactly 1 and no image clause at all for 0),
|
||||
"Cancel" and "Delete article" (danger); while the DELETE request is in flight the dialog
|
||||
cannot be dismissed by Cancel, Esc or a backdrop click, both buttons are disabled, the
|
||||
confirm button reads "Deleting…" with a loading indicator, and aria-busy is set
|
||||
And a 500 on the delete request keeps the dialog open and instead shows an inline alert inside
|
||||
the dialog: "Couldn't delete the article, so it's still there. Try again in a moment.",
|
||||
re-enabling "Cancel" and "Delete article" so the user can retry or back out; the article is
|
||||
left untouched
|
||||
And a successful delete (204) closes the dialog, removes the row, moves focus to the next row
|
||||
(or the page heading if the deleted row was last), recomputes the tab counts, and raises a
|
||||
success toast (icon circle-check, id "article-deleted-{id}"): title "Article deleted" text
|
||||
"\"{title}\" and its images are gone."; this is also where a delete initiated from the editor
|
||||
ends up
|
||||
And the list, sorted by last updated with the most recently updated article first, holds
|
||||
these 6 articles by default: id 1 "How we price a bathroom renovation" (online, updated "2
|
||||
hours ago"), id 2 "Choosing lime plaster for old stone walls" (draft, updated "Yesterday"),
|
||||
id 3 "Before and after: the Rue Verte kitchen" (online, updated "3 days ago"), id 4 "Winter
|
||||
checklist for timber windows" (draft, updated "5 days ago"), id 5 "Why we reuse original
|
||||
floorboards" (online, updated "18 Aug 2026"), id 6 "Our first passive-house retrofit"
|
||||
(online, updated "2 Jul 2026") — 6 total, 4 online, 2 drafts
|
||||
|
||||
Scenario: Default — the list loads sorted by last updated, newest first
|
||||
Given GET /v1/article/ resolves 200 with the 6-article fixture above
|
||||
When the user navigates to /article
|
||||
Then the breadcrumb reads "Articles" (mobile title "Articles")
|
||||
And the tabs read "All 6" "Online 4" "Drafts 2"
|
||||
And the rows appear in exactly the fixture's order, each with its title link, its
|
||||
ArticleStatusBadge and its updated date
|
||||
And the table (desktop) carries a visually hidden caption "Articles, sorted by last updated,
|
||||
newest first"
|
||||
|
||||
Scenario: Loading — skeleton rows while the first request, or a retry, is in flight
|
||||
Given GET /v1/article/ has not yet resolved
|
||||
When the user navigates to /article
|
||||
Then 5 skeleton rows replace the table or card list, and the list container is aria-busy="true"
|
||||
When GET /v1/article/ then resolves 200 with the 6-article fixture
|
||||
Then the skeleton rows are replaced by the Default state's rows
|
||||
|
||||
Scenario: Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…
|
||||
Given the Default state, with the row for id 4 "Winter checklist for timber windows" (draft,
|
||||
updated "5 days ago")
|
||||
When the user activates that row's ellipsis button
|
||||
Then that row's menu opens, named "Actions for Winter checklist for timber windows", listing
|
||||
Edit, Publish, Copy API URL, and, below a separator, "Delete…" in brick red
|
||||
And every other row is unaffected
|
||||
|
||||
Scenario: Copy API URL succeeds — the absolute URL is copied and confirmed
|
||||
Given the Default state, with the row for id 4 "Winter checklist for timber windows"
|
||||
When the user chooses "Copy API URL" from id 4's row menu
|
||||
Then "{origin}/v1/article/4" is written to the clipboard
|
||||
And a success toast appears: icon circle-check, id "article-link-copied-4", title "Link
|
||||
copied", text "The API URL for \"Winter checklist for timber windows\" is on your
|
||||
clipboard."
|
||||
|
||||
Scenario: Copy API URL fails — the clipboard write itself is rejected
|
||||
Given the Default state, with the row for id 4 "Winter checklist for timber windows"
|
||||
When the user chooses "Copy API URL" from id 4's row menu, and the clipboard write fails
|
||||
Then an error toast appears: icon circle-x, id "article-link-copy-failed-4", title "Couldn't
|
||||
copy the link", with no text line
|
||||
|
||||
Scenario: Empty — zero articles
|
||||
Given GET /v1/article/ resolves 200 with zero articles
|
||||
When the user navigates to /article
|
||||
Then the tabs read "All 0" "Online 0" "Drafts 0"
|
||||
And an EmptyState (variant no-articles) replaces the list: icon file-text, title "No articles
|
||||
yet", text "Write your first one. It stays a draft until you publish it.", and a primary
|
||||
action "New article"
|
||||
When the user activates "New article"
|
||||
Then NewArticleModal opens
|
||||
|
||||
Scenario: No results — a search matches nothing, but the tab counts still show the full list
|
||||
Given the Default state has loaded
|
||||
When the user types "tiles" into the search field
|
||||
Then, after the 120 ms debounce, an EmptyState (variant no-results) replaces the list: icon
|
||||
search, title "No articles match \"tiles\"", text "Check the spelling, or search for a
|
||||
shorter part of the title.", and an action "Clear search"
|
||||
And the tabs still read "All 6" "Online 4" "Drafts 2"
|
||||
When the user activates "Clear search"
|
||||
Then the search field is cleared, the Default state's rows reappear, and focus returns to the
|
||||
search field
|
||||
|
||||
Scenario Outline: An empty tab shows its own EmptyState, while the other tab still counts correctly
|
||||
Given GET /v1/article/ resolves 200 with <fixture>
|
||||
When the user navigates to /article and selects the "<tab>" tab
|
||||
Then the tabs read "<counts>"
|
||||
And an EmptyState replaces the list: title "<title>" text "<text>" action "<action>"
|
||||
|
||||
Examples:
|
||||
| fixture | tab | counts | title | text | action |
|
||||
| 4 articles, all online, none drafts | Drafts | All 4, Online 4, Drafts 0 | No drafts | Everything you've written is online. | New article |
|
||||
| 4 articles, all drafts, none online | Online | All 4, Online 0, Drafts 4 | Nothing is online yet | Publish a draft and it will appear on your website. | Show drafts |
|
||||
|
||||
Scenario: Action in progress — publishing a row disables only that row
|
||||
Given the Default state has loaded
|
||||
When the user chooses "Publish" from id 4 "Winter checklist for timber windows"'s row menu
|
||||
Then PATCH /v1/article/4/publish is sent
|
||||
And that row alone is disabled and shows the busy label "Publishing…", while the other 5 rows
|
||||
stay interactive
|
||||
|
||||
Scenario: Published — a 200 updates the row in place and raises a toast
|
||||
Given the user chose "Publish" from id 4 "Winter checklist for timber windows"'s row menu, and
|
||||
the request is in flight
|
||||
When PATCH /v1/article/4/publish resolves 200 with the article, is_online true, updated "Just
|
||||
now"
|
||||
Then id 4's row shows an online ArticleStatusBadge and updated date "Just now", no longer busy
|
||||
And a success toast appears: icon circle-check, id "article-published-4", title "Article
|
||||
published", text "\"Winter checklist for timber windows\" is now online."
|
||||
|
||||
Scenario Outline: Action failed, publish — a 500 or a network error leaves the row unchanged and offers Retry
|
||||
Given the user chose "Publish" from id 4 "Winter checklist for timber windows"'s row menu, and
|
||||
the request is in flight
|
||||
When PATCH /v1/article/4/publish <failure>
|
||||
Then id 4's row stays unchanged (draft, updated "5 days ago", no longer busy), because a row
|
||||
never changes before the server responds
|
||||
And an error toast appears, staying until dismissed or retried: icon circle-x, id
|
||||
"article-publish-failed-4", title "Couldn't publish", text "The server didn't respond.
|
||||
Nothing changed.", action "Retry"
|
||||
When the user activates "Retry"
|
||||
Then PATCH /v1/article/4/publish is sent again
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario Outline: Action failed, unpublish — a 500 or a network error leaves the row unchanged and offers Retry
|
||||
Given the user chose "Unpublish" from id 3 "Before and after: the Rue Verte kitchen"'s row
|
||||
menu, and the request is in flight
|
||||
When PATCH /v1/article/3/unpublish <failure>
|
||||
Then id 3's row stays unchanged (online, updated "3 days ago", no longer busy), because a row
|
||||
never changes before the server responds
|
||||
And an error toast appears, staying until dismissed or retried: icon circle-x, id
|
||||
"article-unpublish-failed-3", title "Couldn't unpublish", text "The server didn't respond.
|
||||
Nothing changed.", action "Retry"
|
||||
When the user activates "Retry"
|
||||
Then PATCH /v1/article/3/unpublish is sent again
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario Outline: Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast
|
||||
Given the Default state has loaded
|
||||
When the user <trigger> for id 2 "Choosing lime plaster for old stone walls"
|
||||
And the request resolves 404
|
||||
Then id 2's row is removed from the list (and any open menu or dialog for it closes)
|
||||
And the tabs read "All 5" "Online 4" "Drafts 1"
|
||||
And an info toast appears: icon info, id "article-not-found-2", title "That article no longer
|
||||
exists", text "It was deleted somewhere else, so we removed it from the list."
|
||||
|
||||
Examples:
|
||||
| trigger |
|
||||
| chooses "Publish" from its row menu |
|
||||
| confirms "Delete article" in its ConfirmDialog |
|
||||
|
||||
Scenario: Row vanished, online row — a 404 on unpublish removes the row and shows an info toast
|
||||
Given the Default state has loaded
|
||||
When the user chooses "Unpublish" from id 3 "Before and after: the Rue Verte kitchen"'s row
|
||||
menu
|
||||
And the request resolves 404
|
||||
Then id 3's row is removed from the list (and any open menu closes)
|
||||
And the tabs read "All 5" "Online 3" "Drafts 2"
|
||||
And an info toast appears: icon info, id "article-not-found-3", title "That article no longer
|
||||
exists", text "It was deleted somewhere else, so we removed it from the list."
|
||||
|
||||
Scenario: Delete confirm — opens the dialog with the row's title and image count
|
||||
Given the Default state has loaded, and id 4 "Winter checklist for timber windows" has 3 images
|
||||
When the user chooses "Delete…" from id 4's row menu
|
||||
Then ConfirmDialog opens: title "Delete this article?" text "\"Winter checklist for timber
|
||||
windows\" and its 3 images will be deleted. This can't be undone.", "Cancel" and "Delete
|
||||
article" (danger)
|
||||
|
||||
Scenario: Delete in progress — the dialog cannot be dismissed while the request is in flight
|
||||
Given ConfirmDialog is open for id 4 "Winter checklist for timber windows"
|
||||
When the user activates "Delete article"
|
||||
Then DELETE /v1/article/4 is sent
|
||||
And both "Cancel" and the confirm button are disabled, the confirm button reads "Deleting…"
|
||||
with a loading indicator, aria-busy is set, and Esc and a backdrop click do not close it
|
||||
|
||||
Scenario: Delete failed — a 500 shows an inline alert inside the dialog and keeps the article
|
||||
Given ConfirmDialog is open for id 4 "Winter checklist for timber windows", and "Delete
|
||||
article" has been activated
|
||||
When DELETE /v1/article/4 resolves 500
|
||||
Then the dialog stays open, showing the alert "Couldn't delete the article, so it's still
|
||||
there. Try again in a moment.", with "Cancel" and "Delete article" re-enabled
|
||||
And id 4's row is untouched in the list behind the dialog
|
||||
|
||||
Scenario: Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row
|
||||
Given ConfirmDialog is open for id 4 "Winter checklist for timber windows", and "Delete
|
||||
article" has been activated
|
||||
When DELETE /v1/article/4 resolves 204
|
||||
Then the dialog closes, id 4's row is removed from the list, and focus moves to the next row
|
||||
And the tabs read "All 5" "Online 4" "Drafts 1"
|
||||
And a success toast appears: icon circle-check, id "article-deleted-4", title "Article
|
||||
deleted", text "\"Winter checklist for timber windows\" and its images are gone."
|
||||
@@ -0,0 +1,176 @@
|
||||
# QA: Articles list screen
|
||||
|
||||
## Implementation note for coder (informational, not a QA assertion)
|
||||
|
||||
`internal/infrastructure/persistence/repositories/articleRepository.go`'s `FindByFilters` gains
|
||||
`.Preload("Images")`, mirroring `Get`, so `GET /v1/article/` carries real `Images` arrays for the
|
||||
delete dialog's count. This is a persistence-layer change asserted by the existing
|
||||
`var _ domain.Repository = &ArticleRepository{}`; it needs no domain or use-case change, since
|
||||
`article.Article.Images` already exists.
|
||||
|
||||
Toast ids follow components.md's action-plus-id convention: "article-published-{id}",
|
||||
"article-unpublished-{id}", "article-publish-failed-{id}", "article-unpublish-failed-{id}",
|
||||
"article-not-found-{id}", "article-deleted-{id}", "article-link-copied-{id}",
|
||||
"article-link-copy-failed-{id}".
|
||||
|
||||
## HTTP-level (Go, `test/`, `task test`)
|
||||
|
||||
Build `article.NewHandler(...)` over the real use cases wired to a fake `article.Repository` (in
|
||||
the style of `test/article_test.go`'s `mockArticleRepo`), mounted on a fresh `chi.NewRouter()`
|
||||
mirroring `NewArticleRouter` in `api/router.go` (the outer group, no auth, for `GET /`). Call
|
||||
`validator.InitValidator()` first.
|
||||
|
||||
1. **GET /v1/article/ returns each article's images array populated** — seed the fake repository
|
||||
with an article whose `Images` slice holds two `image.Image` entries (ids 10 and 11), send
|
||||
`GET /v1/article/` with `httptest`, decode the JSON array, and assert the matching entry's
|
||||
`images` field is a 2-element array containing those ids (not `null` and not empty), proving
|
||||
the handler's response reflects `FindByFilters`'s `Images` data. Because the fake repository's
|
||||
`FindByFilters` is written by this test rather than GORM, it can't exercise the real
|
||||
`.Preload("Images")` SQL by itself — this test only proves the handler passes the repository's
|
||||
`Images` data through to JSON unchanged. The `.Preload` call itself against a real SQLite file is
|
||||
covered under Manual verification below.
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi`'s calls for `GET /v1/article/`, `PATCH /v1/article/{id}/publish`,
|
||||
`PATCH /v1/article/{id}/unpublish`, `DELETE /v1/article/{id}`, and the clipboard write (e.g.
|
||||
`navigator.clipboard.writeText`), per case; no real backend, no httptest server. Mount
|
||||
ArticlesPage inside DashboardShell with a resolved verified session and a test router at
|
||||
/article. Sample data throughout is the Background's 6-article fixture (ids 1-6, titles and dates
|
||||
as listed), unless a scenario's Examples row substitutes a smaller fixture.
|
||||
|
||||
1. **Default** — mock `GET /v1/article/` resolving 200 with the 6-article fixture; assert
|
||||
breadcrumb "Articles"; assert tabs "All 6" "Online 4" "Drafts 2"; assert the rows render in
|
||||
fixture order with title link, ArticleStatusBadge and updated date; assert the table's visually
|
||||
hidden caption reads "Articles, sorted by last updated, newest first".
|
||||
2. **Loading** — mock `GET /v1/article/` with an unresolved promise; assert 5 skeleton rows and
|
||||
`aria-busy="true"` on the list container; resolve 200 with the fixture; assert the Default
|
||||
state's rows replace the skeletons.
|
||||
3. **Row menu open** — from Default, click id 4's ellipsis button; assert the menu opens named
|
||||
"Actions for Winter checklist for timber windows" with items Edit, Publish, Copy API URL, and a
|
||||
separator then "Delete…"; assert no other row is affected.
|
||||
4. **Copy API URL succeeds** — from Default, mock the clipboard write to resolve, choose "Copy API
|
||||
URL" from id 4's menu; assert the clipboard write was called with
|
||||
`"{origin}/v1/article/4"` (using the test's `window.location.origin`); assert a toast was
|
||||
raised with id "article-link-copied-4", icon circle-check, title "Link copied", text "The API
|
||||
URL for \"Winter checklist for timber windows\" is on your clipboard."
|
||||
5. **Copy API URL fails** — from Default, mock the clipboard write to reject, choose "Copy API
|
||||
URL" from id 4's menu; assert a toast was raised with id "article-link-copy-failed-4", icon
|
||||
circle-x, title "Couldn't copy the link", and no description text.
|
||||
6. **Empty** — mock `GET /v1/article/` resolving 200 with `[]`; assert tabs "All 0" "Online 0"
|
||||
"Drafts 0"; assert the no-articles EmptyState (icon file-text, title "No articles yet", text,
|
||||
action "New article"); click it; assert NewArticleModal opens.
|
||||
7. **No results** — from Default, type "tiles" into the search field; advance past the 120 ms
|
||||
debounce (fake timers); assert the no-results EmptyState (icon search, title "No articles match
|
||||
\"tiles\"", text, action "Clear search"); assert tabs still read "All 6" "Online 4" "Drafts 2";
|
||||
click "Clear search"; assert the Default rows reappear and the search field has focus.
|
||||
8. **Empty tab, Examples-driven** — (a) mock `GET /v1/article/` with 4 online-only articles, select
|
||||
the Drafts tab, assert tabs "All 4" "Online 4" "Drafts 0" and the no-drafts EmptyState (icon
|
||||
pencil-line, title "No drafts", text "Everything you've written is online.", action "New
|
||||
article"); (b) mock 4 draft-only articles, select the Online tab, assert tabs "All 4" "Online 0"
|
||||
"Drafts 4" and the EmptyState titled "Nothing is online yet", text "Publish a draft and it will
|
||||
appear on your website.", action "Show drafts" (icon per the board, confirmed in the visual
|
||||
check below).
|
||||
9. **Action in progress** — from Default, choose "Publish" from id 4's menu; assert
|
||||
`PATCH /v1/article/4/publish` is called; assert id 4's row is disabled with busy label
|
||||
"Publishing…" and the other 5 rows stay enabled.
|
||||
10. **Published** — continuing from 9, resolve the PATCH 200 with `is_online: true`, updated "Just
|
||||
now"; assert id 4's row shows the online badge and "Just now", no longer busy; assert a toast
|
||||
was raised with id "article-published-4", icon circle-check, title "Article published", text
|
||||
"\"Winter checklist for timber windows\" is now online."
|
||||
11. **Action failed, publish, Examples-driven** — for each of a 500 response and a network
|
||||
rejection: choose "Publish" from id 4's menu, resolve/reject the mocked PATCH accordingly;
|
||||
assert id 4's row stays exactly unchanged (draft, "5 days ago", not busy) throughout — the row
|
||||
is never touched before the (failed) response, so there is nothing to revert; assert a toast
|
||||
with id "article-publish-failed-4", icon circle-x, title "Couldn't publish", text "The server
|
||||
didn't respond. Nothing changed.", action "Retry"; click "Retry"; assert the same PATCH is
|
||||
called again.
|
||||
12. **Action failed, unpublish, Examples-driven** — for each of a 500 response and a network
|
||||
rejection: choose "Unpublish" from id 3's menu (an online row), resolve/reject the mocked
|
||||
PATCH accordingly; assert id 3's row stays exactly unchanged (online, "3 days ago", not busy);
|
||||
assert a toast with id "article-unpublish-failed-3", icon circle-x, title "Couldn't
|
||||
unpublish", text "The server didn't respond. Nothing changed.", action "Retry"; click "Retry";
|
||||
assert the same PATCH is called again.
|
||||
13. **Row vanished, draft row, Examples-driven** — for each of (publish 404), (delete-confirm
|
||||
404) on id 2 (a draft row): trigger the action, resolve the mocked call 404; assert id 2's row
|
||||
is removed; assert tabs read "All 5" "Online 4" "Drafts 1"; assert a toast with id
|
||||
"article-not-found-2", icon info, title "That article no longer exists", text "It was deleted
|
||||
somewhere else, so we removed it from the list."
|
||||
14. **Row vanished, online row** — choose "Unpublish" from id 3's menu, resolve the mocked PATCH
|
||||
404; assert id 3's row is removed; assert tabs read "All 5" "Online 3" "Drafts 2"; assert a
|
||||
toast with id "article-not-found-3", icon info, title "That article no longer exists", text
|
||||
"It was deleted somewhere else, so we removed it from the list."
|
||||
15. **Delete confirm** — from Default (with id 4 carrying 3 images in the mocked
|
||||
`GET /v1/article/` response), choose "Delete…" from id 4's menu; assert ConfirmDialog opens
|
||||
with title "Delete this article?", text "\"Winter checklist for timber windows\" and its 3
|
||||
images will be deleted. This can't be undone.", "Cancel" and "Delete article".
|
||||
16. **Delete in progress** — from the open dialog, click "Delete article"; assert
|
||||
`DELETE /v1/article/4` is called; assert both buttons are disabled, the confirm button reads
|
||||
"Deleting…", `aria-busy` is set, and Esc / backdrop click do not close it.
|
||||
17. **Delete failed** — continuing from 16, resolve the DELETE 500; assert the dialog stays open
|
||||
with the inline alert "Couldn't delete the article, so it's still there. Try again in a
|
||||
moment.", both buttons re-enabled; assert id 4's row is unchanged in the list behind the
|
||||
dialog.
|
||||
18. **Deleted** — continuing from 16 (a fresh attempt), resolve the DELETE 204; assert the dialog
|
||||
closes, id 4's row is removed, focus moves to the next row; assert tabs read "All 5" "Online
|
||||
4" "Drafts 1"; assert a toast with id "article-deleted-4", icon circle-check, title "Article
|
||||
deleted", text "\"Winter checklist for timber windows\" and its images are gone."
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/screens/articles.md` and `docs/design/source/RenewCMS Screens
|
||||
Back-office.dc.html`, group "Articles". Run `task visual:check -- articles` for each state below
|
||||
and compare against the board frame; add each state's mock setup to `web/visual/states.ts` first,
|
||||
reusing the 6-article fixture (ids/titles/dates above) as sample data. This screen shows relative
|
||||
dates ("2 hours ago", "5 days ago"), so fix `now` to the value the board's own script assumes for
|
||||
those relative strings to render identically (derive it from "2 hours ago" against "12 Mar 2026"
|
||||
and the absolute dates shown, e.g. "18 Aug 2026", per the board's `base` fixture in the source
|
||||
file).
|
||||
|
||||
- [ ] Default: tabs "All 6/Online 4/Drafts 2", 6 rows in fixture order, title/status/updated
|
||||
columns (confirm against the board frame directly whether a Created column is also shown —
|
||||
components.md's prose and the rendered board table disagree on this).
|
||||
- [ ] Row menu open: id 4's menu open with Edit/Publish/Copy API URL/Delete…, matching the board's
|
||||
dropdown placement and the brick-red Delete… treatment.
|
||||
- [ ] Loading: 5 skeleton rows shaped like the real layout.
|
||||
- [ ] Empty: EmptyState icon file-text, title, text, single "New article" action.
|
||||
- [ ] No results: EmptyState icon search, title with the quoted search term, text, "Clear search".
|
||||
- [ ] No drafts / Nothing online: both empty-tab EmptyStates, including the "Nothing is online
|
||||
yet" state's icon (confirm it directly against the board frame, since it isn't given in the
|
||||
prose extraction read for this slice).
|
||||
- [ ] Action in progress: id 4's row at 50% opacity with a spinner in the status cell and
|
||||
"aria-busy".
|
||||
- [ ] Published / Action failed toasts: colors, icons and the "Retry" action match the board.
|
||||
- [ ] Copy API URL: no board state depicts its toasts; verify their copy and icons directly in the
|
||||
app against this spec rather than a board frame.
|
||||
- [ ] Row vanished: the info toast and the row's removal match the board.
|
||||
- [ ] Delete confirm / in progress / failed: the dialog's three states match the board exactly,
|
||||
including the inline alert's placement in the failed state.
|
||||
- [ ] Deleted: toast copy matches, and focus visibly lands on the next row.
|
||||
- [ ] Keyboard/focus: roving tabindex across the tab list (Tab enters on the selected tab, ←/→
|
||||
move and activate); each row's title link is reachable and Enter opens the editor; each
|
||||
row's menu opens with Enter/Space/↓, arrow keys and type-ahead navigate it, Home/End jump to
|
||||
the ends, Esc closes it and returns focus to the ellipsis button; ConfirmDialog traps focus,
|
||||
starts on Cancel, and Enter does not confirm by default; on a successful delete, focus lands
|
||||
on the next row (or the page heading if none).
|
||||
- [ ] Motion: skeleton pulse and modal open/close follow the standard timings and stop under
|
||||
reduced motion.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real GET/PATCH/DELETE round trips against a running server** — Vitest only mocks `useApi`;
|
||||
confirm the real 200/404/500 responses, and the real `updated_at`-based ordering, against
|
||||
`task dev` with a live SQLite file, since these automated tests never exercise the actual Go
|
||||
handlers or use cases.
|
||||
- **Real .Preload("Images") behavior against a live SQLite file** — confirm `GET /v1/article/`
|
||||
returns accurate, non-empty `images` arrays for articles with real uploaded images, and `[]` for
|
||||
articles with none, against a running server, since the HTTP-level Go test above only proves the
|
||||
handler forwards whatever the (fake) repository returns, not that the real GORM query populates
|
||||
it correctly.
|
||||
- **Real image-count accuracy in the delete dialog** — confirm the delete dialog's count matches
|
||||
an article's actual attached images end to end (upload images, then delete), since Vitest only
|
||||
ever supplies a mocked count.
|
||||
- **Toast placement and stacking on a live app, including the three-toast cap** — confirm against
|
||||
a running app, since Vitest only asserts `useToast().add` was called with the right arguments.
|
||||
- **Real clipboard write on a live app** — confirm `navigator.clipboard.writeText` actually places
|
||||
the URL on the system clipboard in a real browser, since Vitest only mocks the clipboard API.
|
||||
@@ -0,0 +1,94 @@
|
||||
Feature: AuthShell and log-in screen conform to the design spec
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want AuthShell and LoginView built from the exact primitives docs/design/components.md
|
||||
specifies — AppLogo, UPageCard's own title/description styling, the muted page background,
|
||||
UForm with "Forgot password?" in the Password field's #hint slot, and the neutral-by-default,
|
||||
red-on-error input border
|
||||
So that the log-in screen (and every other AuthShell-based screen) matches the visual spec of
|
||||
record instead of ad hoc markup that happens to look similar
|
||||
|
||||
Background:
|
||||
Given AuthShell is the layout for every public route, per docs/design/routes.md
|
||||
And AuthShell composes a UPageCard variant="outline" as the centered card, with AppLogo
|
||||
rendered as the card's first element — left-aligned above the title/description block,
|
||||
sharing the card's own 24px gap — not as a separate element centered outside/above the
|
||||
card, per docs/design/components.md's AuthShell section and
|
||||
docs/design/source/AuthCard.dc.html
|
||||
And AppLogo is a reusable component (props include size) rendering the brand mark from
|
||||
docs/design/source/brand/favicon.svg as inline SVG, not an <img>
|
||||
And UPageCard's title renders through its own title slot as an h1 (tabindex="-1", so route
|
||||
changes can move focus to it, per AuthShell's a11y spec), and its description renders
|
||||
through UPageCard's description prop — neither is freeform markup in the default slot
|
||||
And from the sm breakpoint up, the centered <main> sits on the muted page background
|
||||
(--ui-bg-muted), the same canvas the rest of the app uses; below sm it sits on --ui-bg
|
||||
instead, per docs/design/components.md's AuthShell "Responsive" line
|
||||
And LoginView's form is a UForm wrapping the Username and Password UFormFields and the
|
||||
submit button
|
||||
And the Password UFormField's "Forgot password?" link renders through the field's #hint
|
||||
slot, in the position the Auth board draws it — right-aligned on the label row — per
|
||||
docs/design/source/RenewCMS Screens Auth.dc.html, group "Log in"
|
||||
And every UInput on the page uses the neutral control-edge border (--ui-border-accented) by
|
||||
default, and turns to the error edge (--ui-error) only when its field has an error, per
|
||||
docs/design/components.md's Input section (":highlight bound to the field error") and
|
||||
foundations.md's Borders and elevation
|
||||
|
||||
Scenario: AppLogo renders the brand mark as inline SVG, and theme-switches
|
||||
Given a public screen renders inside AuthShell
|
||||
When AuthShell mounts
|
||||
Then an AppLogo renders as UPageCard's first element, inside the card, left-aligned, sharing
|
||||
the card's 24px gap with the title/description block below it — not centered above the card
|
||||
And the logo is inline SVG sourced from docs/design/source/brand/favicon.svg, at 32px, not as
|
||||
an <img> tag
|
||||
And the logo carries alt="RenewCMS" (role="img" aria-label="RenewCMS" on the SVG)
|
||||
And the logo is not wrapped in a link, since there is nothing public to go back to
|
||||
And the black square has no edge stroke in light mode or in dark mode, per
|
||||
docs/design/source/AuthCard.dc.html's renderVals(), which computes the logo's edge as the
|
||||
literal, non-theme-branched value "transparent"
|
||||
|
||||
Scenario: UPageCard's title is the page h1, and its description uses UPageCard's own styling
|
||||
Given AuthShell renders with title "Log in" and description
|
||||
"Welcome back. Log in to manage your articles."
|
||||
When AuthShell mounts
|
||||
Then the card is a UPageCard with variant="outline"
|
||||
And UPageCard's title slot renders an h1 with tabindex="-1" containing "Log in"
|
||||
And UPageCard's description prop is "Welcome back. Log in to manage your articles."
|
||||
And neither the title nor the description is rendered as separate freeform markup inside
|
||||
UPageCard's default slot
|
||||
|
||||
Scenario: The page sits on the muted background from sm up, and on the default background below sm
|
||||
Given AuthShell renders any public screen at a viewport of sm or wider
|
||||
When AuthShell mounts
|
||||
Then the centered <main> uses the muted background token (--ui-bg-muted)
|
||||
And the card keeps its border, is capped at 400px, and its top sits 12vh from the top
|
||||
|
||||
Scenario: Below sm, AuthShell drops the muted background, the card border and the card padding
|
||||
Given AuthShell renders any public screen at a viewport below sm
|
||||
When AuthShell mounts
|
||||
Then the centered <main> uses the default background token (--ui-bg), not the muted one
|
||||
And the card has no border and fills the screen with 20px padding
|
||||
And the fields and buttons on the page render at the xl size
|
||||
|
||||
Scenario: The login form is a UForm
|
||||
Given the user is on /login
|
||||
When the login form renders
|
||||
Then the form is a UForm, not a plain HTML <form>, wrapping the Username UFormField, the
|
||||
Password UFormField and the submit button
|
||||
|
||||
Scenario: "Forgot password?" renders in the Password field's hint slot
|
||||
Given the user is on /login
|
||||
When the login form renders
|
||||
Then the Password UFormField's "Forgot password?" link is rendered through the field's
|
||||
#hint slot
|
||||
And the Password field's label reads only "Password", with no link inside it
|
||||
|
||||
Scenario: Inputs are neutral by default and turn to the error edge only when their field has an error
|
||||
Given the user is on /login with no validation errors yet
|
||||
When the Username and Password UInput fields render
|
||||
Then both use the neutral control-edge border (--ui-border-accented), with :highlight false
|
||||
When the user submits with Username "ma" and Password empty (the Validation errors state,
|
||||
per docs/design/screens/log-in.md)
|
||||
Then the Username UInput's edge turns to the error color (--ui-error), with :highlight true,
|
||||
alongside the message "Use at least 3 characters."
|
||||
And the Password UInput's edge turns to the error color (--ui-error), with :highlight true,
|
||||
alongside the message "Enter your password."
|
||||
@@ -0,0 +1,88 @@
|
||||
# QA: AuthShell and log-in screen conform to the design spec
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
No `/v1` calls are relevant to these assertions; mount AuthShell and LoginView directly (LoginView
|
||||
already needs `useApi`/`GET /v1/auth/registration` mocked per features/login_screen.qa.md's setup,
|
||||
reused here only as scaffolding, not as the thing under test).
|
||||
|
||||
1. **AppLogo renders inline SVG with no edge in either mode** — mount AuthShell with title
|
||||
"Log in"; assert an inline `<svg>` (not `<img>`) renders as UPageCard's first child element,
|
||||
left-aligned (not centered) above the title/description block, sharing the card's 24px gap, at
|
||||
32px, with `role="img" aria-label="RenewCMS"`, and is not inside an `<a>`; assert its outer
|
||||
square has no stroke in light mode; switch to dark mode (toggle the `.dark` class the way
|
||||
`nuxt_ui_shell_foundation`'s color-mode tests do) and assert the outer square still has no
|
||||
stroke, per `docs/design/source/AuthCard.dc.html`'s literal, non-theme-branched
|
||||
`edge: 'transparent'`.
|
||||
2. **UPageCard title is an h1 with tabindex="-1"** — mount AuthShell with title "Log in" and
|
||||
description "Welcome back. Log in to manage your articles."; assert
|
||||
`wrapper.find('h1').attributes('tabindex')` is `"-1"` and its text is "Log in"; assert the
|
||||
description text "Welcome back. Log in to manage your articles." renders via UPageCard's
|
||||
`description` prop (not as a sibling `<p>` hand-authored in AuthShell's template). This
|
||||
replaces the older assertion in `AuthShell.spec.ts` that only checked for any `<h1>`; keep that
|
||||
assertion passing (h1 text still equals the title) while adding the tabindex check.
|
||||
3. **Muted background at sm and up** — mount AuthShell at a `>= 640px` viewport (mock
|
||||
`useBreakpoints`/matchMedia the way other shell tests do); assert `<main>`'s class list
|
||||
includes the muted background utility (e.g. `bg-muted`); assert the card renders with a
|
||||
border and is capped at 400px width, offset 12vh from the top.
|
||||
4. **Default background, borderless card, 20px padding, xl controls below sm** — mount AuthShell
|
||||
at a `< 640px` viewport; assert `<main>`'s class list includes the default background utility
|
||||
(`bg-default`) and not the muted one; assert the card has no border class; assert the card's
|
||||
padding is 20px; assert the Username/Password inputs and submit button render at `size="xl"`.
|
||||
5. **Login form is a UForm** — mount LoginView at /login; assert the form wrapping the fields is
|
||||
a UForm component instance, not a bare `<form>` element with only `@submit.prevent` wired by
|
||||
hand.
|
||||
6. **Forgot password in the #hint slot** — mount LoginView at /login; assert the Password
|
||||
UFormField is passed content (or renders content) in its `hint` slot containing a
|
||||
"Forgot password?" link to /register/reset/request; assert the field's rendered label text is
|
||||
exactly "Password" with no link inside it.
|
||||
7. **Inputs default to neutral, then highlight red on error** — mount LoginView at /login; assert
|
||||
the Username and Password `UInput` components render with `highlight` falsy (or absent) and
|
||||
the theme's default ring class (`ring-(--ui-border-accented)`); then trigger the Validation
|
||||
errors state (type "ma" into Username, submit with Password empty); assert both `UInput`s now
|
||||
receive `:highlight="true"` (or the field's error is otherwise truthy) so the edge renders in
|
||||
`--ui-error`, alongside "Use at least 3 characters." and "Enter your password." respectively.
|
||||
|
||||
### Implementation finding for coder
|
||||
`web/src/views/LoginView.vue`'s Username and Password `UInput`s currently receive no `:highlight`
|
||||
prop at all — `UFormField` gets `:error="usernameError"` / `:error="passwordError"`, but that value
|
||||
is never forwarded to the inner `UInput`. Today the edge never turns red in the Validation errors
|
||||
state; this isn't a stale/inverted binding, it's a missing one. Wiring `:highlight="!!usernameError"`
|
||||
/ `:highlight="!!passwordError"` (or equivalent) onto each `UInput` is the fix scenario 7 above
|
||||
pins down.
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Log in", plus
|
||||
`docs/design/source/RenewCMS Components Product.dc.html`'s AuthShell drawing and
|
||||
`docs/design/source/RenewCMS Components Primitives.dc.html`'s Input drawing.
|
||||
|
||||
- [ ] AppLogo renders inside UPageCard as its first element, left-aligned above the title (not
|
||||
centered above the card), sharing the card's 24px gap; the mark matches the favicon.svg
|
||||
drawing exactly (white R on a black square), desktop and mobile; no edge stroke is visible
|
||||
around the square in either light or dark mode.
|
||||
- [ ] UPageCard title ("Log in") renders at the card-title size/weight from the AuthShell drawing
|
||||
and receives focus (visible focus ring) on route change; description text matches
|
||||
"Welcome back. Log in to manage your articles." styling, desktop and mobile, light and dark.
|
||||
- [ ] At sm and up: 400px card with its border, 12vh top offset, on the muted page canvas, light
|
||||
and dark.
|
||||
- [ ] Below sm: card fills the screen borderless with 20px padding on the default (not muted)
|
||||
background; fields and the submit button render at the touch (xl, 44px) size, light and
|
||||
dark.
|
||||
- [ ] The Password field's "Forgot password?" link sits right-aligned on the label row (the hint
|
||||
position), matching the Auth board, desktop and mobile, light and dark.
|
||||
- [ ] Username and Password fields show the neutral control-edge border by default; in the
|
||||
Validation errors state (short username, empty password) both fields' edges turn to the
|
||||
error color alongside their messages, in both light and dark.
|
||||
- [ ] Keyboard/focus: Tab order remains Username → Password → show/hide toggle → "Forgot
|
||||
password?" → submit; UPageCard's h1 is reachable/focusable via tabindex="-1" on route
|
||||
change, matching AuthShell's existing a11y spec.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **AppLogo reused in DashboardShell** — out of scope for this slice (AuthShell/login only).
|
||||
`DashboardShell.vue`'s sidebar header is currently a plain `<span class="font-semibold">
|
||||
RenewCMS</span>` (not AppLogo), and its footer is a bare `<span>Log out</span>` instead of the
|
||||
UserMenu component components.md specifies. Both need their own "Built from" conformance pass in
|
||||
a later DashboardShell slice; AppLogo (built here with a `size` prop) will be ready to drop in
|
||||
at that point without changes.
|
||||
@@ -0,0 +1,101 @@
|
||||
Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want DashboardShell built from the exact primitives docs/design/components.md specifies —
|
||||
AppLogo plus the "RenewCMS" wordmark in the sidebar header, and a UserMenu (UDropdownMenu on a
|
||||
ghost UButton with a UAvatar) in the sidebar footer, with a collapsed rail that hides text but
|
||||
never drops an accessible name
|
||||
So that every verified route's sidebar matches the visual spec of record and stays usable when
|
||||
collapsed, instead of the current plain-text "RenewCMS" header and "Log out" footer
|
||||
|
||||
Background:
|
||||
Given DashboardShell is the layout for every verified route, per docs/design/routes.md
|
||||
And DashboardShell composes a UDashboardGroup storage="local" with a UDashboardSidebar
|
||||
collapsible :resizable="false" :default-size="14" :collapsed-size="3.5", per
|
||||
docs/design/components.md's DashboardShell section
|
||||
And the sidebar's #header slot renders AppLogo (the same reusable inline-SVG brand mark
|
||||
AuthShell uses) followed by the "RenewCMS" wordmark text, not the plain
|
||||
<span class="font-semibold">RenewCMS</span> the shell renders today
|
||||
And the sidebar's #default slot is a UNavigationMenu orientation="vertical" :collapsed bound
|
||||
to the sidebar's own collapsed state, with items Home, Articles and Integration
|
||||
And the sidebar's #footer slot renders a UserMenu component, not the plain
|
||||
<span>Log out</span> text the shell renders today
|
||||
And UserMenu is a UDropdownMenu :content="{ side: 'top', align: 'start' }" anchored to a
|
||||
ghost UButton block containing a UAvatar and the session's username
|
||||
And the UAvatar's initials are the first two characters of the username, upper-cased (for
|
||||
example "marta" gives "MA"), and the avatar is aria-hidden because the username is shown
|
||||
next to it
|
||||
And UserMenu's dropdown groups, top to bottom, are: an account label (the username); a
|
||||
checkbox group of System, Light and Dark bound to useColorMode().preference, with the
|
||||
current preference's item checked; then Log out, in its own trailing group
|
||||
And Log out calls POST /v1/auth/logout inside a try/finally with a loading flag (no catch),
|
||||
then calls session.clear() and navigates to /login — the exact same shape PendingView.vue
|
||||
and VerifyView.vue already use for their own log-out actions, not a new resilience pattern
|
||||
And per docs/design/components.md's DashboardShell a11y note, in the collapsed rail the
|
||||
UNavigationMenu's items show tooltips and keep their aria-label; the wordmark and UserMenu
|
||||
apply that same spirit when the sidebar collapses to :collapsed-size="3.5"
|
||||
|
||||
Scenario: The sidebar header renders AppLogo and the "RenewCMS" wordmark
|
||||
Given the user is on any verified route with the sidebar expanded
|
||||
When DashboardShell mounts
|
||||
Then the sidebar's #header slot renders an AppLogo before the text "RenewCMS"
|
||||
And the wordmark text "RenewCMS" is visible next to the logo
|
||||
And neither the logo nor the wordmark is a link, since there is nothing to navigate to from
|
||||
the shell itself
|
||||
|
||||
Scenario: The sidebar collapse trigger toggles the rail and exposes aria-expanded
|
||||
Given the user is on any verified route with the sidebar expanded
|
||||
When the user activates UDashboardSidebarCollapse in the navbar's leading slot
|
||||
Then the sidebar transitions to its :collapsed-size="3.5" icon-only rail
|
||||
And UDashboardSidebarCollapse carries aria-expanded, reflecting the sidebar's new collapsed
|
||||
state
|
||||
When the user activates UDashboardSidebarCollapse again
|
||||
Then the sidebar returns to its :default-size="14" expanded width
|
||||
And aria-expanded reflects the expanded state
|
||||
|
||||
Scenario: UserMenu's dropdown lists the account label, color-mode checkboxes, then Log out
|
||||
Given the session's username is "marta"
|
||||
When the user activates the UserMenu trigger in the sidebar footer
|
||||
Then the dropdown opens with a UAvatar showing initials "MA" and the trigger's own username
|
||||
text "marta"
|
||||
And the first group is the account label showing "marta"
|
||||
And the second group is a checkbox group with items System, Light and Dark, and exactly the
|
||||
item matching the current useColorMode().preference is checked
|
||||
And the third and final group contains only "Log out", separated from the color-mode group
|
||||
|
||||
Scenario Outline: Selecting a color-mode item updates the preference and its checked state
|
||||
Given the user has opened the UserMenu dropdown
|
||||
And the current useColorMode().preference is "<from>"
|
||||
When the user activates the "<to>" checkbox item
|
||||
Then useColorMode().preference is set to "<to>"
|
||||
And the "<to>" item is checked and the "<from>" item is no longer checked
|
||||
|
||||
Examples:
|
||||
| from | to |
|
||||
| system | light |
|
||||
| light | dark |
|
||||
| dark | system |
|
||||
|
||||
Scenario: A successful logout clears the session and navigates to /login
|
||||
Given the user has opened the UserMenu dropdown
|
||||
When the user activates "Log out"
|
||||
Then a POST request is made to /v1/auth/logout
|
||||
And once it resolves, the local session is cleared
|
||||
And the user is navigated to /login
|
||||
|
||||
Scenario: Collapsed sidebar hides the wordmark and username text but keeps an icon-only rail
|
||||
Given the user is on any verified route
|
||||
When the sidebar is collapsed to its :collapsed-size="3.5" icon-only rail
|
||||
Then the AppLogo icon in the sidebar header remains visible, but the "RenewCMS" wordmark text
|
||||
is hidden, not merely visually truncated
|
||||
And the header keeps an accessible name for the brand mark (an aria-label or sr-only text
|
||||
reading "RenewCMS" on the logo or its wrapper), so collapsing the rail is not a pure a11y
|
||||
regression
|
||||
And the UserMenu trigger in the sidebar footer still shows the UAvatar, but the username text
|
||||
next to it is hidden
|
||||
And the UserMenu trigger keeps an accessible name that includes the username (an aria-label
|
||||
such as "marta — account menu", or a tooltip plus aria-label), matching the same
|
||||
tooltip/aria-label spirit the collapsed rail's UNavigationMenu items already use per
|
||||
docs/design/components.md's DashboardShell a11y note
|
||||
And activating the collapsed UserMenu trigger still opens the same dropdown, with the same
|
||||
account label, color-mode checkbox group and Log out item as the expanded state
|
||||
@@ -0,0 +1,65 @@
|
||||
# QA: DashboardShell branding (wordmark, sidebar collapse, UserMenu)
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mount DashboardShell (or a verified route rendered inside it) with a resolved session (username
|
||||
"marta") and a test router. Mock `useApi`'s `/v1/auth/logout` call per case; no real backend, no
|
||||
httptest server. Stub `useColorMode()` so its `preference` can be asserted and set.
|
||||
|
||||
1. **Header renders AppLogo and the wordmark** — mount DashboardShell expanded; assert the
|
||||
sidebar's #header slot renders an AppLogo followed by the text "RenewCMS"; assert neither is a
|
||||
link.
|
||||
2. **Sidebar collapse trigger toggles the rail and aria-expanded** — mount DashboardShell
|
||||
expanded; activate UDashboardSidebarCollapse; assert the sidebar reaches its
|
||||
:collapsed-size="3.5" state and the trigger's aria-expanded reflects it; activate it again; assert
|
||||
the sidebar returns to :default-size="14" and aria-expanded flips back.
|
||||
3. **UserMenu dropdown groups and order** — mount with username "marta" and
|
||||
`useColorMode().preference` set to each of "system", "light", "dark" in turn; open the UserMenu
|
||||
dropdown; assert the avatar shows initials "MA"; assert the trigger shows "marta"; assert the
|
||||
first group is the account label "marta"; assert the second group has System, Light and Dark
|
||||
checkbox items with exactly the current preference's item checked; assert the third, separate
|
||||
group contains only "Log out".
|
||||
4. **Selecting a color-mode item updates the preference** — Examples-driven: (system → light),
|
||||
(light → dark), (dark → system). Open the dropdown, activate the target item, assert
|
||||
`useColorMode().preference` is set to the target and its checkbox item is checked while the
|
||||
previous one is not.
|
||||
5. **Successful logout clears the session and navigates** — mock `POST /v1/auth/logout` resolving
|
||||
200; open the UserMenu dropdown; activate "Log out"; assert the mock was called; assert the
|
||||
session store's `clear()` is called once it resolves; assert the router navigates to /login.
|
||||
6. **Collapsed sidebar hides wordmark/username text but keeps an accessible name** — mount
|
||||
DashboardShell and collapse the sidebar to :collapsed-size="3.5"; assert the AppLogo icon is still
|
||||
present but the "RenewCMS" text node is not rendered (or is visually hidden, e.g. sr-only) while
|
||||
an accessible name for the logo (aria-label or sr-only text reading "RenewCMS") is still present
|
||||
in the accessibility tree; assert the UserMenu trigger still renders the UAvatar but not the
|
||||
visible "marta" text node, while the trigger's accessible name still includes "marta" (via
|
||||
aria-label or a tooltip plus aria-label); activate the collapsed trigger and assert the same
|
||||
dropdown (account label, color-mode group, Log out) opens as in the expanded state.
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/components.md`'s DashboardShell section and
|
||||
`docs/design/source/RenewCMS Screens Back-office.dc.html`. Run `task visual:check -- <a verified
|
||||
screen, e.g. home>` for each state and compare the sidebar header/footer against the board frame.
|
||||
|
||||
- [ ] Expanded sidebar: AppLogo plus "RenewCMS" wordmark in the header; UserMenu in the footer
|
||||
shows the avatar and username, matching the board exactly at desktop and mobile.
|
||||
- [ ] UserMenu dropdown: opens above the trigger (`side: 'top'`), left-aligned (`align: 'start'`),
|
||||
with the account label, the System/Light/Dark checkbox group and Log out visually separated
|
||||
as in the board.
|
||||
- [ ] Color mode selection: choosing Light/Dark/System visibly re-themes the app and moves the
|
||||
checkmark to the selected item.
|
||||
- [ ] Collapsed sidebar (icon-only rail): the AppLogo icon and the avatar remain visible; the
|
||||
"RenewCMS" wordmark and the username text are hidden; nothing overflows or clips awkwardly at
|
||||
the :collapsed-size="3.5" width, matching the board's collapsed rail treatment.
|
||||
- [ ] Keyboard/focus: Tab reaches UDashboardSidebarCollapse, then the UserMenu trigger; the
|
||||
dropdown opens with Enter/Space, arrow keys move through the checkbox group, and Esc closes
|
||||
it and returns focus to the trigger, in both the expanded and collapsed rail.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real POST /v1/auth/logout against a running server** — Vitest only mocks `useApi`; confirm the
|
||||
real 200 round-trip against `task dev` with a live SQLite file, since these automated tests
|
||||
never exercise the actual Go handler.
|
||||
- **Real useColorMode persistence across reload** — confirm a selected preference survives a full
|
||||
page reload via the plugin's `colorMode: true` storage, since Vitest only asserts the in-memory
|
||||
preference value.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,124 @@
|
||||
Feature: Home screen
|
||||
|
||||
As a logged-in RenewCMS owner
|
||||
I want /home to summarize how many articles I have, how many are online or drafts, and my five
|
||||
most recently updated articles, all from the single GET /v1/article/ call the route already
|
||||
makes
|
||||
So that I land somewhere useful after logging in, instead of an empty dashboard or a screen that
|
||||
silently fails
|
||||
|
||||
Background:
|
||||
Given /home is a verified route (meta.auth "verified") rendered as HomePage inside
|
||||
DashboardShell, per docs/design/routes.md
|
||||
And HomePage calls GET /v1/article/ once per navigation; there is no separate stats endpoint,
|
||||
so the three stat tiles and the recent list are both derived from that one response
|
||||
And the PageHeader shows breadcrumb "Home" (mobile title "Home") and a primary action "New
|
||||
article" opening NewArticleModal
|
||||
And a "Connect your website" card is always rendered on the page, in every state (Loading,
|
||||
Default, Empty and Error alike): icon plug, title "Connect your website", text "Your site
|
||||
reads published articles from RenewCMS. Share this guide with whoever builds it.", and a
|
||||
link "Open the guide" navigating to /integration
|
||||
And a greeting reading "{greeting}, {username}" sits above the subtitle in every state
|
||||
(Loading, Default, Empty and Error alike); it is computed from the session's username and
|
||||
the device's local hour via Date.getHours(), needs no request of its own since the session
|
||||
is already resolved by the app-boot flow before HomePage ever renders, and so never has its
|
||||
own loading or error state
|
||||
And hours 0 through 11 give the greeting "Good morning", hours 12 through 17 give "Good
|
||||
afternoon", and hours 18 through 23 give "Good evening"
|
||||
And once the list resolves with at least one article, the subtitle reads "Here's where things
|
||||
stand on your site.", three stat tiles show the total article count labelled "articles", the
|
||||
online count labelled "online" and the draft count labelled "drafts", and a recent list shows
|
||||
the 5 most recently updated articles, each with its title, an ArticleStatusBadge (online or
|
||||
draft) and its updated date, linking to /article/:id/edit
|
||||
And while the list request is in flight, skeleton placeholders replace the stat tiles and the
|
||||
recent list rows, but only after 150 ms, so a fast response never flashes a skeleton
|
||||
And when the list resolves with zero articles, the subtitle reads "Welcome to RenewCMS.", and
|
||||
the stat tiles and recent list are replaced by an empty state: icon file-text, title "Write
|
||||
your first article", text "It starts as a draft, so nothing appears on your site until you
|
||||
publish it.", and a single action "New article" that opens NewArticleModal
|
||||
And when the list request fails with a 500 or a network error, the breadcrumb, greeting and
|
||||
subtitle stay visible, the subtitle stays "Here's where things stand on your site.", and the
|
||||
stat tiles and recent list are replaced by an inline role="alert" banner: icon circle-x,
|
||||
title "Couldn't load your articles", text "Check your connection and try again.", and a
|
||||
single action "Try again"
|
||||
|
||||
Scenario: Skeletons appear only after 150 ms, never for a fast response
|
||||
Given the user navigates to /home for the first time in this session
|
||||
When GET /v1/article/ has not resolved 150 ms after the navigation
|
||||
Then skeleton placeholders replace the three stat tiles and the five recent-list rows
|
||||
And the recent list container is aria-busy="true"
|
||||
When GET /v1/article/ then resolves 200 with a non-empty list of articles
|
||||
Then the skeletons are replaced by the Default state's stat tiles and recent list
|
||||
Given a second, separate navigation to /home
|
||||
When GET /v1/article/ resolves in under 150 ms with a non-empty list of articles
|
||||
Then no skeleton is ever shown, and the Default state's content renders directly
|
||||
|
||||
Scenario: Default — the list loaded with a mix of online and draft articles
|
||||
Given GET /v1/article/ resolves 200 with 6 articles, 4 online and 2 drafts, most recently
|
||||
updated first: "How we price a bathroom renovation" (online), "Winter checklist for timber
|
||||
windows" (online), "Choosing tiles for a small bathroom" (draft), "A guide to underfloor
|
||||
heating" (online), "When to reseal a stone worktop" (draft), and "Small bathroom lighting
|
||||
ideas" (online, least recently updated)
|
||||
When the user navigates to /home
|
||||
Then the subtitle reads "Here's where things stand on your site."
|
||||
And the stat tiles read "6" "articles", "4" "online" and "2" "drafts"
|
||||
And the recent list shows exactly the first 5 articles above, in that order, each with its
|
||||
ArticleStatusBadge and updated date, and each row links to its /article/:id/edit
|
||||
And "Small bathroom lighting ideas" does not appear in the recent list
|
||||
And the "Connect your website" card's "Open the guide" link navigates to /integration, and
|
||||
the header's "New article" action opens NewArticleModal
|
||||
|
||||
Scenario: Empty, first run — zero articles
|
||||
Given GET /v1/article/ resolves 200 with zero articles
|
||||
When the user navigates to /home
|
||||
Then the subtitle reads "Welcome to RenewCMS."
|
||||
And no stat tiles are shown
|
||||
And an empty state is shown in place of the recent list: icon file-text, title "Write your
|
||||
first article", text "It starts as a draft, so nothing appears on your site until you
|
||||
publish it.", and a single action "New article"
|
||||
When the user activates "New article"
|
||||
Then NewArticleModal opens
|
||||
|
||||
Scenario Outline: Error — a 500 or a network failure shows the same inline alert
|
||||
Given GET /v1/article/ <failure>
|
||||
When the user navigates to /home
|
||||
Then the breadcrumb, greeting and subtitle remain visible, and the subtitle stays "Here's
|
||||
where things stand on your site."
|
||||
And no stat tiles or recent list are shown
|
||||
And an inline alert with role="alert" is shown in their place: icon circle-x, title
|
||||
"Couldn't load your articles", text "Check your connection and try again.", and a single
|
||||
action "Try again"
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| fails with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Retry re-issues the request and recovers into the Default state
|
||||
Given the user is on /home in the Error state, because GET /v1/article/ failed
|
||||
When the user activates "Try again"
|
||||
Then GET /v1/article/ is called again
|
||||
When it resolves 200 with 6 articles, 4 online and 2 drafts
|
||||
Then the Alert is replaced by the stat tiles reading "6" "articles", "4" "online", "2" "drafts",
|
||||
and the recent list
|
||||
|
||||
Scenario: The list is fetched exactly once per navigation
|
||||
Given the user navigates to /home
|
||||
Then GET /v1/article/ is called exactly once
|
||||
And both the stat tiles and the recent list are derived from that single response, with no
|
||||
second request made for either
|
||||
|
||||
Scenario Outline: The greeting matches the hour of day
|
||||
Given the session's username is "marta"
|
||||
And the device's local hour is <hour>
|
||||
When the user views /home
|
||||
Then the greeting above the subtitle reads "<greeting>, marta"
|
||||
|
||||
Examples:
|
||||
| hour | greeting |
|
||||
| 0 | Good morning |
|
||||
| 11 | Good morning |
|
||||
| 12 | Good afternoon |
|
||||
| 17 | Good afternoon |
|
||||
| 18 | Good evening |
|
||||
| 23 | Good evening |
|
||||
@@ -0,0 +1,101 @@
|
||||
# QA: Home screen
|
||||
|
||||
## Implementation note for coder (informational, not a QA assertion)
|
||||
|
||||
The greeting requires `useApi.getMe()` and the `Session` type/store
|
||||
(`web/src/composables/useApi.ts`, `web/src/stores/session.ts`) to also expose `username`, which
|
||||
`GET /v1/auth/me` already returns in its JSON body. Today `getMe()` only extracts `is_verified` and
|
||||
discards the rest, and `Session` is just `{ verified: boolean }`. Widen `MeResult` and `Session` to
|
||||
carry `username: string` as well; this is a same-layer frontend response-type widening (the wire
|
||||
contract doesn't change), not a backend change.
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi`'s `GET /v1/article/` call per case; no real backend, no httptest server. Mount
|
||||
HomePage inside DashboardShell with a resolved session (username "marta") and a test router at
|
||||
/home.
|
||||
|
||||
1. **Skeleton only after 150 ms** — mock `GET /v1/article/` with an unresolved promise; assert no
|
||||
skeleton is present before 150 ms elapse (use fake timers); advance past 150 ms; assert skeleton
|
||||
placeholders replace the stat tiles and the 5 recent-list rows, and the list container has
|
||||
aria-busy="true"; resolve the promise with 200 and a non-empty list; assert the skeletons are
|
||||
gone and the Default content is shown. Separately, mock a promise that resolves in under 150 ms;
|
||||
assert no skeleton ever renders.
|
||||
2. **Default state** — mock `GET /v1/article/` resolving 200 with the 6-article fixture (4 online,
|
||||
2 drafts, ordered most-recently-updated first as in the feature); assert subtitle "Here's where
|
||||
things stand on your site."; assert stat tiles "6" "articles", "4" "online", "2" "drafts"; assert
|
||||
the recent list shows exactly the first 5 articles in order, each with its ArticleStatusBadge and
|
||||
updated date, each row linking to /article/:id/edit; assert the 6th (least recently updated)
|
||||
article is absent from the recent list; assert the PageHeader's "New article" action opens
|
||||
NewArticleModal.
|
||||
3. **Empty, first run** — mock `GET /v1/article/` resolving 200 with `[]`; assert subtitle "Welcome
|
||||
to RenewCMS."; assert no stat tiles render; assert an empty state renders in place of the recent
|
||||
list with icon file-text, title "Write your first article", text "It starts as a draft, so
|
||||
nothing appears on your site until you publish it.", and action "New article"; click it; assert
|
||||
NewArticleModal opens.
|
||||
4. **Error state** — Examples-driven: (a) `GET /v1/article/` resolves 500, (b) it rejects with a
|
||||
network error. In each: assert the breadcrumb, greeting and subtitle remain visible and the
|
||||
subtitle stays "Here's where things stand on your site."; assert no stat tiles or recent list
|
||||
render; assert an inline alert with role="alert" renders in their place, with icon circle-x,
|
||||
title "Couldn't load your articles", text "Check your connection and try again.", and a single
|
||||
action "Try again"; assert the alert text is identical between the 500 case and the network-
|
||||
failure case — there is no separate wording per cause.
|
||||
5. **Retry recovers into Default** — start from the Error state (mocked 500); click "Try again"; assert
|
||||
`GET /v1/article/` is called a second time; resolve it 200 with the 6-article fixture; assert the
|
||||
Alert is replaced by the stat tiles "6"/"4"/"2" and the recent list.
|
||||
6. **Single request per navigation** — mock `GET /v1/article/` resolving 200 with the 6-article
|
||||
fixture; mount /home; assert the mock was called exactly once, and both the stat tiles and the
|
||||
recent list read from that one resolved value (no second call fires for either).
|
||||
7. **Greeting matches the hour of day, at every state, for the session username** — stub the clock
|
||||
(e.g. `vi.setSystemTime` or a mocked `Date.now`/`Date.prototype.getHours`) to each boundary hour
|
||||
in turn (0, 11, 12, 17, 18, 23) and mount with session username "marta"; assert the greeting
|
||||
above the subtitle reads exactly "Good morning, marta" for hours 0 and 11, "Good afternoon,
|
||||
marta" for hours 12 and 17, and "Good evening, marta" for hours 18 and 23. Then, fixing the hour
|
||||
at 9 ("Good morning, marta"), assert the same exact greeting text is shown above the subtitle in
|
||||
all four states: while `GET /v1/article/` is unresolved past 150 ms (Loading), once it resolves
|
||||
with the 6-article fixture (Default), once it resolves with `[]` (Empty), and once it rejects
|
||||
with a 500 (Error).
|
||||
8. **"Connect your website" card renders in every state** — assert the static card (icon plug,
|
||||
title "Connect your website", text "Your site reads published articles from RenewCMS. Share
|
||||
this guide with whoever builds it.", link "Open the guide" navigating to /integration) is
|
||||
present and identical while `GET /v1/article/` is unresolved past 150 ms (Loading), once it
|
||||
resolves with the 6-article fixture (Default), once it resolves with `[]` (Empty), and once it
|
||||
rejects with a 500 (Error). The card is not part of the PageHeader's actions.
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/screens/home.md` and `docs/design/source/RenewCMS Screens Back-office.dc.html`,
|
||||
group "Home". Run `task visual:check -- home` for each state and compare against the board frame.
|
||||
|
||||
- [ ] Loading state: skeleton stat tiles and 5 skeleton recent rows, shaped like the real layout,
|
||||
matching the board.
|
||||
- [ ] Default state: stat tiles, recent list with ArticleStatusBadge per row, PageHeader primary
|
||||
action "New article", breadcrumb "Home", and the "Connect your website" card below — matches
|
||||
the board exactly at desktop and mobile.
|
||||
- [ ] Empty, first run state: the empty state replaces the stats and recent list, matching the
|
||||
board's icon tile, title "Write your first article", text, and single "New article" action.
|
||||
- [ ] Error state: the inline alert's icon (circle-x), title "Couldn't load your articles", text
|
||||
"Check your connection and try again.", and "Try again" action match the board; the
|
||||
breadcrumb, greeting and subtitle stay visible above it.
|
||||
- [ ] "Connect your website" card: icon plug, title, text and "Open the guide" link match the
|
||||
board, and the card renders identically in every state (Loading, Default, Empty, Error).
|
||||
- [ ] Greeting placement: the greeting sits above the subtitle in the PageHeader area (not the note
|
||||
board's fixture text, since the greeting is new content — position and type scale should read
|
||||
as a lightweight line above the description, not competing with the h1).
|
||||
- [ ] Keyboard/focus: Tab order reaches "New article" in the header, then the "Connect your
|
||||
website" card's "Open the guide" link, before the recent list; the Error state's "Try again"
|
||||
button is reachable and, on activation, focus stays sensible (not lost) while the request
|
||||
re-runs; the recent list's rows are reachable and each activates its /article/:id/edit link
|
||||
with Enter.
|
||||
- [ ] Motion: the skeleton pulse runs at 1.6 s and stops under reduced motion, per the Skeleton
|
||||
component spec.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real GET /v1/article/ against a running server** — Vitest only mocks `useApi`; confirm the real
|
||||
200/500 round-trip and the real article ordering (by updated_at) against `task dev` with a live
|
||||
SQLite file, since these automated tests never exercise the actual Go handler or the
|
||||
`ListArticles` use case.
|
||||
- **Real session username on a live app-boot** — confirm `GET /v1/auth/me`'s `username` field flows
|
||||
through the real session store into the greeting, since Vitest only ever supplies a mocked
|
||||
session.
|
||||
@@ -0,0 +1,63 @@
|
||||
Feature: Integration screen
|
||||
|
||||
As a verified RenewCMS owner
|
||||
I want /integration to show my site's base API URL and ready-to-use endpoint examples
|
||||
So that I, or the developer who builds my public site, can wire it up without reading the
|
||||
source
|
||||
|
||||
Background:
|
||||
Given /integration is a verified route (meta.auth "verified") rendered as IntegrationPage
|
||||
inside DashboardShell, per docs/design/routes.md
|
||||
And the screen makes no /v1 calls; api: none (static)
|
||||
And the PageHeader shows title "Connect your website", description "A guide for the developer
|
||||
who builds your public site.", breadcrumb "Integration" (mobile title "Integration"), and no
|
||||
actions
|
||||
And an introductory paragraph reads "RenewCMS is headless: it stores and publishes your
|
||||
articles but doesn't show them to visitors. Your website fetches published articles from the
|
||||
two addresses below and displays them in its own design."
|
||||
And a "Base URL" row shows window.location.origin in a monospace box, with a "Copy" button
|
||||
(icon copy) beside it
|
||||
And two EndpointCard entries follow, in this order:
|
||||
| method | path | description |
|
||||
| GET | /v1/article/ | Lists every online article. |
|
||||
| GET | /v1/article/{id} | Returns one online article. Drafts and unknown ids both return 404. |
|
||||
And each EndpointCard's fetch example uses the current origin as the base of its URL:
|
||||
| path | fetch example |
|
||||
| /v1/article/ | const res = await fetch('{origin}/v1/article/')\nconst articles = await res.json() |
|
||||
| /v1/article/{id} | const res = await fetch('{origin}/v1/article/12')\nif (res.status === 404) return notFound()\nconst article = await res.json() |
|
||||
And each EndpointCard has fetch, curl and Response tabs, with fetch active by default, and its
|
||||
CodeBlock is labelled "JavaScript" with its own ghost "Copy" button
|
||||
And the curl tab's content is a plain curl command built from the current origin (e.g.
|
||||
"curl {origin}/v1/article/" and "curl {origin}/v1/article/12"), and the Response tab's
|
||||
content is a JSON sample built from the real domain Article fields; neither tab is active by
|
||||
default, and neither is visually checked, since the fetch tab is the one shown in the board
|
||||
And three info notes follow the endpoint cards, in this order:
|
||||
- "Anonymous callers only ever get online articles. Drafts stay private until you publish
|
||||
them."
|
||||
- "Images in body use paths like /static/uploadedImages/<uuid>.png. Prefix them with the
|
||||
base URL before rendering."
|
||||
- "Browsers can only call the API from origins listed in the server's CORS setting. Ask
|
||||
whoever runs the server to add your site's address."
|
||||
|
||||
Scenario: Default — the page loads with the app's own origin
|
||||
Given the app is served from origin "{origin}"
|
||||
When the user navigates to /integration
|
||||
Then the Base URL row shows exactly "{origin}"
|
||||
And the Base URL "Copy" button reads "Copy", not "Copied"
|
||||
And both EndpointCards' fetch examples use "{origin}" as the base of their URL
|
||||
|
||||
Scenario: Copied — the Base URL copy button
|
||||
Given the user is on /integration, served from origin "{origin}"
|
||||
When the user activates the Base URL row's "Copy" button
|
||||
Then "{origin}" is written to the clipboard
|
||||
And the button's icon changes to check and its text changes to "Copied"
|
||||
And the change is announced to assistive technology
|
||||
When 2 seconds elapse without a further click
|
||||
Then the button reverts to icon copy and text "Copy"
|
||||
|
||||
Scenario: Each EndpointCard's own Copy button is independent of the Base URL's
|
||||
Given the user is on /integration
|
||||
When the user activates the "/v1/article/" EndpointCard's "JavaScript" code block's "Copy"
|
||||
button
|
||||
Then that card's own code is written to the clipboard, and only that button reads "Copied"
|
||||
And the Base URL row's "Copy" button and the other EndpointCard's "Copy" button are unaffected
|
||||
@@ -0,0 +1,66 @@
|
||||
# QA: Integration screen
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mount IntegrationPage inside DashboardShell with a resolved verified session and a test router at
|
||||
/integration. Stub `window.location.origin` (or the equivalent composable/helper the coder uses to
|
||||
read it) to a fixed value for the test, and stub `navigator.clipboard.writeText` per case. No
|
||||
`/v1` mocks are needed; the screen makes no calls.
|
||||
|
||||
1. **Default** — mount at /integration with origin stubbed to a fixed value (e.g.
|
||||
`https://cms.renew.example`, matching the design board's sample so the assertions read the same
|
||||
as the visual reference); assert breadcrumb "Integration"; assert the PageHeader title
|
||||
"Connect your website" and description "A guide for the developer who builds your public
|
||||
site."; assert the intro paragraph text; assert the Base URL row shows exactly the stubbed
|
||||
origin; assert the Base URL "Copy" button reads "Copy"; assert both EndpointCards render in
|
||||
order (GET /v1/article/, GET /v1/article/{id}) with their descriptions and their fetch examples
|
||||
containing the stubbed origin; assert the fetch tab is active by default on both cards; assert
|
||||
the three info notes render in order.
|
||||
2. **Copied** — from Default, use fake timers; click the Base URL row's "Copy" button; assert
|
||||
`navigator.clipboard.writeText` was called with the stubbed origin; assert the button's icon is
|
||||
check and its text is "Copied"; assert the change is exposed to assistive technology (e.g. an
|
||||
aria-live region or a status role holds "Copied"); advance the fake timer by 2 s; assert the
|
||||
button reverts to icon copy and text "Copy".
|
||||
3. **EndpointCard's own Copy button is independent** — from Default, click the "/v1/article/"
|
||||
card's "JavaScript" CodeBlock's "Copy" button; assert `navigator.clipboard.writeText` was called
|
||||
with that card's exact code string; assert only that button reads "Copied"; assert the Base URL
|
||||
button still reads "Copy" and the other card's Copy button still reads "Copy". This behavior is
|
||||
Vitest-only; it has no visual-check state of its own (see Visual below).
|
||||
4. **curl and Response tabs render, unchecked visually** — from Default, switch the "/v1/article/"
|
||||
card to its curl tab; assert its content is a plain curl command built from the stubbed origin
|
||||
(e.g. "curl https://cms.renew.example/v1/article/"); switch to its Response tab; assert its
|
||||
content is a JSON sample built from the real domain Article fields. Neither tab's content is
|
||||
asserted pixel-for-pixel anywhere, since the design board only ever shows the fetch tab active.
|
||||
|
||||
## Visual (`task visual:check -- integration`, desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/screens/integration.md` and
|
||||
`docs/design/source/RenewCMS Screens Back-office.dc.html`, group "Integration"; layout detail from
|
||||
`docs/design/source/AppFrame.dc.html`'s `isInteg` block.
|
||||
|
||||
The visual-check setup for this screen serves the SPA under `https://cms.renew.example` via
|
||||
Playwright request routing, so the real `window.location.origin` the running app reads matches the
|
||||
board's sample text exactly; no production test hook or origin stub is needed in app code.
|
||||
|
||||
- [ ] Default state: PageHeader ("Connect your website" / description), intro paragraph, Base URL
|
||||
row with monospace text and a "Copy" button (icon copy), two EndpointCards in order with
|
||||
their GET badge, path, description, fetch/curl/Response tabs (fetch active), JavaScript
|
||||
CodeBlock and its own "Copy" button, and the three info notes below — matches the board
|
||||
exactly at desktop and mobile, light and dark. Sample data for the mocked run: origin
|
||||
`https://cms.renew.example`, served as above.
|
||||
- [ ] Copied state: the Base URL button shows the check icon and "Copied" text, in the success
|
||||
color, matching the board's `copied: true` frame. This state is timer-driven, not
|
||||
date-driven, so it is not clock-sensitive and gets a real visual check, not a skip.
|
||||
- [ ] Keyboard/focus (manual, not a screenshot check): Tab order reaches the Base URL "Copy"
|
||||
button, then each EndpointCard's tab strip (fetch/curl/Response, reachable and operable with
|
||||
arrow keys per UTabs), then that card's own CodeBlock "Copy" button, then the pre's
|
||||
`tabindex="0"` for scrolling; Enter/Space activates each Copy button.
|
||||
- [ ] Hover (manual): ghost Copy buttons show a hover state consistent with other ghost buttons
|
||||
elsewhere in the app (UserMenu, ArticleTable row menu).
|
||||
|
||||
## Manual verification
|
||||
|
||||
- Confirm the real running app's `window.location.origin` (e.g. `https://your-domain.example` in
|
||||
production, or `http://localhost:8080` under `task dev`) renders correctly in the Base URL row
|
||||
and in both fetch examples, since Vitest only ever exercises a stubbed origin, and
|
||||
`visual:check` only ever exercises the routed `https://cms.renew.example` origin.
|
||||
@@ -0,0 +1,102 @@
|
||||
Feature: Log-in card sizing, alert placement and focus behavior match the Auth board
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want AuthShell's card padding, control heights, "Forgot password?" styling, field-error
|
||||
presentation, theme select and h1 focus timing built exactly to the AuthCard board
|
||||
(docs/design/source/AuthCard.dc.html) instead of Nuxt UI's untouched defaults
|
||||
So that /login (and every other AuthShell-based public screen) matches the design board pixel
|
||||
for pixel, at desktop and mobile, in light and dark
|
||||
|
||||
This complements features/auth_shell_login_conformance.feature, which covers AppLogo, the
|
||||
UPageCard title/description wiring, the muted-background breakpoint and the neutral/error input
|
||||
edge. This feature covers what that one doesn't: exact card/control measurements, the "Forgot
|
||||
password?" link's own styling, the field-error icon and halo behavior, the theme select's
|
||||
chevron, where the alert sits relative to the card, and when the h1 receives focus.
|
||||
|
||||
Background:
|
||||
Given AuthShell is the layout for every public route, per docs/design/routes.md
|
||||
And the card's padding, control heights and title size come from
|
||||
docs/design/source/AuthCard.dc.html's renderVals(): pad "40px" and control height "40px"
|
||||
from the sm breakpoint up; pad "8px 20px 0" (with a separate footer padding of
|
||||
"0 20px 24px") and control height "44px" below sm; the title is always "font:600 20px/28px"
|
||||
And the Password UFormField's "Forgot password?" link is styled color var(--ui-text-toned)
|
||||
(muted), text-decoration underline, underline-offset 3px, per AuthCard.dc.html's f.link span
|
||||
And a field error renders with the i-lucide-circle-alert icon in var(--ui-error), and the
|
||||
field's edge/shadow is computed error-first, so an errored field that is also focused shows
|
||||
only the single error-colored inset ring, never a focus ring stacked under or around it
|
||||
And AuthShell's notice slot (the alert) renders inside the card's own layout, after the
|
||||
title/description block and before the form — not as a sibling element above the card
|
||||
And the footer's UColorModeSelect renders with no chevron/dropdown-arrow icon, as the last
|
||||
(right-hand) child of the footer's space-between row, per AuthCard.dc.html's theme span
|
||||
And on first paint, AuthShell's h1 (tabindex="-1") does not receive focus; focus moves to it
|
||||
only when the title changes while AuthShell stays mounted (an in-app navigation between
|
||||
public screens, e.g. /login to /register/reset/request), per components.md's AuthShell a11y
|
||||
line ("On route change, focus moves to the h1")
|
||||
And the h1 never shows a visible focus outline, since it is only ever focused programmatically
|
||||
and is not part of the visible keyboard-tab order
|
||||
|
||||
Scenario: Card padding and control heights at sm and up
|
||||
Given the user is on /login at a viewport of sm or wider
|
||||
When AuthShell and the login form render
|
||||
Then the card's own padding is 40px on every side
|
||||
And the Username input, the Password input and the submit button are each 40px tall
|
||||
And input text renders at 14px
|
||||
|
||||
Scenario: Card padding and control heights below sm
|
||||
Given the user is on /login at a viewport below sm
|
||||
When AuthShell and the login form render
|
||||
Then the card's own padding is 8px top, 20px left and right, 0 bottom
|
||||
And the footer (outside the card) has its own padding of 0 top, 20px left and right, 24px bottom
|
||||
And the Username input, the Password input and the submit button are each 44px tall
|
||||
And input text renders at 16px
|
||||
|
||||
Scenario: "Forgot password?" is underlined and muted
|
||||
Given the user is on /login
|
||||
When the Password field renders
|
||||
Then the "Forgot password?" link uses the muted text color (--ui-text-toned)
|
||||
And the link is underlined with a 3px underline offset
|
||||
And the link carries no other emphasis (not bold, not the primary color)
|
||||
|
||||
Scenario: A field error shows the circle-alert icon with no extra focus halo
|
||||
Given the user is on /login
|
||||
When the user submits with Username "ma" and Password empty (the Validation errors state)
|
||||
Then the Username field's error message is preceded by the i-lucide-circle-alert icon in the
|
||||
error color
|
||||
And the Password field's error message is preceded by the same icon in the same color
|
||||
When the user tabs into the still-errored Username field so it is both errored and focused
|
||||
Then the field shows exactly one inset ring, in the error color
|
||||
And no second, differently colored focus ring is layered under or around it
|
||||
|
||||
Scenario: The theme select is right-aligned in the footer with no chevron
|
||||
Given the user is on /login
|
||||
When AuthShell's footer renders
|
||||
Then the UColorModeSelect is the last (right-most) element in the footer's row
|
||||
And it renders only its mode icon and label ("System", "Light" or "Dark")
|
||||
And it renders no trailing chevron or dropdown-arrow icon
|
||||
|
||||
Scenario Outline: The alert renders inside the card, under the description
|
||||
Given the user is redirected to <path>
|
||||
When /login renders with its <state> alert
|
||||
Then the alert is a child of the same card element that contains the title and description,
|
||||
not a sibling positioned above the card
|
||||
And the alert sits after the title/description block and before the Username field
|
||||
And the alert's background uses UAlert's soft variant, which is dark-tinted automatically in
|
||||
dark mode
|
||||
|
||||
Examples:
|
||||
| path | state |
|
||||
| /login?redirect=/article/12/edit&reason=expired | Session expired |
|
||||
| /login?reason=password-changed | Password changed |
|
||||
|
||||
Scenario: The h1 is not focused on first load
|
||||
Given the user navigates directly to /login (a fresh page load)
|
||||
When AuthShell mounts and the h1 renders
|
||||
Then the h1 does not receive focus
|
||||
And focus remains on the document body (or wherever the browser places it on load)
|
||||
|
||||
Scenario: Focus moves to the h1 only on an in-app navigation between public screens
|
||||
Given the user is on /login with AuthShell already mounted
|
||||
When the user follows the "Forgot password?" link to /register/reset/request without a full
|
||||
page reload
|
||||
Then AuthShell's h1 receives focus once ResetRequestPage's title renders
|
||||
And the h1 shows no visible outline when it receives this focus
|
||||
@@ -0,0 +1,59 @@
|
||||
# QA: Log-in card sizing, alert placement and focus behavior match the Auth board
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mount AuthShell/LoginView directly; mock `useApi` (`GET /v1/auth/registration`,
|
||||
`POST /v1/auth/login`) only as scaffolding, per features/login_screen.qa.md's setup.
|
||||
|
||||
1. **Card padding and control heights at sm+** — mount at a `>= 640px` viewport; assert the
|
||||
card's own padding is 40px on every side (not Nuxt UI's untouched default); assert Username,
|
||||
Password and the submit button each render at a computed height of 40px; assert input font-size
|
||||
is 14px.
|
||||
2. **Card padding and control heights below sm** — mount at a `< 640px` viewport; assert the
|
||||
card's padding is 8px top / 20px sides / 0 bottom; assert the footer's own padding is 0 top /
|
||||
20px sides / 24px bottom; assert Username, Password and the submit button are each 44px tall;
|
||||
assert input font-size is 16px.
|
||||
3. **"Forgot password?" styling** — assert the link's computed color equals `--ui-text-toned`,
|
||||
`text-decoration-line: underline`, `text-underline-offset: 3px`, and that it carries no bold
|
||||
weight or primary-color class.
|
||||
4. **Field error icon and no stacked halo** — trigger the Validation errors state; assert each
|
||||
field's error text is preceded by an `i-lucide-circle-alert` icon in the error color; then
|
||||
focus the still-errored Username input and assert only one ring class (the error ring) is
|
||||
present — the neutral/primary focus ring class is absent, not merely visually covered.
|
||||
5. **Theme select has no chevron, is footer-last** — assert `UColorModeSelect` is the last child
|
||||
of the footer's flex row; assert no chevron-down icon element renders inside it.
|
||||
6. **Alert renders inside the card** — mount at `/login?reason=expired` and separately at
|
||||
`/login?reason=password-changed`; assert the alert element's closest UPageCard/card ancestor is
|
||||
the same element that contains the h1 (i.e., the alert is NOT a sibling before the card);
|
||||
assert its DOM position is after the title/description block and before the Username field.
|
||||
7. **h1 not focused on first load** — mount LoginView fresh (simulating first paint); assert
|
||||
`document.activeElement` is not the h1.
|
||||
8. **h1 focus on in-app navigation only** — mount AuthShell at /login, then update the `title`
|
||||
prop the way an in-app route change to /register/reset/request would (per the existing `watch`
|
||||
on `props.title`); assert the h1 now has focus.
|
||||
|
||||
## Visual (manual verification — `task visual:check -- log-in`, desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/source/AuthCard.dc.html` via `RenewCMS Screens Auth.dc.html`, group
|
||||
"Log in", all 9 states.
|
||||
|
||||
- [ ] Card padding is 40px on every side at desktop, and 8px/20px/0 (card) + 0/20px/24px
|
||||
(footer) below sm, across all 9 states.
|
||||
- [ ] Username, Password and the submit button are 40px tall at desktop and 44px below sm.
|
||||
- [ ] "Forgot password?" is muted and underlined in every state that shows it.
|
||||
- [ ] In the Validation errors state, both fields show the circle-alert icon and exactly one
|
||||
ring around the field (no double ring), light and dark.
|
||||
- [ ] The theme select shows icon + label only, no chevron, right-aligned in the footer.
|
||||
- [ ] In Session expired and Password changed, the alert sits inside the card, under the
|
||||
description, above the Username field, and is visibly dark-tinted (not a light-mode
|
||||
background) in dark mode.
|
||||
- [ ] Keyboard/focus: on a fresh /login load, nothing is auto-focused to the h1 (initial focus
|
||||
stays with the browser/body); following "Forgot password?" to the reset-request screen moves
|
||||
focus to that screen's h1, with no visible outline ring at any point.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **AppLogo edge in dark mode** — resolved by the amended
|
||||
features/auth_shell_login_conformance.feature: AppLogo shows no edge stroke in either light or
|
||||
dark mode, per AuthCard.dc.html's literal `edge: 'transparent'`. No check needed here; see that
|
||||
feature's own QA file for the AppLogo assertions.
|
||||
@@ -0,0 +1,122 @@
|
||||
Feature: Log in screen
|
||||
|
||||
As a returning RenewCMS user
|
||||
I want a /login screen that authenticates me against POST /v1/auth/login, shows why a previous
|
||||
attempt or session failed, and offers to create the owner account only while registration is
|
||||
open
|
||||
So that I can get back into the back office from any entry point (a direct visit, an expired
|
||||
session, a completed password reset) with copy that matches what actually happened
|
||||
|
||||
Background:
|
||||
Given /login is a public route (meta.auth "public") rendered as LoginPage inside AuthShell,
|
||||
per docs/design/routes.md
|
||||
And on mount, LoginPage calls GET /v1/auth/registration to learn whether registration is open
|
||||
And the form has a Username field (placeholder "Your username") and a Password field (a
|
||||
password input with a show/hide toggle, autocomplete "current-password", and a
|
||||
"Forgot password?" link), per docs/design/components.md
|
||||
And client-side validation runs on submit before any request is sent: Username is required
|
||||
with "Use at least 3 characters." under 3 characters, and Password is required with
|
||||
"Enter your password." when empty
|
||||
And a valid submit calls POST /v1/auth/login with { username, password }
|
||||
And on a 200 response, LoginPage re-resolves the session (GET /v1/auth/me, per
|
||||
features/nuxt_ui_shell_foundation.feature and features/api_client_global_error_handling.feature)
|
||||
and then navigates: to the original ?redirect= target if present, else to /home when the
|
||||
session is verified, or to /register/pending when it is not
|
||||
And a 403 response means the username/password pair is wrong, since the API never reveals
|
||||
which field was wrong
|
||||
And a 429 response carries a Retry-After header in seconds, surfaced by useApi and displayed as
|
||||
a live countdown on the submit button; the button re-enables when the countdown reaches zero
|
||||
And a 500 response, or a request that never reaches the server, is surfaced as a network/server
|
||||
error; the fields keep whatever the user typed
|
||||
|
||||
Scenario: Default — registration is closed
|
||||
Given GET /v1/auth/registration resolves 200 with { "open": false }
|
||||
When the user navigates to /login
|
||||
Then the heading is "Log in"
|
||||
And the description is "Welcome back. Log in to manage your articles."
|
||||
And the Username field shows placeholder "Your username" with no value
|
||||
And the Password field shows no value
|
||||
And the submit button reads "Log in"
|
||||
And no "Create the owner account" footer link is shown
|
||||
|
||||
Scenario: Fresh install — registration is open
|
||||
Given GET /v1/auth/registration resolves 200 with { "open": true }
|
||||
When the user navigates to /login
|
||||
Then the heading, description and fields render exactly as in the Default state
|
||||
And the footer reads "First time here?" with a "Create the owner account" link
|
||||
|
||||
Scenario: Validation errors — nothing is sent for a short username and an empty password
|
||||
Given the user is on /login
|
||||
When the user types "ma" into Username, leaves Password empty, and submits
|
||||
Then POST /v1/auth/login is never called
|
||||
And the Username field shows the error "Use at least 3 characters."
|
||||
And the Password field shows the error "Enter your password."
|
||||
|
||||
Scenario: Submitting — fields and button are disabled while the request is in flight
|
||||
Given the user is on /login with Username "marta" and Password "limewash2026"
|
||||
When the user submits and POST /v1/auth/login has not yet resolved
|
||||
Then the Username and Password fields are disabled
|
||||
And the submit button reads "Logging in…" and shows a loading state
|
||||
|
||||
Scenario: Wrong credentials — the password is cleared and focused
|
||||
Given the user is on /login with Username "marta" and Password "wrong-password"
|
||||
When the user submits and POST /v1/auth/login resolves 403
|
||||
Then an error alert reads "That username and password don't match" with description
|
||||
"Check both and try again. Passwords are case-sensitive."
|
||||
And the Username field still shows "marta"
|
||||
And the Password field is cleared and receives focus
|
||||
And the submit button reads "Log in" again, no longer loading
|
||||
|
||||
Scenario: Too many attempts — the button is disabled with a live countdown
|
||||
Given the user is on /login with Username "marta" and Password "limewash"
|
||||
When the user submits and POST /v1/auth/login resolves 429 with a Retry-After of 42 seconds
|
||||
Then an info alert reads "Too many attempts" with description
|
||||
"For your security, wait a moment before trying again."
|
||||
And the Username field still shows "marta" and the Password field still shows "limewash"
|
||||
And the submit button reads "Try again in 0:42" and is disabled
|
||||
And the Username and Password fields are disabled while the countdown runs, as the board draws
|
||||
them
|
||||
When 42 seconds elapse
|
||||
Then the submit button re-enables and reads "Log in"
|
||||
And the Username and Password fields are editable again
|
||||
|
||||
Scenario: Session expired — redirected from a 401 elsewhere
|
||||
Given the user is redirected to "/login?redirect=/article/12/edit&reason=expired"
|
||||
When /login renders
|
||||
Then an info alert reads "Your session expired" with description
|
||||
"Log in again to pick up where you left off. Unsaved changes are kept on this device."
|
||||
And the Password field receives focus
|
||||
And the submit button reads "Log in"
|
||||
|
||||
Scenario: Password changed — arrived from a successful reset
|
||||
Given the user is redirected to "/login?reason=password-changed"
|
||||
When /login renders
|
||||
Then a success alert reads "Password changed" with description "Log in with your new password."
|
||||
And the Username field shows placeholder "Your username" with no value
|
||||
And the submit button reads "Log in"
|
||||
|
||||
Scenario Outline: Server or network error — the fields keep their values
|
||||
Given the user is on /login with Username "marta" and Password <password>
|
||||
When the user submits and POST /v1/auth/login <failure>
|
||||
Then an error alert reads "Couldn't reach RenewCMS" with description
|
||||
"Check your connection and try again."
|
||||
And the Username field still shows "marta" and the Password field still shows <password>
|
||||
And the submit button reads "Log in" again, no longer loading
|
||||
|
||||
Examples:
|
||||
| password | failure |
|
||||
| "limewash2026" | fails with a 500 response |
|
||||
| "limewash2026" | never reaches the server |
|
||||
| "short1" | fails with a 400 response, because "short1" clears client-side validation (non-empty, 3+ characters) but is under the server's 8-character minimum, which the client does not check |
|
||||
|
||||
Scenario Outline: A successful log in navigates to the right destination
|
||||
Given the user is on <path>
|
||||
And GET /v1/auth/me, called after the 200 response, resolves 200 with is_verified <verified>
|
||||
When the user submits valid credentials and POST /v1/auth/login resolves 200 with a jwt cookie
|
||||
Then the app navigates to <destination>
|
||||
|
||||
Examples:
|
||||
| path | verified | destination |
|
||||
| /login | true | /home |
|
||||
| /login | false | /register/pending |
|
||||
| /login?redirect=/article/12/edit | true | /article/12/edit |
|
||||
@@ -0,0 +1,96 @@
|
||||
# QA: Log in screen
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi` (`GET /v1/auth/registration`, `POST /v1/auth/login`, `GET /v1/auth/me`) per case; no
|
||||
real backend, no httptest server. Mount LoginPage inside AuthShell with a test router whose current
|
||||
route starts at the path each case needs.
|
||||
|
||||
1. **Default state** — mock `GET /v1/auth/registration` resolving `{ open: false }`; mount at
|
||||
/login; assert heading "Log in", description "Welcome back. Log in to manage your articles.",
|
||||
Username placeholder "Your username" with no value, Password with no value, submit button "Log
|
||||
in", and no "Create the owner account" link.
|
||||
2. **Fresh install state** — mock `GET /v1/auth/registration` resolving `{ open: true }`; mount at
|
||||
/login; assert the same heading/description/fields as case 1, plus a "Create the owner account"
|
||||
link under "First time here?".
|
||||
3. **Validation errors state** — mount at /login; set Username to "ma", leave Password empty,
|
||||
submit; assert the mocked `POST /v1/auth/login` was never called; assert Username shows "Use at
|
||||
least 3 characters." and Password shows "Enter your password."
|
||||
4. **Submitting state** — set Username "marta", Password "limewash2026"; mock
|
||||
`POST /v1/auth/login` with an unresolved promise; submit; assert Username and Password are
|
||||
disabled and the submit button reads "Logging in…" with a loading indicator.
|
||||
5. **Wrong credentials state** — set Username "marta", Password "wrong-password"; mock
|
||||
`POST /v1/auth/login` resolving 403; submit; assert an error alert with title "That username and
|
||||
password don't match" and description "Check both and try again. Passwords are case-sensitive.";
|
||||
assert Username still reads "marta"; assert Password is empty and has focus; assert the submit
|
||||
button reads "Log in" and is no longer loading.
|
||||
6. **Too many attempts state** — set Username "marta", Password "limewash"; mock
|
||||
`POST /v1/auth/login` resolving 429 with a `Retry-After: 42` header; submit; assert an info alert
|
||||
with title "Too many attempts" and description "For your security, wait a moment before trying
|
||||
again."; assert Username still reads "marta" and Password still reads "limewash"; assert the
|
||||
submit button reads "Try again in 0:42" and is disabled; assert both fields are disabled;
|
||||
advance fake timers by 42 seconds; assert the button re-enables and reads "Log in" and both fields
|
||||
are editable again.
|
||||
7. **Session expired state** — mount at
|
||||
"/login?redirect=/article/12/edit&reason=expired"; assert an info alert with title "Your session
|
||||
expired" and description "Log in again to pick up where you left off. Unsaved changes are kept on
|
||||
this device."; assert the Password field has focus; assert the submit button reads "Log in".
|
||||
8. **Password changed state** — mount at "/login?reason=password-changed"; assert a success alert
|
||||
with title "Password changed" and description "Log in with your new password."; assert Username
|
||||
shows placeholder "Your username" with no value.
|
||||
9. **Server or network error state** — three sub-cases, one per Examples row: (a) a 500 response
|
||||
with Password "limewash2026"; (b) a rejected fetch (network error) with Password "limewash2026";
|
||||
(c) a 400 response with Password "short1" (6 characters — passes client validation since it's
|
||||
non-empty and 3+ characters, but is under the server's 8-character minimum, which the client does
|
||||
not check, so the request reaches the server and comes back 400). In each sub-case, set Username
|
||||
"marta" and the row's Password, submit, and assert an error alert with title "Couldn't reach
|
||||
RenewCMS" and description "Check your connection and try again."; assert Username still reads
|
||||
"marta" and Password still reads the row's value; assert the submit button reads "Log in" and is
|
||||
no longer loading. Sub-case (c) also asserts the mocked `POST /v1/auth/login` was actually called
|
||||
(proving client validation let it through), unlike scenario 3's validation-errors case.
|
||||
10. **Successful log in redirects correctly** — one test per Examples row: mock
|
||||
`POST /v1/auth/login` resolving 200, then `GET /v1/auth/me` resolving 200 with the row's
|
||||
`is_verified`; mount at the row's path; submit valid credentials; assert the router's current
|
||||
path equals the row's destination.
|
||||
| path | verified | destination |
|
||||
|----------------------------------------|----------|-------------------------|
|
||||
| /login | true | /home |
|
||||
| /login | false | /register/pending |
|
||||
| /login?redirect=/article/12/edit | true | /article/12/edit |
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Log in".
|
||||
|
||||
- [ ] Default state, desktop and mobile, light and dark: heading, description, Username, Password
|
||||
with show/hide toggle, "Forgot password?" link, "Log in" button, matches the board exactly.
|
||||
- [ ] Fresh install state: "First time here? Create the owner account" footer renders below the
|
||||
form, desktop and mobile, light and dark.
|
||||
- [ ] Validation errors state: both field errors render inline under their fields with the error
|
||||
styling from components.md, desktop and mobile, light and dark.
|
||||
- [ ] Submitting state: disabled field styling and the loading spinner in the submit button, desktop
|
||||
and mobile, light and dark.
|
||||
- [ ] Wrong credentials state: error alert (circle-x icon, `role="alert"`) above the heading,
|
||||
cleared/focused Password field, desktop and mobile, light and dark.
|
||||
- [ ] Too many attempts state: info alert (timer icon), disabled fields and a disabled button with
|
||||
the live "Try again in m:ss" countdown text, desktop and mobile, light and dark.
|
||||
- [ ] Session expired state: info alert (clock icon, no close button), focused Password field,
|
||||
desktop and mobile, light and dark.
|
||||
- [ ] Password changed state: success alert (circle-check icon, no close button, as the board draws
|
||||
it), desktop and mobile, light and dark.
|
||||
- [ ] Server or network error state: error alert (circle-x icon, no close button), fields retain
|
||||
their values, desktop and mobile, light and dark.
|
||||
- [ ] Keyboard/focus: Tab order is Username → Password → show/hide toggle → "Forgot password?" →
|
||||
submit; the alert (when present) is announced via `role="status"` (info/success) or
|
||||
`role="alert"` (error) without re-announcing on re-render; Password receives programmatic focus
|
||||
exactly in the Wrong credentials and Session expired states, and nowhere else.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real GET /v1/auth/registration and POST /v1/auth/login against a running server** — Vitest only
|
||||
mocks `useApi`; confirm the Default/Fresh install split and a real 403/429/200 round-trip against
|
||||
`task dev` with a live SQLite file, since this slice's automated tests never exercise the actual Go
|
||||
handlers, the rate limiter, or the real `jwt` cookie.
|
||||
- **429 countdown drift** — confirm `useRetryAfter`'s live countdown does not drift or double-fire
|
||||
the re-enable against a real elapsed 42-second wait, since Vitest fake timers only prove the logic,
|
||||
not real wall-clock behavior.
|
||||
@@ -0,0 +1,146 @@
|
||||
Feature: Creating a new article from NewArticleModal
|
||||
|
||||
As a verified RenewCMS owner
|
||||
I want NewArticleModal to take a title and a first paragraph, create the article as a draft
|
||||
over POST /v1/article/, and take me straight to the editor to keep writing
|
||||
So that starting a new article is a two-field, low-friction step instead of a full form, and I
|
||||
land where I can actually write it
|
||||
|
||||
Background:
|
||||
Given NewArticleModal is a UModal opened from Home's "New article" action (mobile header
|
||||
button, desktop header button, and the empty state's "New article" action), per
|
||||
docs/design/routes.md and docs/design/screens/new-article.md; the Articles-page trigger and
|
||||
the /article/new deep link are out of scope for this slice, deferred until ArticlesPage exists
|
||||
And every time it opens, it starts in the Default state: title "New article", text "It's
|
||||
saved as a draft. Titles can't be changed later, so check it now.", an empty, focused Title
|
||||
field with placeholder "e.g. Choosing tiles for a small bathroom", a CharacterCounter
|
||||
reading "0 / 200" and help "3 to 200 characters.", an empty First paragraph textarea with
|
||||
placeholder "Start writing. You'll continue in the editor." and help "You can format it and
|
||||
add images in the editor.", a "Cancel" button and a "Create draft" button
|
||||
And the Title field is sent as the request's title and is validated required, 3 to 200
|
||||
characters, counted the same way as the CharacterCounter ([...str].length, which is rune
|
||||
count, matching article_title_rules.feature's server-side rune counting)
|
||||
And the First paragraph field is sent as the request's body and is validated required (at
|
||||
least one character)
|
||||
And each field validates on blur and again on submit; submitting while either field is
|
||||
invalid sends no request, and shows both fields' errors at once if both are invalid: Title
|
||||
under 3 characters shows "Use at least 3 characters." in place of its help text, with the
|
||||
counter in its error style; an empty First paragraph shows "Add a first paragraph. It can
|
||||
be a single sentence." in place of its help text
|
||||
And activating "Create draft" while both fields are valid sends POST /v1/article/ with JSON
|
||||
{"title": "<title>", "body": "<first paragraph>"}; while the request is in flight both
|
||||
fields and both buttons are disabled, the confirm button reads "Creating…" with a loading
|
||||
indicator, and the modal cannot be dismissed by Cancel, Esc or a backdrop click
|
||||
And a 201 response closes the modal, emits created(article) with the response body, and (for
|
||||
this slice) HomeView.vue's created handler navigates to /article/{id}/edit and raises a
|
||||
toast: color success, icon i-lucide-circle-check, title "Draft created", description
|
||||
"\"{title}\" is saved as a draft.", with id "article-created-{id}" per
|
||||
docs/design/components.md's toast-id convention
|
||||
And a 400 response — whether apperr.ArticleTitleErrorMessage for a title that passed this
|
||||
form's own check but still failed the server's, or any other 400 — is never shown verbatim;
|
||||
it re-enables the form with both fields' current values kept, shows "Check this title and
|
||||
try again." in place of the Title field's help text, and shows an alert "Something in the
|
||||
form isn't valid. Check the highlighted fields."; the First paragraph field shows no error
|
||||
And a 500 response or a request that never reaches the server re-enables the form with both
|
||||
fields' current values kept and shows an alert "Couldn't create the article. Your text is
|
||||
still here. Try again in a moment.", with no field-level error
|
||||
And "Cancel" always closes the modal immediately and discards both fields, with no
|
||||
confirmation prompt, whenever the form is not disabled by an in-flight request
|
||||
And a 401 response is handled entirely by useApi's existing global handling
|
||||
(features/api_client_global_error_handling.feature): the session is cleared and the app
|
||||
navigates to /login?redirect=<path>&reason=expired; this slice adds no draft-preservation
|
||||
behavior for the modal's fields, unlike the editor's session-expiry handling
|
||||
|
||||
Scenario: Opening the modal shows the Default state
|
||||
Given the user is on /home
|
||||
When the user activates "New article"
|
||||
Then NewArticleModal opens with title "New article" and the text "It's saved as a draft.
|
||||
Titles can't be changed later, so check it now."
|
||||
And the Title field is empty, focused, and its counter reads "0 / 200"
|
||||
And the First paragraph field is empty
|
||||
And "Create draft" is present but the form has not been submitted
|
||||
|
||||
Scenario: Validation — a title under 3 characters on blur
|
||||
Given the modal is open
|
||||
When the user types "Ti" into the Title field and moves focus away
|
||||
Then the Title field shows the error "Use at least 3 characters." in place of its help text
|
||||
And the counter reads "2 / 200" in its error style
|
||||
And no request is sent
|
||||
|
||||
Scenario: Validation — an empty first paragraph on blur
|
||||
Given the modal is open with a valid Title
|
||||
When the user leaves the First paragraph field empty and moves focus away
|
||||
Then the First paragraph field shows the error "Add a first paragraph. It can be a single
|
||||
sentence."
|
||||
And no request is sent
|
||||
|
||||
Scenario: Validation — submitting with both fields invalid shows both errors and sends nothing
|
||||
Given the modal is open with Title "Ti" and an empty First paragraph
|
||||
When the user activates "Create draft"
|
||||
Then the Title field shows "Use at least 3 characters." and the First paragraph field shows
|
||||
"Add a first paragraph. It can be a single sentence."
|
||||
And POST /v1/article/ is never sent
|
||||
|
||||
Scenario: Submitting disables the form and cannot be dismissed
|
||||
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph
|
||||
"Small rooms show every grout line, so we start with the floor."
|
||||
When the user activates "Create draft"
|
||||
Then POST /v1/article/ is sent with JSON {"title": "Choosing tiles for a small bathroom",
|
||||
"body": "Small rooms show every grout line, so we start with the floor."}
|
||||
And both fields become disabled, "Cancel" becomes disabled, and the confirm button reads
|
||||
"Creating…" with a loading indicator
|
||||
And pressing Esc or clicking the backdrop does not close the modal
|
||||
|
||||
Scenario: A successful create closes the modal and hands off to the editor with a toast
|
||||
Given the user is on /home with the modal open, Title "Choosing tiles for a small bathroom"
|
||||
and First paragraph "Small rooms show every grout line, so we start with the floor.", and
|
||||
"Create draft" has been activated
|
||||
When POST /v1/article/ resolves 201 with the created article, id 42, is_online false
|
||||
Then the modal closes
|
||||
And the app navigates to /article/42/edit
|
||||
And a toast appears: color success, icon i-lucide-circle-check, title "Draft created",
|
||||
description "\"Choosing tiles for a small bathroom\" is saved as a draft.", id
|
||||
"article-created-42"
|
||||
|
||||
Scenario: Title rejected — a 400 that got past this form's own check
|
||||
Given the modal is open with Title "Choosing tiles for a small bathroom" (already valid by
|
||||
this form's own rule) and First paragraph "Small rooms show every grout line, so we start
|
||||
with the floor.", and "Create draft" has been activated
|
||||
When POST /v1/article/ resolves 400
|
||||
Then the modal re-enables, keeping both field values
|
||||
And the Title field shows "Check this title and try again." in place of its help text
|
||||
And an alert reads "Something in the form isn't valid. Check the highlighted fields."
|
||||
And the First paragraph field shows no error
|
||||
|
||||
Scenario Outline: Error — a 500 or a network failure keeps the text and offers to retry
|
||||
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph
|
||||
"Small rooms show every grout line, so we start with the floor.", and "Create draft" has
|
||||
been activated
|
||||
When POST /v1/article/ <failure>
|
||||
Then the modal re-enables, keeping both field values
|
||||
And an alert reads "Couldn't create the article. Your text is still here. Try again in a
|
||||
moment."
|
||||
And neither field shows a field-level error
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Retrying after an Error succeeds
|
||||
Given the modal is in the Error state after a 500, with Title "Choosing tiles for a small
|
||||
bathroom" and First paragraph "Small rooms show every grout line, so we start with the
|
||||
floor." still in the fields
|
||||
When the user activates "Create draft" again
|
||||
Then POST /v1/article/ is sent again with the same JSON body
|
||||
When it resolves 201 with the created article, id 42
|
||||
Then the modal closes, the app navigates to /article/42/edit, and a toast appears: color
|
||||
success, icon i-lucide-circle-check, title "Draft created", description "\"Choosing tiles
|
||||
for a small bathroom\" is saved as a draft.", id "article-created-42"
|
||||
|
||||
Scenario: Cancel discards the fields with no confirmation
|
||||
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph
|
||||
"Small rooms show every grout line, so we start with the floor."
|
||||
When the user activates "Cancel"
|
||||
Then the modal closes immediately, with no confirmation prompt
|
||||
And no request is sent
|
||||
@@ -0,0 +1,97 @@
|
||||
# QA: Creating a new article from NewArticleModal
|
||||
|
||||
## Implementation note for coder (informational, not a QA assertion)
|
||||
|
||||
Only HomeView.vue is wired as a real `created` consumer in this slice: it listens for
|
||||
`@created="article => { router.push(...); useToast().add(...) }"`. The Articles-page trigger and
|
||||
the `/article/new` route stay pointed at the current ArticleView.vue placeholder until that page
|
||||
exists; don't wire a `created` handler there yet.
|
||||
|
||||
The confirm button's label changes from the placeholder's "Create article" to the design's
|
||||
"Create draft" — this is a deliberate copy change, not a leftover.
|
||||
|
||||
The Created toast uses `useToast().add({ id: 'article-created-{id}', color: 'success', icon:
|
||||
'i-lucide-circle-check', title: 'Draft created', description: '"{title}" is saved as a draft.' })`,
|
||||
per docs/design/components.md's toast-id convention (action + article id) and the Toast rules
|
||||
section (success/info close after 5 s).
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi`'s `request` for `POST /v1/article/` per case; no real backend, no httptest server.
|
||||
Mount HomePage (which owns NewArticleModal) inside DashboardShell with a resolved verified session,
|
||||
and a test router at `/home`. Sample data throughout: title "Choosing tiles for a small bathroom",
|
||||
first paragraph "Small rooms show every grout line, so we start with the floor.", created article
|
||||
id 42, is_online false.
|
||||
|
||||
1. **Default state on open** — click "New article"; assert the modal shows title "New article",
|
||||
the intro text, an empty focused Title field with counter "0 / 200" and help "3 to 200
|
||||
characters.", an empty First paragraph field with its placeholder and help, "Cancel" and
|
||||
"Create draft".
|
||||
2. **Blur validation, Title** — type "Ti" into Title and blur; assert the error "Use at least 3
|
||||
characters." replaces the help text and the counter "2 / 200" is in its error style; assert no
|
||||
`request` call.
|
||||
3. **Blur validation, First paragraph** — with a valid Title, leave First paragraph empty and
|
||||
blur; assert "Add a first paragraph. It can be a single sentence." appears; assert no
|
||||
`request` call.
|
||||
4. **Submit with both fields invalid** — Title "Ti", empty First paragraph, click "Create draft";
|
||||
assert both errors show and `request` is never called.
|
||||
5. **Submitting state** — fill both fields validly, click "Create draft"; assert `request` is
|
||||
called once with `POST /v1/article/` and the exact JSON body; assert both fields and Cancel
|
||||
become disabled, the confirm button reads "Creating…"; assert pressing Escape and clicking the
|
||||
backdrop do not close the modal while unresolved.
|
||||
6. **Successful create hands off** — from Submitting, resolve `request` 201 with the fixture
|
||||
article (id 42); assert the modal closes; assert the router navigated to `/article/42/edit`;
|
||||
assert a toast was raised with id "article-created-42", color success, icon
|
||||
"i-lucide-circle-check", title "Draft created", and description "\"Choosing tiles for a small
|
||||
bathroom\" is saved as a draft."
|
||||
7. **Title rejected (400)** — from Submitting, resolve `request` rejecting with `ApiError(400)`;
|
||||
assert both field values are kept, the Title field shows "Check this title and try again.",
|
||||
an alert reads "Something in the form isn't valid. Check the highlighted fields.", and the
|
||||
First paragraph field shows no error.
|
||||
8. **Error, Examples-driven** — from Submitting, (a) resolve `request` rejecting with
|
||||
`ApiError(500)`, (b) resolve it rejecting with `NetworkError`; in each, assert both field values
|
||||
are kept, the alert reads "Couldn't create the article. Your text is still here. Try again in a
|
||||
moment.", and neither field shows an error.
|
||||
9. **Retry after Error succeeds** — from the Error state (500), click "Create draft" again; assert
|
||||
`request` is called a second time with the same JSON body; resolve 201 with the fixture; assert
|
||||
the modal closes, the router navigated to `/article/42/edit`, and the same "Draft created" toast
|
||||
(id "article-created-42") is raised.
|
||||
10. **Cancel discards** — fill both fields, click "Cancel"; assert the modal closes immediately
|
||||
with no confirmation UI, and `request` was never called.
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/screens/new-article.md` and `docs/design/source/RenewCMS Screens
|
||||
Back-office.dc.html`, group "New article". Run `task visual:check -- new-article` for each state
|
||||
below and compare against the board frame; add each state's mock setup to `web/visual/states.ts`
|
||||
first, reusing this slice's sample data (title "Choosing tiles for a small bathroom", first
|
||||
paragraph "Small rooms show every grout line, so we start with the floor."). This screen has no
|
||||
time-dependent content, so no fixed `now` is needed.
|
||||
|
||||
- [ ] Default: empty, focused Title with counter "0 / 200", empty First paragraph, help text on
|
||||
both fields, "Cancel" / "Create draft".
|
||||
- [ ] Validation: Title "Ti" with counter "2 / 200" in error and "Use at least 3 characters.";
|
||||
First paragraph error "Add a first paragraph. It can be a single sentence."
|
||||
- [ ] Submitting: both fields filled and disabled, confirm reads "Creating…" with its loading
|
||||
state.
|
||||
- [ ] Title rejected (400): both fields filled, Title error "Check this title and try again.",
|
||||
alert "Something in the form isn't valid. Check the highlighted fields."
|
||||
- [ ] Error: both fields filled, alert "Couldn't create the article. Your text is still here. Try
|
||||
again in a moment.", no field-level error.
|
||||
- [ ] Created: no board frame; verified by Vitest (toast copy + navigation).
|
||||
- [ ] Keyboard/focus: Tab reaches Title (already focused on open) → First paragraph → Cancel →
|
||||
Create draft; Esc closes the modal in every dismissible state and is blocked while
|
||||
Submitting; the counter's live region announces only when its error/warn threshold is
|
||||
crossed, not on every keystroke, per CharacterCounter's a11y spec.
|
||||
- [ ] Motion: modal open/close follows the standard 240 ms timing and stops under reduced motion.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real POST /v1/article/ round trip against a running server** — Vitest only mocks `useApi`;
|
||||
confirm the real 201/400/500 responses and that a genuinely-created article is retrievable at
|
||||
`/article/{id}/edit` against `task dev` with a live SQLite file, since automated tests never
|
||||
exercise the real Go handler or `CreateArticleUseCase`.
|
||||
- **Toast placement and stacking on a live app** — confirm the "Draft created" toast's real
|
||||
position, duration and stacking behavior (per docs/design/components.md's Toast rules) against
|
||||
a running app, since Vitest only asserts that `useToast().add` was called with the right
|
||||
arguments, not the rendered/animated toast region.
|
||||
@@ -0,0 +1,146 @@
|
||||
Feature: Nuxt UI shell foundation and router guards
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want the SPA to run on Nuxt UI instead of PrimeVue, with AuthShell and DashboardShell layouts,
|
||||
an AppBoot skeleton, and a router guard that resolves and caches the session once per app load
|
||||
So that every later screen slice can build on a themed, guarded shell instead of an unguarded router
|
||||
with no layout
|
||||
|
||||
Background:
|
||||
Given web/src/main.ts wires the app through the @nuxt/ui/vite plugin, and no longer imports
|
||||
primevue, @primeuix/themes, primeicons or tailwindcss-primeui
|
||||
And web/src/theme/ui.config.ts and web/src/assets/main.css hold the token files copied from
|
||||
docs/design/source/tokens/ (ui.config.ts, renewcms.css), and public/favicon.svg is the file
|
||||
copied from docs/design/source/brand/favicon.svg
|
||||
And the router declares route meta.auth as one of "public", "session", "verified" or "any", and
|
||||
meta.title for every verified route
|
||||
And a global router.beforeEach guard runs on every navigation, and on the first navigation of the
|
||||
app it resolves the session by calling GET /v1/auth/me once and caches the result (session or
|
||||
"no session") in a session Pinia store; later navigations in the same app load reuse that
|
||||
cached result instead of calling GET /v1/auth/me again
|
||||
And /home, /article and /integration currently render only a trivial placeholder view inside
|
||||
DashboardShell; later screen slices (Home, Articles, Integration) replace that placeholder
|
||||
content with the real screens
|
||||
|
||||
Scenario: A public route never triggers a session check
|
||||
Given the target route is /login, whose meta.auth is "public"
|
||||
When the router navigates to it as the first navigation of the app
|
||||
Then GET /v1/auth/me is not called
|
||||
And AuthShell renders immediately, with no AppBoot skeleton shown
|
||||
|
||||
Scenario: The first navigation to a non-public route resolves the session and shows AppBoot meanwhile
|
||||
Given the target route is /home, whose meta.auth is "verified"
|
||||
And GET /v1/auth/me has not resolved yet
|
||||
When the router navigates to /home as the first navigation of the app
|
||||
Then DashboardShell renders with the AppBoot skeleton in place of the page content
|
||||
When GET /v1/auth/me then resolves 200 with is_verified true
|
||||
Then the AppBoot skeleton is replaced by the routed page's content
|
||||
And the navigation is not redirected
|
||||
|
||||
Scenario: A second navigation reuses the cached session instead of calling GET /v1/auth/me again
|
||||
Given GET /v1/auth/me has already resolved 200 with is_verified true during the first navigation
|
||||
When the router navigates from /home to /article, whose meta.auth is also "verified"
|
||||
Then GET /v1/auth/me is not called a second time
|
||||
And no AppBoot skeleton is shown for this navigation
|
||||
|
||||
Scenario: A verified-only route admits a verified session
|
||||
Given the target route is /article, whose meta.auth is "verified"
|
||||
And GET /v1/auth/me resolves 200 with is_verified true
|
||||
When the router navigates to /article
|
||||
Then the navigation completes to /article
|
||||
And there is no redirect
|
||||
|
||||
Scenario: A verified-only route redirects an unverified session to the pending page
|
||||
Given the target route is /article, whose meta.auth is "verified"
|
||||
And GET /v1/auth/me resolves 200 with is_verified false
|
||||
When the router navigates to /article
|
||||
Then the router redirects to /register/pending
|
||||
And the navigation to /article does not complete
|
||||
|
||||
Scenario: A session-only route admits an unverified session
|
||||
Given the target route is /register/pending, whose meta.auth is "session"
|
||||
And GET /v1/auth/me resolves 200 with is_verified false
|
||||
When the router navigates to /register/pending
|
||||
Then the navigation completes to /register/pending
|
||||
And there is no redirect
|
||||
|
||||
Scenario: No session redirects to login with the original path and an expired reason
|
||||
Given the target route is /article/12/edit, whose meta.auth is "verified"
|
||||
And GET /v1/auth/me resolves 401
|
||||
When the router navigates to /article/12/edit
|
||||
Then the router redirects to "/login?redirect=/article/12/edit&reason=expired"
|
||||
And the navigation to /article/12/edit does not complete
|
||||
|
||||
Scenario Outline: The root path resolves to /home, /login or /register/pending depending on the session
|
||||
Given GET /v1/auth/me resolves <response>
|
||||
When the router navigates to /
|
||||
Then the router redirects to <destination>
|
||||
|
||||
Examples:
|
||||
| response | destination |
|
||||
| 200 with is_verified true | /home |
|
||||
| 401 | /login |
|
||||
| 200 with is_verified false | /register/pending |
|
||||
|
||||
Scenario: A session check that fails with a server error renders the Generic error view instead of redirecting
|
||||
Given the target route is /, whose meta.auth is "any"
|
||||
And GET /v1/auth/me fails with a 500 response
|
||||
When the router navigates to /
|
||||
Then the router does not redirect anywhere
|
||||
And the Generic error view renders in place, with breadcrumb "Home", 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.", a primary action "Try again" and a secondary action "Go to Home"
|
||||
When "Try again" is activated
|
||||
Then GET /v1/auth/me is called again to re-run the session check
|
||||
|
||||
Scenario: A session check that fails with a network error on a non-root verified route also renders the Generic error view
|
||||
Given the target route is /article, whose meta.auth is "verified"
|
||||
And GET /v1/auth/me fails with a network error
|
||||
When the router navigates to /article
|
||||
Then the router does not redirect to /login or /register/pending
|
||||
And DashboardShell renders the Generic error view in place of the AppBoot skeleton and the page
|
||||
content, with title "Something went wrong" and a primary action "Try again"
|
||||
When "Try again" is activated
|
||||
Then GET /v1/auth/me is called again to re-run the session check
|
||||
|
||||
Scenario Outline: Each verified placeholder route renders inside DashboardShell with its own heading, breadcrumb and active nav item
|
||||
Given GET /v1/auth/me resolves 200 with is_verified true
|
||||
When the router navigates to <path>
|
||||
Then the route renders inside DashboardShell
|
||||
And the page heading is <title>
|
||||
And the breadcrumb is <title>
|
||||
And the nav item for <title> has aria-current="page"
|
||||
|
||||
Examples:
|
||||
| path | title |
|
||||
| /home | "Home" |
|
||||
| /article | "Articles" |
|
||||
| /integration | "Integration" |
|
||||
|
||||
Scenario: AuthShell renders its title as the page heading and sets the document title
|
||||
Given a screen renders AuthShell with title "Log in" and no description
|
||||
When AuthShell mounts
|
||||
Then AuthShell renders an h1 containing "Log in"
|
||||
And document.title is "Log in · RenewCMS"
|
||||
|
||||
Scenario: AuthShell renders its notice and footer slots
|
||||
Given a screen renders AuthShell with a "notice" slot containing "Your session expired." and a
|
||||
"footer" slot containing a "Forgot password?" link
|
||||
When AuthShell mounts
|
||||
Then the alert above the title contains "Your session expired."
|
||||
And the footer area outside the card contains a "Forgot password?" link
|
||||
|
||||
Scenario: DashboardShell renders the three navigation destinations
|
||||
Given the current route is /article
|
||||
When DashboardShell mounts
|
||||
Then the sidebar navigation lists exactly three destinations: "Home" linking to /home, "Articles"
|
||||
linking to /article, and "Integration" linking to /integration
|
||||
And the "Articles" destination has aria-current="page"
|
||||
And neither "Home" nor "Integration" has aria-current set
|
||||
|
||||
Scenario: DashboardShell shows the offline banner when the browser reports offline
|
||||
Given navigator.onLine is false
|
||||
When DashboardShell mounts
|
||||
Then an offline banner is shown
|
||||
When navigator.onLine becomes true
|
||||
Then the offline banner is hidden
|
||||
@@ -0,0 +1,73 @@
|
||||
# QA: Nuxt UI shell foundation and router guards
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi` / the `/v1/auth/me` call per case; no real backend, no httptest server.
|
||||
|
||||
1. **Public route skips session check** — mount the router at /login; assert the mocked
|
||||
GET /v1/auth/me function is never invoked, and AuthShell is rendered with no AppBoot skeleton.
|
||||
2. **AppBoot shown while GET /v1/auth/me is pending** — mount the router at /home with an
|
||||
unresolved GET /v1/auth/me promise; assert the AppBoot skeleton (USkeleton) is present and the
|
||||
route's own content is absent; resolve the promise with 200 is_verified true; assert the skeleton
|
||||
is gone and the route's content is present.
|
||||
3. **Session cached across navigations** — resolve GET /v1/auth/me once (200 is_verified true),
|
||||
navigate /home → /article; assert the mock was called exactly once.
|
||||
4. **Verified route + verified session** — mock 200 is_verified true, navigate to /article; assert
|
||||
the router's current route is /article (no redirect).
|
||||
5. **Verified route + unverified session** — mock 200 is_verified false, navigate to /article; assert
|
||||
the router redirects to /register/pending.
|
||||
6. **Session route + unverified session** — mock 200 is_verified false, navigate to
|
||||
/register/pending; assert no redirect.
|
||||
7. **No session** — mock a 401, navigate to /article/12/edit; assert redirect to
|
||||
/login?redirect=/article/12/edit&reason=expired.
|
||||
8. **Root path outcomes** — one test per Examples row: 200 is_verified true → /home, 401 → /login,
|
||||
200 is_verified false → /register/pending.
|
||||
9. **Boot failure at root renders the Generic error view** — mock GET /v1/auth/me rejecting with a
|
||||
500, navigate to /; assert no redirect happens, and the Generic error view renders with title
|
||||
"Something went wrong" and text "This page couldn't load. Your saved work is safe. Try again, and
|
||||
if it keeps happening, restart the RenewCMS server."; click "Try again"; assert
|
||||
GET /v1/auth/me is called a second time.
|
||||
10. **Boot failure at a non-root verified route renders the Generic error view** — mock
|
||||
GET /v1/auth/me rejecting with a network error, navigate to /article; assert no redirect to
|
||||
/login or /register/pending happens, DashboardShell shows the Generic error view instead of
|
||||
AppBoot or page content; click "Try again"; assert GET /v1/auth/me is called a second time.
|
||||
11. **Placeholder verified routes wire up shell, heading, breadcrumb and active nav** — one test per
|
||||
Examples row (/home → "Home", /article → "Articles", /integration → "Integration"): mock 200
|
||||
is_verified true, navigate to the path; assert the route renders inside DashboardShell, the page
|
||||
heading and breadcrumb both equal the expected title, and the corresponding nav item has
|
||||
aria-current="page" while the other two do not.
|
||||
12. **AuthShell renders title as h1 and sets document.title** — mount AuthShell with
|
||||
title="Log in"; assert an h1 with text "Log in" and document.title === "Log in · RenewCMS".
|
||||
13. **AuthShell notice/footer slots** — mount AuthShell with notice and footer slot content; assert
|
||||
both render outside/above the card content as specified.
|
||||
14. **DashboardShell nav + aria-current** — mount DashboardShell with current route /article; assert
|
||||
3 links (Home→/home, Articles→/article, Integration→/integration), and only the Articles link
|
||||
has aria-current="page".
|
||||
15. **DashboardShell offline banner** — mock navigator.onLine false, mount DashboardShell; assert
|
||||
the offline banner is visible; set navigator.onLine true and dispatch the 'online' event; assert
|
||||
it hides.
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/source/RenewCMS Screens Back-office.dc.html`, groups "Boot" and "Global",
|
||||
plus the components.md AuthShell/DashboardShell sections.
|
||||
|
||||
- [ ] AuthShell at desktop (sm+): 400px UPageCard variant="outline", centered, top offset 12vh,
|
||||
light and dark.
|
||||
- [ ] AuthShell below sm: card loses border, 20px padding fills screen, fields/buttons use xl size,
|
||||
light and dark.
|
||||
- [ ] AuthShell footer: UColorModeSelect size="sm" plus footer links render outside the card.
|
||||
- [ ] DashboardShell desktop: sidebar with RenewCMS logo header, 3 nav items, UserMenu footer;
|
||||
collapsing the sidebar leaves a 56px icon rail with tooltips on hover/focus.
|
||||
- [ ] DashboardShell mobile (below lg): UDashboardSidebarToggle appears, sidebar becomes a
|
||||
slideover, closes after navigating.
|
||||
- [ ] DashboardShell active nav item uses the sand-fill/ink-text bend, not Nuxt UI's default
|
||||
primary-tinted background.
|
||||
- [ ] AppBoot skeleton visual match to the boot board, light and dark.
|
||||
- [ ] Generic error view visual match to the Global board's "Generic error" state, desktop and
|
||||
mobile, light and dark (breadcrumb "Home", title "Something went wrong", primary "Try again",
|
||||
secondary "Go to Home").
|
||||
- [ ] Keyboard: first Tab stop on a DashboardShell route is the "Skip to content" link; sidebar is
|
||||
a `nav` labeled "Main"; collapse button exposes `aria-expanded`; AuthShell moves focus to its
|
||||
h1 on route change; the Generic error view's title gets focus on render.
|
||||
- [ ] Favicon renders as the copied favicon.svg mark in the browser tab, dark-aware.
|
||||
@@ -0,0 +1,153 @@
|
||||
Feature: Pending registration screen
|
||||
|
||||
As a logged-in user who has not yet verified their account
|
||||
I want /register/pending to tell me where the verification link went, let me log out or cancel
|
||||
my registration, and route me to the right destination no matter which of those calls
|
||||
succeeds or fails
|
||||
So that I always know what to do next while waiting for the emailed link, and a device that
|
||||
verified elsewhere or a server error never strands me on the wrong screen
|
||||
|
||||
Background:
|
||||
Given /register/pending is a "session" route (meta.auth "session"), rendered as PendingPage
|
||||
inside AuthShell, per docs/design/routes.md; "session" only requires a logged-in account, so
|
||||
both a verified and an unverified account can land on it without being redirected away by
|
||||
the router guard
|
||||
And PendingPage only calls DELETE /v1/auth/register and POST /v1/auth/logout
|
||||
And RegisterView.vue, on a 200 from POST /v1/auth/register, writes the submitted email to
|
||||
sessionStorage under the key "renewcms:pendingEmail" before navigating to /register/pending;
|
||||
this is the only change this slice makes to RegisterView.vue
|
||||
And on mount, PendingPage reads "renewcms:pendingEmail" from sessionStorage once: if present,
|
||||
it renders the Default state with that email interpolated into the ResultPanel text; if
|
||||
absent (for example, the user arrived by logging in as an unverified account rather than by
|
||||
just registering), it renders the Email unknown state with the generic text
|
||||
And in both cases the ResultPanel has tone "success", icon "i-lucide-mail-check", title "Check
|
||||
your email", and two secondary actions, "Log out" and "Cancel registration" (color
|
||||
"neutral", variant "outline"); the note line ("Opened it on another device? That works too;
|
||||
you'll log in there first. Can't find it? Check your spam folder. If the link has expired,
|
||||
cancel your registration and start again.") is rendered through ResultPanel's default slot
|
||||
as a second paragraph below the description, per components.md's "Slot default for extras"
|
||||
And "Log out" calls POST /v1/auth/logout, which per loginHandler.Logout always responds 200 and
|
||||
clears the jwt cookie; on its response, PendingPage calls session.clear() and navigates to
|
||||
/login, disabling both actions while the call is in flight
|
||||
And "Cancel registration" opens ConfirmDialog (built by this slice; components.md documents it
|
||||
but no file exists yet) via useOverlay().create(ConfirmDialog).open(), with title "Cancel
|
||||
your registration?", description "Your account will be deleted. You can register again with
|
||||
the same or a different email.", confirmLabel "Cancel registration", cancelLabel "Keep my
|
||||
account" (components.md's "Other uses" line pairs title/description/confirmLabel/cancelLabel
|
||||
in that order, so ConfirmDialog exposes a cancelLabel prop alongside confirmLabel even though
|
||||
the compact props line only names confirmLabel), pendingLabel "Cancelling…", and tone
|
||||
"danger"; its action prop performs the DELETE /v1/auth/register call
|
||||
And while the dialog's action is in flight (Cancelling), both buttons are disabled, the confirm
|
||||
button reads "Cancelling…", aria-busy is set, and the dialog cannot be dismissed, per
|
||||
ConfirmDialog's general contract in components.md
|
||||
And PendingPage's DELETE /v1/auth/register call passes { onForbidden: 'passthrough' } (a new
|
||||
RequestOptions field on useApi, added by this slice, mirroring the existing onUnauthorized
|
||||
field: type 'redirect' | 'passthrough', default 'redirect'; when 'passthrough' is given, the
|
||||
403 branch in useApi's request() skips the "if session.session?.verified === false, push to
|
||||
/register/pending" step entirely and always throws ApiError(403)) so PendingPage's own 403
|
||||
handling below runs instead of the global handler bouncing it back to /register/pending
|
||||
And on a 200 from DELETE /v1/auth/register, the action resolves, the dialog closes, the app
|
||||
navigates to /register, and a toast (color "success", icon "i-lucide-circle-check", title
|
||||
"Registration cancelled", description "You can create the owner account again.") is shown
|
||||
And on a 403 from DELETE /v1/auth/register (user.ErrAlreadyVerified, since the account was
|
||||
verified on another device while this tab still holds a stale unverified session), the
|
||||
action first awaits session.retry() so the session store's cached verified flag is refreshed
|
||||
to true — without this, the router guard's stale local state would immediately bounce a
|
||||
navigation to /home straight back to /register/pending — then the dialog closes,
|
||||
PendingPage replaces its panel with a ResultPanel (tone "success", icon
|
||||
"i-lucide-shield-check", title "You're verified", text "Taking you to your dashboard…", no
|
||||
actions) and shows a toast (color "info", icon "i-lucide-info", title "Your account is already
|
||||
verified", description "There's nothing to cancel.") over it, exactly as the design board
|
||||
draws this state, then navigates to /home after the same 800 ms Verify's Success uses
|
||||
And on a 500 response or a request that never reaches the server, the action rejects, the
|
||||
dialog stays open, and it shows the failure inline (per ConfirmDialog's general contract:
|
||||
"the action runs inside the dialog, so errors show there instead of a toast"), re-enabling
|
||||
both buttons so the user can retry or choose "Keep my account"
|
||||
And a 401 from either call is handled entirely by useApi's existing global 401 handling
|
||||
(redirect to /login?redirect=...&reason=expired); PendingPage adds no special case for it
|
||||
|
||||
Scenario: Default — the email is known from a just-completed registration
|
||||
Given sessionStorage holds "renewcms:pendingEmail" set to "marta@renew.example"
|
||||
When the user navigates to /register/pending
|
||||
Then a ResultPanel is shown with tone "success" and icon "i-lucide-mail-check"
|
||||
And its title is "Check your email"
|
||||
And its text is "We sent a verification link to marta@renew.example. It expires in 2 hours."
|
||||
And its note reads "Opened it on another device? That works too; you'll log in there first.
|
||||
Can't find it? Check your spam folder. If the link has expired, cancel your registration and
|
||||
start again."
|
||||
And its actions are "Log out" and "Cancel registration", both secondary
|
||||
|
||||
Scenario: Email unknown — arrived by logging in as an unverified account
|
||||
Given sessionStorage holds no "renewcms:pendingEmail" entry
|
||||
When the user navigates to /register/pending
|
||||
Then a ResultPanel is shown with tone "success" and icon "i-lucide-mail-check"
|
||||
And its title is "Check your email"
|
||||
And its text is "We sent a verification link to the email you registered with. It expires in 2
|
||||
hours."
|
||||
And its note is the same as the Default state's note
|
||||
And its actions are "Log out" and "Cancel registration", both secondary
|
||||
|
||||
Scenario: Logging out
|
||||
Given the user is on /register/pending
|
||||
When the user activates "Log out"
|
||||
Then both actions are disabled while POST /v1/auth/logout is in flight
|
||||
When the response resolves 200
|
||||
Then the session store is cleared
|
||||
And the app navigates to /login
|
||||
|
||||
Scenario: Confirm cancel — the destructive dialog opens
|
||||
Given the user is on /register/pending
|
||||
When the user activates "Cancel registration"
|
||||
Then a ConfirmDialog opens with title "Cancel your registration?", description "Your account
|
||||
will be deleted. You can register again with the same or a different email.", a "Keep my
|
||||
account" cancel button, a "Cancel registration" confirm button in tone "danger", role
|
||||
"alertdialog", and focus starting on "Keep my account"
|
||||
And the underlying ResultPanel is still shown behind it, unchanged
|
||||
|
||||
Scenario: Cancelling — the DELETE is in flight and the dialog can't be dismissed
|
||||
Given the ConfirmDialog from "Confirm cancel" is open
|
||||
When the user activates "Cancel registration" inside the dialog
|
||||
Then both dialog buttons are disabled, the confirm button reads "Cancelling…", aria-busy is set
|
||||
on the dialog, and it cannot be dismissed by Escape or an overlay click
|
||||
And DELETE /v1/auth/register is sent with { onForbidden: 'passthrough' }
|
||||
|
||||
Scenario: Keep my account — the dialog is dismissed without deleting anything
|
||||
Given the ConfirmDialog from "Confirm cancel" is open
|
||||
When the user activates "Keep my account"
|
||||
Then the dialog closes, emitting close(false)
|
||||
And DELETE /v1/auth/register is never called
|
||||
And the ResultPanel from before the dialog opened is still shown, unchanged
|
||||
|
||||
Scenario: Cancelled — 200 navigates to /register with a toast
|
||||
Given the ConfirmDialog's action is in flight after the user confirmed
|
||||
When DELETE /v1/auth/register resolves 200
|
||||
Then the dialog closes
|
||||
And the app navigates to /register
|
||||
And a toast is shown with color "success", icon "i-lucide-circle-check", title "Registration
|
||||
cancelled", description "You can create the owner account again."
|
||||
|
||||
Scenario: Already verified — 403 shows the verified panel and toast, then navigates to /home
|
||||
Given the ConfirmDialog's action is in flight after the user confirmed
|
||||
And the session store currently holds { verified: false }
|
||||
When DELETE /v1/auth/register resolves 403 with the apperr.AlreadyVerifiedErrorMessage body
|
||||
Then session.retry() is awaited before any navigation happens
|
||||
And once it resolves the session store holds { verified: true }
|
||||
And the dialog closes
|
||||
And a ResultPanel is shown with tone "success", icon "i-lucide-shield-check", title "You're
|
||||
verified", text "Taking you to your dashboard…", and no actions
|
||||
And a toast is shown over it with color "info", icon "i-lucide-info", title "Your account is
|
||||
already verified", description "There's nothing to cancel."
|
||||
And after 800 ms the app navigates to /home
|
||||
|
||||
Scenario Outline: Cancel failed — the dialog stays open and shows the failure inline
|
||||
Given the ConfirmDialog's action is in flight after the user confirmed
|
||||
When DELETE /v1/auth/register <failure>
|
||||
Then the dialog does not close
|
||||
And the failure is shown inline in the dialog, not as a toast
|
||||
And both dialog buttons are re-enabled so the user can retry "Cancel registration" or choose
|
||||
"Keep my account"
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| fails with a 500 response |
|
||||
| never reaches the server |
|
||||
@@ -0,0 +1,81 @@
|
||||
# QA: Pending registration screen
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi` (`DELETE /v1/auth/register`, `POST /v1/auth/logout`) and `useToast`/`useOverlay` per
|
||||
case; no real backend, no httptest server. Mount PendingPage inside AuthShell with a test router and
|
||||
a test Pinia session store. Seed/clear `sessionStorage["renewcms:pendingEmail"]` per case.
|
||||
|
||||
1. **Default state** — set `sessionStorage["renewcms:pendingEmail"] = "marta@renew.example"`; mount
|
||||
at /register/pending; assert a ResultPanel with tone "success", icon "i-lucide-mail-check", title
|
||||
"Check your email", text "We sent a verification link to marta@renew.example. It expires in 2
|
||||
hours.", the note paragraph rendered via the default slot, and two secondary actions "Log out"
|
||||
and "Cancel registration".
|
||||
2. **Email unknown state** — clear `sessionStorage["renewcms:pendingEmail"]`; mount at
|
||||
/register/pending; assert the same ResultPanel shape with text "We sent a verification link to
|
||||
the email you registered with. It expires in 2 hours." and the same note and actions.
|
||||
3. **Logging out** — mount with an unresolved `POST /v1/auth/logout` mock; click "Log out"; assert
|
||||
both actions are disabled; resolve the mock 200; assert the session store's `clear()` was called
|
||||
and the router's current path is /login.
|
||||
4. **Confirm cancel opens the dialog** — click "Cancel registration"; assert a ConfirmDialog renders
|
||||
with title "Cancel your registration?", description "Your account will be deleted. You can
|
||||
register again with the same or a different email.", buttons "Keep my account" and "Cancel
|
||||
registration" (tone danger), `role="alertdialog"`, and initial focus on "Keep my account".
|
||||
5. **Cancelling state** — with the dialog open, mock `DELETE /v1/auth/register` with an unresolved
|
||||
promise, click "Cancel registration" inside the dialog; assert both dialog buttons are disabled,
|
||||
the confirm button reads "Cancelling…", `aria-busy="true"` on the dialog, and the mocked call was
|
||||
made with `{ onForbidden: 'passthrough' }`.
|
||||
6. **Keep my account** — with the dialog open, click "Keep my account"; assert the dialog closes,
|
||||
`DELETE /v1/auth/register` was never called, and the underlying ResultPanel is unchanged.
|
||||
7. **Cancelled state** — confirm the dialog; mock `DELETE /v1/auth/register` resolving 200; assert
|
||||
the dialog closes, the router's current path is /register, and a toast was raised with color
|
||||
"success", icon "i-lucide-circle-check", title "Registration cancelled", description "You can
|
||||
create the owner account again."
|
||||
8. **Already verified state** — seed the session store with `{ verified: false }`, `status:'ready'`;
|
||||
confirm the dialog; mock `DELETE /v1/auth/register` resolving 403; mock the session store's
|
||||
`retry()` to resolve and set `session.session = { verified: true }`; assert `retry()` was awaited
|
||||
before the navigation; assert the "You're verified" ResultPanel ("Taking you to your
|
||||
dashboard…", no actions) is shown and a toast was raised with color "info", icon "i-lucide-info",
|
||||
title "Your account is already verified", description "There's nothing to cancel." while the
|
||||
path is not yet /home; then assert the router reaches /home after 800 ms with no second toast.
|
||||
9. **Cancel failed state** — two sub-cases: (a) a 500 response, (b) a rejected fetch. In each,
|
||||
confirm the dialog, resolve/reject the mock accordingly, and assert the dialog stays open, an
|
||||
inline failure indicator is shown inside it (not a toast — assert no toast was raised), and both
|
||||
dialog buttons are re-enabled.
|
||||
10. **`useApi` onForbidden option (unit-level, alongside the existing suite in
|
||||
`api_client_global_error_handling`'s spec file)** — seed `session = { verified: false }`; mock
|
||||
fetch resolving 403; call `useApi().request('/v1/auth/register', { method: 'DELETE', onForbidden:
|
||||
'passthrough' })`; assert the promise rejects with `ApiError` status 403; assert the router's
|
||||
current route did not change (no redirect to /register/pending); assert the session store is
|
||||
unchanged. This regresses nothing in the existing onForbidden-absent (default 'redirect')
|
||||
scenarios already covered by `features/api_client_global_error_handling.feature`.
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Pending". Run
|
||||
`task visual:check -- pending` for each state and compare against the board frame.
|
||||
|
||||
- [ ] Default state: icon tile, title, text with the interpolated email, note paragraph, "Log out"
|
||||
and "Cancel registration" secondary buttons — matches the board exactly.
|
||||
- [ ] Email unknown state: same layout with the generic text.
|
||||
- [ ] Confirm cancel state: ConfirmDialog layout, button order (Keep my account left, Cancel
|
||||
registration right, danger color), matches components.md's button-order rule.
|
||||
- [ ] Cancelling state: disabled buttons, "Cancelling…" label, spinner/aria-busy styling.
|
||||
- [ ] Cancelled state (transient, capture via visual test hook or a frozen mock): toast appearance
|
||||
over the /register board.
|
||||
- [ ] Already verified state: the "You're verified" ResultPanel with the info toast over it,
|
||||
captured before the 800 ms redirect (the visual setup freezes timers with `paused: true`).
|
||||
- [ ] Keyboard/focus: focus starts on "Keep my account" in the dialog and Enter does not confirm;
|
||||
while cancelling, focus cannot leave the dialog; on the ResultPanel states, focus lands on the
|
||||
title on mount; toasts are announced without stealing focus.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real DELETE /v1/auth/register and POST /v1/auth/logout against a running server** — Vitest only
|
||||
mocks `useApi`; confirm the real 200/403 round-trips against `task dev` with a live SQLite file.
|
||||
The endpoint's own status/body/cookie contract is already covered by
|
||||
`features/cancel_pending_registration.qa.md`; this slice only re-verifies the screen consumes it
|
||||
correctly.
|
||||
- **Router-guard staleness fix in a real browser** — confirm that after the "Already verified" 403,
|
||||
a hard reload of /home does not bounce back to /register/pending, since Vitest's fake session store
|
||||
can't fully reproduce the router's `afterEach` re-check timing.
|
||||
@@ -0,0 +1,112 @@
|
||||
Feature: Register screen
|
||||
|
||||
As the first person to set up a fresh RenewCMS install
|
||||
I want a /register screen that creates the single owner account via POST /v1/auth/register,
|
||||
validates the same rules the API enforces before submitting, and tells me plainly when
|
||||
someone already owns this site or the server failed
|
||||
So that I can create my account once, land on the verification-pending page, and never see a
|
||||
confusing or silent failure
|
||||
|
||||
Background:
|
||||
Given /register is a public route (meta.auth "public") rendered as RegisterPage inside
|
||||
AuthShell, per docs/design/routes.md
|
||||
And RegisterPage only calls POST /v1/auth/register; it never calls GET /v1/auth/registration
|
||||
And the form has a Username field (placeholder "e.g. marta", a live "N / 20" character
|
||||
counter, help text "Letters and numbers, 3 to 20 characters. You'll use it to log in."), an
|
||||
Email field (placeholder "you@example.com", help text "We'll send a verification link
|
||||
here."), and a Password field (a password input with a show/hide toggle and a live
|
||||
checklist item "At least 8 characters" that is unmet/error until the rule is satisfied, then
|
||||
turns met/green)
|
||||
And client-side validation runs on blur of each field and again on submit, before any request
|
||||
is sent, using the same rules as the API: Username must be 3 to 20 characters (the API has
|
||||
no character-class constraint beyond length, so the client checks length only, matching the
|
||||
help text's length claim but not attempting to enforce "letters and numbers" client-side),
|
||||
Email must be a well-formed email address, and Password must be at least 8 characters
|
||||
And a failing Username shows "Use 3 to 20 characters."; a failing Email shows "Enter a full
|
||||
email address, like name@example.com."; a failing Password leaves the checklist item in its
|
||||
unmet/error state
|
||||
And a valid submit calls POST /v1/auth/register with { username, email, password }
|
||||
And on a 200 response, the jwt cookie is set by the server and the app navigates to
|
||||
/register/pending without re-resolving the session first
|
||||
And a 403 response means someone already verified an owner account (user.ErrRegistrationClosed
|
||||
on the backend) and the whole form is replaced by a ResultPanel
|
||||
And a 500 response, or a request that never reaches the server, is surfaced as a server/network
|
||||
error; the fields keep whatever the user typed and the form re-enables
|
||||
And a 400 response is not expected once client-side validation passes, since the client checks
|
||||
the same rules as the API; it is out of scope for this slice
|
||||
|
||||
Scenario: Default — a fresh, empty form
|
||||
When the user navigates to /register
|
||||
Then the heading is "Create the owner account"
|
||||
And the description is "This creates the single admin account for this site. Once it's
|
||||
verified, registration closes."
|
||||
And the Username field shows placeholder "e.g. marta" with no value and counter "0 / 20"
|
||||
And the Email field shows placeholder "you@example.com" with no value
|
||||
And the Password field shows no value, with a show/hide toggle and the checklist item
|
||||
"At least 8 characters" in its unmet state
|
||||
And the submit button reads "Create account"
|
||||
And the footer reads "Already set up?" with a "Log in" link to /login
|
||||
|
||||
Scenario: Validation errors — checked on blur and on submit
|
||||
Given the user is on /register
|
||||
When the user types "ma" into Username and blurs the field
|
||||
Then the Username counter reads "2 / 20" in its error state and the error "Use 3 to 20
|
||||
characters." is shown
|
||||
When the user types "marta@renew" into Email and blurs the field
|
||||
Then the Email field shows the error "Enter a full email address, like name@example.com."
|
||||
When the user types "lime" into Password and blurs the field
|
||||
Then the checklist item "At least 8 characters" stays in its error state
|
||||
When the user submits with these same values
|
||||
Then POST /v1/auth/register is never called
|
||||
And all three errors from above are still shown
|
||||
|
||||
Scenario: Filled, valid — every rule is met
|
||||
Given the user is on /register
|
||||
When the user types "marta" into Username, "marta@renew.example" into Email, and
|
||||
"limewash2026" into Password, and reveals the password with the show/hide toggle
|
||||
Then the Username counter reads "5 / 20" with no error, and the help text "Letters and
|
||||
numbers, 3 to 20 characters. You'll use it to log in." is shown
|
||||
And the Email field shows no error, with the help text "We'll send a verification link here."
|
||||
And the Password field shows "limewash2026" as plain text and the checklist item "At least 8
|
||||
characters" turns to its met/green state
|
||||
And the submit button reads "Create account" and is enabled
|
||||
|
||||
Scenario: Submitting — fields and button are disabled while the request is in flight
|
||||
Given the user is on /register with Username "marta", Email "marta@renew.example", and
|
||||
Password "limewash2026"
|
||||
When the user submits and POST /v1/auth/register has not yet resolved
|
||||
Then the Username, Email and Password fields are disabled
|
||||
And the submit button reads "Creating account…" and shows a loading state
|
||||
|
||||
Scenario: Registration closed — the form is replaced by a ResultPanel
|
||||
Given the user is on /register with Username "marta", Email "marta@renew.example", and
|
||||
Password "limewash2026"
|
||||
When the user submits and POST /v1/auth/register resolves 403
|
||||
Then the form, its fields and the footer are no longer shown
|
||||
And a ResultPanel with tone "info" and icon "user-x" is shown instead, with title "This site
|
||||
already has an owner" and text "Log in instead."
|
||||
And its single action is a primary button "Go to log in"
|
||||
When the user activates "Go to log in"
|
||||
Then the app navigates to /login
|
||||
|
||||
Scenario Outline: Server or network error — the form is kept and re-enabled
|
||||
Given the user is on /register with Username "marta", Email "marta@renew.example", and
|
||||
Password "limewash2026"
|
||||
When the user submits and POST /v1/auth/register <failure>
|
||||
Then an error alert reads "Couldn't create the account" with description "Something went
|
||||
wrong on the server. Try again in a moment."
|
||||
And the Username field still shows "marta", the Email field still shows "marta@renew.example",
|
||||
and the Password field still shows "limewash2026"
|
||||
And the Username, Email and Password fields are re-enabled
|
||||
And the submit button reads "Create account" again, no longer loading
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| fails with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: A successful registration goes straight to the pending page
|
||||
Given the user is on /register with Username "marta", Email "marta@renew.example", and
|
||||
Password "limewash2026"
|
||||
When the user submits and POST /v1/auth/register resolves 200 with a jwt cookie
|
||||
Then the app navigates to /register/pending
|
||||
@@ -0,0 +1,73 @@
|
||||
# QA: Register screen
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi` (`POST /v1/auth/register` only — RegisterPage never calls
|
||||
`GET /v1/auth/registration`); no real backend, no httptest server. Mount RegisterPage inside
|
||||
AuthShell with a test router.
|
||||
|
||||
1. **Default state** — mount at /register; assert heading "Create the owner account", description
|
||||
"This creates the single admin account for this site. Once it's verified, registration
|
||||
closes.", Username placeholder "e.g. marta" with no value and counter "0 / 20", Email
|
||||
placeholder "you@example.com" with no value, Password with no value and the checklist item
|
||||
"At least 8 characters" in its unmet state, submit button "Create account", and the footer
|
||||
"Already set up? Log in" linking to /login.
|
||||
2. **Validation errors state** — mount at /register; set Username to "ma" and blur; assert counter
|
||||
"2 / 20" in error and the error "Use 3 to 20 characters."; set Email to "marta@renew" and blur;
|
||||
assert the error "Enter a full email address, like name@example.com."; set Password to "lime"
|
||||
and blur; assert the checklist item stays in its error state; submit; assert the mocked
|
||||
`POST /v1/auth/register` was never called and all three errors are still shown.
|
||||
3. **Filled, valid state** — set Username "marta", Email "marta@renew.example", Password
|
||||
"limewash2026"; toggle password visibility on; assert counter "5 / 20" with no error and the
|
||||
help text restored, no Email error, Password rendered as plain text, the checklist item in its
|
||||
met/green state, and the submit button enabled and reading "Create account".
|
||||
4. **Submitting state** — set Username "marta", Email "marta@renew.example", Password
|
||||
"limewash2026"; mock `POST /v1/auth/register` with an unresolved promise; submit; assert
|
||||
Username, Email and Password are disabled and the submit button reads "Creating account…" with
|
||||
a loading indicator.
|
||||
5. **Registration closed state** — set the same valid values; mock `POST /v1/auth/register`
|
||||
resolving 403; submit; assert the form and footer are no longer rendered; assert a ResultPanel
|
||||
with title "This site already has an owner", text "Log in instead.", and a single primary
|
||||
action "Go to log in"; click it and assert the router's current path is /login.
|
||||
6. **Server or network error state** — two sub-cases: (a) a 500 response, (b) a rejected fetch
|
||||
(network error). In each, set Username "marta", Email "marta@renew.example", Password
|
||||
"limewash2026", submit, and assert an error alert with title "Couldn't create the account" and
|
||||
description "Something went wrong on the server. Try again in a moment."; assert Username,
|
||||
Email and Password still show their values; assert the three fields are re-enabled; assert the
|
||||
submit button reads "Create account" and is no longer loading.
|
||||
7. **Successful registration navigates to pending** — mock `POST /v1/auth/register` resolving 200;
|
||||
set the same valid values; submit; assert the router's current path is /register/pending, and
|
||||
that no `GET /v1/auth/me` call was made by RegisterPage itself.
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Register". Run
|
||||
`task visual:check -- register` for each state and compare against the board frame.
|
||||
|
||||
- [ ] Default state: heading, description, Username with counter and help text, Email with help
|
||||
text, Password with show/hide toggle and the unmet checklist item, "Create account" button,
|
||||
"Already set up? Log in" footer — matches the board exactly.
|
||||
- [ ] Validation errors state: Username counter and error, Email error, and the Password checklist
|
||||
item's error styling all render inline per components.md.
|
||||
- [ ] Filled, valid state: the checklist item's met/green transition and the revealed password
|
||||
text render correctly.
|
||||
- [ ] Submitting state: disabled field styling and the loading spinner in the submit button.
|
||||
- [ ] Registration closed state: the ResultPanel (info tone, user-x icon tile, title, text, single
|
||||
primary "Go to log in" action) fully replaces the form and footer.
|
||||
- [ ] Server error state: error alert (circle-x icon, no close button per the Log in screen's
|
||||
precedent) above the heading, fields retaining their values, form re-enabled.
|
||||
- [ ] Keyboard/focus: Tab order is Username → Email → Password → show/hide toggle → submit; when
|
||||
the Registration closed ResultPanel appears, focus moves to its title and it is
|
||||
`role="status"`; the server error alert is `role="alert"` and does not steal focus from
|
||||
wherever the user was; the checklist item's live state change is not visually jarring
|
||||
(no layout shift) as it flips from unmet to met.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real POST /v1/auth/register against a running server** — Vitest only mocks `useApi`; confirm
|
||||
the real 403 (`user.ErrRegistrationClosed`) and a real 200 round-trip with the actual jwt cookie
|
||||
against `task dev` with a live SQLite file, since this slice's automated tests never exercise the
|
||||
actual Go handler, the `CreateUser` use case, or cookie issuance.
|
||||
- **Real navigation to /register/pending after a live registration** — confirm the route guard
|
||||
reads the freshly set cookie correctly, since Vitest never resolves a real session against
|
||||
GET /v1/auth/me on that navigation.
|
||||
@@ -28,8 +28,8 @@ Feature: Registration is only open while no verified account exists, and it send
|
||||
both injected from config.Settings.PublicURL in api/dependecyInjection.go; the handler no longer
|
||||
sends mail and no longer holds a SendMailUseCase or PublicURL itself
|
||||
And after user.Repository.Create succeeds, CreateUserUseCase.CreateUser calls
|
||||
mail.Repository.Send with the user's email, template "mailValidation"
|
||||
(internal/infrastructure/mailer/templates/mailValidation.html, which renders a link from
|
||||
mail.Repository.Send with the user's email, template "verification"
|
||||
(internal/infrastructure/mailer/templates/verification.html, which renders a link from
|
||||
"{{.Host}}/register/validate?c={{.VerificationCode}}"), and data whose Host is the injected public
|
||||
URL and whose VerificationCode is the raw (unhashed) code
|
||||
And if mail.Repository.Send returns an error, CreateUserUseCase.CreateUser deletes the
|
||||
@@ -45,7 +45,7 @@ Feature: Registration is only open while no verified account exists, and it send
|
||||
email "alice@example.com"
|
||||
Then the response status is 200
|
||||
And a jwt cookie is set
|
||||
And exactly one email was sent to "alice@example.com" using template "mailValidation"
|
||||
And exactly one email was sent to "alice@example.com" using template "verification"
|
||||
And that email's data has VerificationCode equal to the raw code returned by CreateUser, not its
|
||||
stored hash
|
||||
|
||||
@@ -100,7 +100,7 @@ Feature: Registration is only open while no verified account exists, and it send
|
||||
When CreateUser is called with username "carol", password "hunter2example12",
|
||||
email "carol@example.com"
|
||||
Then the call succeeds and returns a user with IsVerified false
|
||||
And exactly one email was sent to "carol@example.com" using template "mailValidation"
|
||||
And exactly one email was sent to "carol@example.com" using template "verification"
|
||||
|
||||
Scenario: The create-user use case rolls back the user when the mail repository fails
|
||||
Given an empty user.Repository fake
|
||||
|
||||
@@ -39,8 +39,8 @@ Feature: Anyone can request a password reset, and the response never reveals whe
|
||||
most recently requested code and expiration are ever valid; a user who requests a reset twice
|
||||
can only use the second code, and the first one no longer matches once overwritten
|
||||
And after storing succeeds, RequestPasswordReset calls mail.Repository.Send with the user's
|
||||
email, template "passwordReset" (the already-existing
|
||||
internal/infrastructure/mailer/templates/passwordReset.html, which renders a link from
|
||||
email, template "password-reset" (the already-existing
|
||||
internal/infrastructure/mailer/templates/password-reset.html, which renders a link from
|
||||
"{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}"), and a
|
||||
MailPasswordResetData value whose Host is the injected public URL, whose VerificationCode is
|
||||
the raw (unhashed) code, and whose Email is the user's email
|
||||
@@ -69,7 +69,7 @@ Feature: Anyone can request a password reset, and the response never reveals whe
|
||||
When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"}
|
||||
Then the response status is 200
|
||||
And the response body is {"message": "If an account exists for that email, a password reset link has been sent."}
|
||||
And exactly one email was sent to "alice@example.com" using template "passwordReset"
|
||||
And exactly one email was sent to "alice@example.com" using template "password-reset"
|
||||
And that email's data has VerificationCode equal to the raw code stored in the repository's
|
||||
hashed PasswordResetCode for alice, not the hash itself, and Email equal to "alice@example.com"
|
||||
|
||||
@@ -116,7 +116,7 @@ Feature: Anyone can request a password reset, and the response never reveals whe
|
||||
When the client sends POST /v1/auth/password-reset with body {"email": "bob@example.com"}
|
||||
Then the response status is 200
|
||||
And the response body is {"message": "If an account exists for that email, a password reset link has been sent."}
|
||||
And exactly one email was sent to "bob@example.com" using template "passwordReset"
|
||||
And exactly one email was sent to "bob@example.com" using template "password-reset"
|
||||
|
||||
Scenario Outline: A malformed request is still rejected, since this is not an enumeration concern
|
||||
When the client sends POST /v1/auth/password-reset with body <body>
|
||||
@@ -136,7 +136,7 @@ Feature: Anyone can request a password reset, and the response never reveals whe
|
||||
Then the call returns nil
|
||||
And the fake repository's UpdatePasswordResetCode method was invoked exactly once with userId 3,
|
||||
a hashed code, and an expiration within 5 seconds of time.Now().Add(1 * time.Hour)
|
||||
And exactly one email was sent to "alice@example.com" using template "passwordReset" whose data
|
||||
And exactly one email was sent to "alice@example.com" using template "password-reset" whose data
|
||||
carries the same raw code that was hashed, and Host "https://cms.example.com"
|
||||
|
||||
Scenario: The use case swallows an unknown email and returns nil without touching the repository or mailer
|
||||
|
||||
@@ -15,13 +15,13 @@ Feature: Runtime settings are read once at startup
|
||||
CORS_ALLOWED_ORIGINS on every router build,
|
||||
api/handlers/auth.shouldCookieBeSecure reads ENVIRONMENT on every
|
||||
Login/Register/Logout call, internal/infrastructure/mailer.MailRepository.Send
|
||||
reads SMTP_EMAIL, SMTP_PASSWORD, SMTP_HOST and SMTP_PORT on every call,
|
||||
and internal/infrastructure/persistence.ImageRepository.Create and
|
||||
.Delete read UPLOAD_DIR on every call
|
||||
reads SMTP_LOGIN, SMTP_SENDER, SMTP_PASSWORD, SMTP_HOST and SMTP_PORT
|
||||
on every call, and internal/infrastructure/persistence.ImageRepository.Create
|
||||
and .Delete read UPLOAD_DIR on every call
|
||||
And this slice introduces internal/infrastructure/config.Settings, a
|
||||
plain struct with fields Environment, CorsAllowedOrigins ([]string),
|
||||
PublicURL, UploadDir, JWTSecret, DBFile, Port and an Smtp sub-struct
|
||||
(Email, Password, Host, Port)
|
||||
(Login, Sender, Password, Host, Port)
|
||||
And cmd/server/main.go's initEnvVariables still loads the .env file and
|
||||
applies the same defaults and required-var checks as before, then
|
||||
builds exactly one config.Settings value from the process environment,
|
||||
@@ -34,9 +34,9 @@ Feature: Runtime settings are read once at startup
|
||||
the upload dir, and the router's logging and CORS setup receive the
|
||||
environment string and the CORS origins list as parameters
|
||||
And after this slice, os.Getenv (and os.LookupEnv) for ENVIRONMENT,
|
||||
CORS_ALLOWED_ORIGINS, SMTP_EMAIL, SMTP_PASSWORD, SMTP_HOST, SMTP_PORT
|
||||
and UPLOAD_DIR appears only inside cmd/server/main.go and
|
||||
internal/infrastructure/config
|
||||
CORS_ALLOWED_ORIGINS, SMTP_LOGIN, SMTP_SENDER, SMTP_PASSWORD,
|
||||
SMTP_HOST, SMTP_PORT and UPLOAD_DIR appears only inside
|
||||
cmd/server/main.go and internal/infrastructure/config
|
||||
And a component built from a config.Settings value keeps behaving
|
||||
according to the values it was built with even if the process
|
||||
environment changes afterwards, because it holds the captured values,
|
||||
@@ -109,8 +109,9 @@ Feature: Runtime settings are read once at startup
|
||||
Secure attribute set to true
|
||||
|
||||
Scenario: The mailer is constructed from injected SMTP settings, not the environment
|
||||
Given a config.Settings value with Smtp.Email "notify@example.com",
|
||||
Smtp.Host "smtp.example.com" and Smtp.Port 587
|
||||
Given a config.Settings value with Smtp.Login "smtp-login@example.com",
|
||||
Smtp.Sender "notify@example.com", Smtp.Host "smtp.example.com" and
|
||||
Smtp.Port 587
|
||||
And internal/infrastructure/mailer.NewMailRepository is called with that
|
||||
Smtp settings value and no SMTP_* environment variable set
|
||||
Then constructing the MailRepository does not read any SMTP_*
|
||||
|
||||
@@ -83,9 +83,10 @@ Package: `test` (black-box).
|
||||
constructor signature changes.
|
||||
|
||||
## Mailer
|
||||
16. Construct `mailer.NewMailRepository(config.SmtpSettings{Email:
|
||||
"notify@example.com", Host: "smtp.example.com", Port: 587, Password:
|
||||
"unused-in-test"})` with no `SMTP_*` environment variable set (do not
|
||||
16. Construct `mailer.NewMailRepository(config.SmtpSettings{Login:
|
||||
"smtp-login@example.com", Sender: "notify@example.com", Host:
|
||||
"smtp.example.com", Port: 587, Password: "unused-in-test"})` with no
|
||||
`SMTP_*` environment variable set (do not
|
||||
call `t.Setenv` for any `SMTP_*` name in this test). Assert construction
|
||||
does not panic and does not error — this is the only automated
|
||||
assertion available, since exercising `Send` requires a real SMTP dialog
|
||||
@@ -95,7 +96,8 @@ Package: `test` (black-box).
|
||||
|
||||
## Manual verification (not exercised by the Go test)
|
||||
- Real SMTP send: run `task dev` (or a built binary) with a real `.env`
|
||||
populated with SMTP_EMAIL/SMTP_PASSWORD/SMTP_HOST/SMTP_PORT, trigger a
|
||||
populated with SMTP_LOGIN/SMTP_SENDER/SMTP_PASSWORD/SMTP_HOST/SMTP_PORT,
|
||||
trigger a
|
||||
flow that calls `SendMailUseCase.SendMail`, and confirm the message
|
||||
actually leaves via the injected host/port/credentials, and that changing
|
||||
those env vars requires a process restart to take effect (there is no
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
Feature: The two transactional email templates are replaced by the design's ready-to-use files
|
||||
|
||||
As the maintainer of RenewCMS
|
||||
I want the verification and password-reset emails to use the exact Go html/template files from
|
||||
docs/design/source/emails/, renamed to match the design's naming, instead of the old ad hoc
|
||||
mailValidation.html and passwordReset.html files
|
||||
So that account-verification and password-reset emails match the visual spec (bulletproof button,
|
||||
preheader, dark-mode overrides), and the template name used at the call site reflects the file it
|
||||
now loads
|
||||
|
||||
Background:
|
||||
Given internal/infrastructure/mailer/templates/mailValidation.html is removed and replaced by
|
||||
internal/infrastructure/mailer/templates/verification.html, copied verbatim from
|
||||
docs/design/source/emails/verification.html, still driven by a Go struct with a Host field and
|
||||
a VerificationCode field (the existing MailValidationData shape in CreateUser.go is unchanged)
|
||||
And internal/infrastructure/mailer/templates/passwordReset.html is removed and replaced by
|
||||
internal/infrastructure/mailer/templates/password-reset.html, copied verbatim from
|
||||
docs/design/source/emails/password-reset.html, still driven by a Go struct with a Host field, a
|
||||
VerificationCode field and an Email field (the existing MailPasswordResetData shape in
|
||||
RequestPasswordReset.go is unchanged)
|
||||
And MailRepository.Send (internal/infrastructure/mailer/mailRepository.go) is unchanged code: it
|
||||
already loads "templates/" + templateName + ".html" via template.ParseFS, so it now resolves
|
||||
the templateName "verification" to the new verification.html file and "password-reset" to the
|
||||
new password-reset.html file, purely because of the rename, with no change to Send's own logic
|
||||
And CreateUserUseCase.CreateUser (internal/infrastructure/useCases/CreateUser.go) now calls
|
||||
mail.Repository.Send with template "verification" instead of the old "mailValidation"; nothing
|
||||
else about that call changes: the receiver address, and the Host/VerificationCode data, are as
|
||||
before
|
||||
And RequestPasswordResetUseCase.RequestPasswordReset
|
||||
(internal/infrastructure/useCases/RequestPasswordReset.go) now calls mail.Repository.Send with
|
||||
template "password-reset" instead of the old "passwordReset"; nothing else about that call
|
||||
changes: the receiver address, and the Host/VerificationCode/Email data, are as before
|
||||
And the verification email's link is still built as
|
||||
"{{.Host}}/register/validate?c={{.VerificationCode}}" and the password-reset email's link is
|
||||
still built as
|
||||
"{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}", matching both the
|
||||
old templates and the use cases that populate them, so no handler, use case or route changes
|
||||
as a result of this slice
|
||||
And MailRepository (internal/infrastructure/mailer/mailRepository.go) gains an unexported render
|
||||
step, e.g. render(templateName string, data any) (subject string, body string, err error),
|
||||
that Send calls before dialing; render selects the subject by templateName ("Verify your
|
||||
RenewCMS account" for "verification", "Reset your RenewCMS password" for "password-reset",
|
||||
matching docs/design/emails.md's suggested subjects exactly) and parses/executes the
|
||||
corresponding templates/<templateName>.html file, returning its rendered body; Send then uses
|
||||
that subject and body to build and dial the message exactly as before, and any ParseFS or
|
||||
Execute error from render is returned by Send unchanged, with the same log.Println call sites
|
||||
as today
|
||||
And an unrecognized templateName still reaches template.ParseFS, which fails to find
|
||||
"templates/<templateName>.html" and returns its usual error; render does not special-case
|
||||
unknown names beyond the two known subjects, so an unknown templateName's subject falls back to
|
||||
the same literal "RenewCMS | Action required" used before this slice, and Send still returns
|
||||
the ParseFS error without attempting to dial
|
||||
And the two feature files that already assert a template-name string literal,
|
||||
registration_gate_and_verification_email.feature and request_password_reset.feature, are
|
||||
updated in this same slice so every occurrence of "mailValidation" reads "verification" and
|
||||
every occurrence of "passwordReset" reads "password-reset"; their behaviour is otherwise
|
||||
unchanged
|
||||
|
||||
Scenario: Registering a user sends the verification email under its new template name
|
||||
Given the user repository is empty
|
||||
When the client sends POST /v1/auth/register with username "alice", password "hunter2example12",
|
||||
email "alice@example.com"
|
||||
Then the response status is 200
|
||||
And exactly one email was sent to "alice@example.com" using template "verification"
|
||||
And that email's data has Host and VerificationCode populated exactly as before this slice
|
||||
|
||||
Scenario: Requesting a password reset sends the reset email under its new template name
|
||||
Given the user repository contains a user "alice" with email "alice@example.com"
|
||||
When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"}
|
||||
Then the response status is 200
|
||||
And exactly one email was sent to "alice@example.com" using template "password-reset"
|
||||
And that email's data has Host, VerificationCode and Email populated exactly as before this slice
|
||||
|
||||
Scenario: render() produces the verification email's subject, link and copy
|
||||
Given a real *mailer.MailRepository
|
||||
When its render step is called with templateName "verification" and data whose Host is
|
||||
"https://cms.example.com" and whose VerificationCode is "abc123"
|
||||
Then the returned subject is "Verify your RenewCMS account"
|
||||
And the returned body contains the link "https://cms.example.com/register/validate?c=abc123"
|
||||
And the returned body contains the button text "Verify my account"
|
||||
And the returned body contains the text "This link expires in 2 hours."
|
||||
|
||||
Scenario: render() produces the password-reset email's subject, link and copy
|
||||
Given a real *mailer.MailRepository
|
||||
When its render step is called with templateName "password-reset" and data whose Host is
|
||||
"https://cms.example.com", whose VerificationCode is "abc123" and whose Email is
|
||||
"alice@example.com"
|
||||
Then the returned subject is "Reset your RenewCMS password"
|
||||
And the returned body contains the link
|
||||
"https://cms.example.com/register/reset/validate?c=abc123&email=alice%40example.com"
|
||||
And the returned body contains the button text "Choose a new password"
|
||||
And the returned body contains the text "This link expires in 1 hour and works once."
|
||||
|
||||
Scenario: render() falls back to the old default subject for an unrecognized template name
|
||||
Given a real *mailer.MailRepository
|
||||
When its render step is called with templateName "does-not-exist" and any data
|
||||
Then the returned subject is "RenewCMS | Action required"
|
||||
And an error is returned because templates/does-not-exist.html cannot be found
|
||||
And the returned body is empty
|
||||
|
||||
Scenario: A template execution failure is returned without sending
|
||||
Given a real *mailer.MailRepository
|
||||
And data that causes Execute to fail for a known templateName (e.g. a type without the fields
|
||||
the template references)
|
||||
When its render step is called with that templateName and that data
|
||||
Then render returns an error and MailRepository.Send never reaches DialAndSend
|
||||
|
||||
Scenario Outline: The old template files no longer exist under their previous names
|
||||
Then the file internal/infrastructure/mailer/templates/<old file> does not exist on disk
|
||||
|
||||
Examples:
|
||||
| old file |
|
||||
| mailValidation.html |
|
||||
| passwordReset.html |
|
||||
@@ -0,0 +1,71 @@
|
||||
# QA procedure: transactional email templates
|
||||
|
||||
## Automated (Go, `test/`)
|
||||
|
||||
This slice touches a use-case call site (a string literal) and two static template files; it has
|
||||
no new HTTP surface of its own, so no new handler/router wiring is exercised here. Reuse the
|
||||
existing fakes already in `test/` (`mockMailRepo` from `test/mail_test.go`, the fake `user.Repository`
|
||||
used by the registration and password-reset tests) rather than building new ones.
|
||||
|
||||
1. **Registration sends "verification".**
|
||||
In the existing registration test file (or its replacement after this slice), build
|
||||
`CreateUserUseCase` over an empty fake `user.Repository`, a real hasher, and `mockMailRepo`, call
|
||||
`CreateUser` with username `alice`, password `hunter2example12`, email `alice@example.com`, assert
|
||||
the call succeeds, then assert `mockMailRepo.SentEmails` has exactly one entry whose `Template`
|
||||
field equals `"verification"` (not `"mailValidation"`).
|
||||
|
||||
2. **Password reset sends "password-reset".**
|
||||
Build `RequestPasswordResetUseCase` over a fake `user.Repository` seeded with a user whose email is
|
||||
`alice@example.com`, a real hasher, `mockMailRepo` and a public URL, call `RequestPasswordReset("alice@example.com")`,
|
||||
assert it returns `nil`, then assert `mockMailRepo.SentEmails` has exactly one entry whose
|
||||
`Template` field equals `"password-reset"` (not `"passwordReset"`).
|
||||
|
||||
3. **render() produces the right subject, link and copy for each known template.**
|
||||
Construct a real `mailer.NewMailRepository(config.SmtpSettings{})` (SMTP fields are irrelevant
|
||||
since `DialAndSend` is never called here) and call its render step directly (test lives in
|
||||
package `test`, so this exercises the exported behavior surface of a real `*mailer.MailRepository`
|
||||
without opening a network connection):
|
||||
- `render("verification", <data with Host "https://cms.example.com", VerificationCode "abc123">)`
|
||||
→ subject `"Verify your RenewCMS account"`, body contains
|
||||
`https://cms.example.com/register/validate?c=abc123`, `Verify my account`,
|
||||
`This link expires in 2 hours.`
|
||||
- `render("password-reset", <data with Host "https://cms.example.com", VerificationCode "abc123",
|
||||
Email "alice@example.com">)` → subject `"Reset your RenewCMS password"`, body contains
|
||||
`https://cms.example.com/register/reset/validate?c=abc123&email=alice%40example.com`,
|
||||
`Choose a new password`, `This link expires in 1 hour and works once.`
|
||||
|
||||
(`html/template` auto-escapes `Email` in the query string, turning `@` into `%40`; assert the
|
||||
escaped form, matching what `MailRepository.Send` will actually produce.)
|
||||
|
||||
3b. **render() fallback and failure paths.**
|
||||
- `render("does-not-exist", nil)` returns an error (template file not found) and the returned
|
||||
subject is still the old default `"RenewCMS | Action required"`, with an empty body.
|
||||
- A `render(<known templateName>, <data that fails Execute>)` call returns an error; assert
|
||||
`MailRepository.Send` with the same inputs returns an error too and never gets to
|
||||
`DialAndSend` (no network dependency needed to prove this: a failing `render` return is
|
||||
sufficient to prove `Send` short-circuits before dialing, since `Send`'s existing structure
|
||||
calls render, then returns immediately on its error).
|
||||
|
||||
4. **Old template files are gone.**
|
||||
`os.Stat("internal/infrastructure/mailer/templates/mailValidation.html")` and the `passwordReset.html`
|
||||
equivalent each return an error satisfying `os.IsNotExist`.
|
||||
|
||||
5. **Existing slice tests still pass with updated literals.**
|
||||
Run the full suite (`task test`) and confirm the tests backing
|
||||
`registration_gate_and_verification_email.feature` and `request_password_reset.feature` still pass
|
||||
now that their scenario text and any Go assertions inside their test files use `"verification"` and
|
||||
`"password-reset"` instead of the old strings. If those test files still contain the old literals
|
||||
as Go string constants (not just prose in the `.feature` files), they need the same rename; point
|
||||
this out to `coder` rather than silently doing a code edit here.
|
||||
|
||||
## Manual verification (not covered by the automated Go tests above)
|
||||
|
||||
- **Rendered appearance in an actual mail client.** The bulletproof button (table-cell background +
|
||||
padded link), the hidden preheader text, and the `prefers-color-scheme: dark` overrides can only be
|
||||
judged by opening the rendered HTML in a real or preview mail client (or the board file
|
||||
`docs/design/source/RenewCMS Emails.dc.html` next to a manually triggered send through a real SMTP
|
||||
server) — this is not something `os.ReadFile` + `html/template.Execute` can verify. Confirm both
|
||||
light and dark rendering for both templates.
|
||||
- **Real SMTP delivery end to end.** Sending an actual email via `SMTP_HOST`/`SMTP_PORT` and eyeballing
|
||||
the arrived message (subject line, sender name "RenewCMS <...>", clickable button, monospace
|
||||
fallback URL) is a manual item; nothing here exercises `gomail.Dialer.DialAndSend`.
|
||||
@@ -0,0 +1,203 @@
|
||||
Feature: Verify account screen
|
||||
|
||||
As a user who just clicked the verification link in their email
|
||||
I want /register/validate?c=CODE to submit that code automatically, land me on my dashboard once
|
||||
it's accepted, tell me plainly when it's wrong or expired, and let me log in inline if I opened
|
||||
the link somewhere I'm not logged in
|
||||
So that verifying my account is a single click from my inbox, whatever device or session state I
|
||||
open the link in
|
||||
|
||||
Background:
|
||||
Given /register/validate is a "public" route (meta.auth "public"), rendered as VerifyPage
|
||||
inside AuthShell, per docs/design/routes.md; the router's beforeEach never runs session.check
|
||||
for a public route, so VerifyPage starts with no assumption about the session
|
||||
And VerifyPage only calls POST /v1/auth/verify, DELETE /v1/auth/register, and POST
|
||||
/v1/auth/logout (and, inline, POST /v1/auth/login on a 401)
|
||||
And on mount, VerifyPage reads the "c" query parameter once: if it is absent or an empty
|
||||
string, no request is ever made and the Missing code state renders immediately; otherwise the
|
||||
Verifying state renders and POST /v1/auth/verify is called immediately with { code: c }
|
||||
And every call to POST /v1/auth/verify (the initial one and the post-login retry) passes
|
||||
{ onUnauthorized: 'passthrough', onForbidden: 'passthrough' } (the same onForbidden
|
||||
RequestOptions field added to useApi by features/pending_screen.feature, plus the existing
|
||||
onUnauthorized field used the same way getMe() already uses it), so a 401 renders the inline
|
||||
log-in state instead of the global redirect to /login, and a 403 renders the Invalid-or-
|
||||
expired state instead of the global "unverified session" redirect
|
||||
And Verifying and No-session-retrying both render the same full-card spinner: a centered
|
||||
"i-lucide-loader-2" icon with a spin animation, the title "Verifying your account…" and the
|
||||
line "This only takes a moment.", as the design board's AuthCard spinner draws it,
|
||||
role="status", replacing AuthShell's whole card body with no title header shown
|
||||
And on a 200, VerifyPage shows a ResultPanel with tone "success", icon "i-lucide-shield-check",
|
||||
title "Your account is verified", text "Taking you to your dashboard…", and no actions, while
|
||||
a toast (color "success", icon "i-lucide-circle-check", title "Your account is verified",
|
||||
description "Welcome to RenewCMS.") is shown immediately over that panel, as the design board
|
||||
draws it, while concurrently awaiting session.retry() (needed so /home's route guard sees the
|
||||
now-verified session instead of whatever stale state this tab last cached) and an 800 ms
|
||||
timer; once both settle, the app navigates to /home
|
||||
And on a 403 (user.ErrInvalidVerificationCode or user.ErrVerificationCodeExpired), VerifyPage
|
||||
shows a ResultPanel with tone "error", icon "i-lucide-link-2-off", title "This verification
|
||||
link doesn't work", text "It may have expired (links last 2 hours) or already been used.",
|
||||
and a note (rendered via the default slot) "Cancel your registration and start over to get a
|
||||
new link.", with a primary action "Cancel registration and start over" and a secondary action
|
||||
"Log out"; unlike Pending's cancel flow, this button opens no ConfirmDialog (verify.md's state
|
||||
list has no confirm step for it) and calls DELETE /v1/auth/register directly, passing
|
||||
{ onForbidden: 'passthrough' } for the same reason Pending's own DELETE call does
|
||||
And "Cancel registration and start over" resolving 200 navigates to /register and shows the
|
||||
same toast Pending's own Cancelled state shows (color "success", icon "i-lucide-circle-check",
|
||||
title "Registration cancelled", description "You can create the owner account again."),
|
||||
reusing that copy since it is the same underlying event
|
||||
And "Cancel registration and start over" failing — with a 403 (already verified elsewhere, so
|
||||
there is nothing left to cancel), a 500, or a request that never reaches the server — shows an
|
||||
alert on the same ResultPanel screen via AuthShell's #notice slot, reusing Register's existing
|
||||
server-error pattern: color "error", icon "i-lucide-circle-x", title "Couldn't cancel
|
||||
registration", description "Something went wrong on the server. Try again in a moment.",
|
||||
variant "soft", not closable, role "alert"; the ResultPanel and its two actions stay exactly
|
||||
as they were so the user can try again or choose "Log out" instead; no redirect happens for
|
||||
the 403 case here, unlike Pending's own already-verified handling
|
||||
And "Log out" (on the Invalid-or-expired state) behaves exactly as it does on Pending: it calls
|
||||
POST /v1/auth/logout, which per loginHandler.Logout always responds 200 and clears the jwt
|
||||
cookie, then VerifyPage calls session.clear() and navigates to /login, disabling both actions
|
||||
while the call is in flight
|
||||
And on a 401, VerifyPage replaces the spinner with an inline log-in form: AuthShell title "Log
|
||||
in to finish verifying", description "You opened this link somewhere you're not logged in.
|
||||
Log in here and we'll verify your account right after.", a Username field (placeholder "Your
|
||||
username") and a Password field (password input with a show/hide toggle), and a submit button
|
||||
"Log in and verify"; there is no "Forgot password?" hint and no registration-open footer,
|
||||
unlike LoginView
|
||||
And the inline form's submit button is enabled, as the design board draws it; on submit,
|
||||
LoginView's own validation runs first (Username under 3 characters shows "Use at least 3
|
||||
characters.", an empty Password shows "Enter your password.", and nothing is sent), and a
|
||||
valid form calls POST /v1/auth/login with { username, password }
|
||||
And a 200 from that login call transitions to the No-session-retrying spinner and immediately
|
||||
retries POST /v1/auth/verify with the same code and the same passthrough options, which then
|
||||
follows the 200/403 handling above
|
||||
And a 403 (wrong credentials), 429, or 500/network failure from that inline login call reuses
|
||||
LoginView's existing alert copy for the same statuses (wrongCredentialsAlert, with LoginView's
|
||||
exact title/description; tooManyAttemptsAlert with the same retryAfter countdown behavior as
|
||||
LoginView; serverErrorAlert), shown via AuthShell's #notice slot, since verify.md specifies no
|
||||
separate copy for this embedded form and states.md's HTTP-status table assigns this copy
|
||||
generically to "Log in" contexts
|
||||
And while that inline login is submitting, both fields are disabled and the submit button reads
|
||||
"Logging in…" with a loading indicator, mirroring LoginView's submitting state
|
||||
|
||||
Scenario: Verifying — the code submits automatically on load
|
||||
When the user navigates to "/register/validate?c=abc123"
|
||||
Then the full-card spinner is shown with the text "Verifying your account…"
|
||||
And POST /v1/auth/verify is called once, immediately, with body {"code": "abc123"}
|
||||
|
||||
Scenario: Success — 200 refreshes the session and navigates to /home with a toast
|
||||
Given the user is on "/register/validate?c=abc123" in the Verifying state
|
||||
When POST /v1/auth/verify resolves 200
|
||||
Then a ResultPanel is shown with tone "success", icon "i-lucide-shield-check", title "Your
|
||||
account is verified", text "Taking you to your dashboard…", and no actions
|
||||
And a toast is shown over it with color "success", icon "i-lucide-circle-check", title "Your
|
||||
account is verified", description "Welcome to RenewCMS."
|
||||
And session.retry() is awaited
|
||||
And after 800 ms and once session.retry() has resolved, the app navigates to /home
|
||||
|
||||
Scenario: Invalid or expired — 403 offers a way to start over
|
||||
Given the user is on "/register/validate?c=abc123" in the Verifying state
|
||||
When POST /v1/auth/verify resolves 403
|
||||
Then a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This
|
||||
verification link doesn't work", text "It may have expired (links last 2 hours) or already
|
||||
been used.", and note "Cancel your registration and start over to get a new link."
|
||||
And its actions are a primary "Cancel registration and start over" and a secondary "Log out"
|
||||
|
||||
Scenario: Cancel registration and start over — success
|
||||
Given the ResultPanel from "Invalid or expired" is shown
|
||||
When the user activates "Cancel registration and start over"
|
||||
Then DELETE /v1/auth/register is sent directly, without any confirmation dialog, with
|
||||
{ onForbidden: 'passthrough' }
|
||||
When it resolves 200
|
||||
Then the app navigates to /register
|
||||
And a toast is shown with color "success", icon "i-lucide-circle-check", title "Registration
|
||||
cancelled", description "You can create the owner account again."
|
||||
|
||||
Scenario Outline: Cancel registration and start over — failure shows an alert, not a redirect
|
||||
Given the ResultPanel from "Invalid or expired" is shown
|
||||
When the user activates "Cancel registration and start over"
|
||||
And DELETE /v1/auth/register <failure>
|
||||
Then an alert is shown with color "error", icon "i-lucide-circle-x", title "Couldn't cancel
|
||||
registration", description "Something went wrong on the server. Try again in a moment."
|
||||
And the ResultPanel and its two actions ("Cancel registration and start over", "Log out") are
|
||||
still shown, unchanged, so the user can retry or log out instead
|
||||
And no navigation happens
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves 403 with the apperr.AlreadyVerifiedErrorMessage body |
|
||||
| fails with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Log out from the Invalid-or-expired state
|
||||
Given the ResultPanel from "Invalid or expired" is shown
|
||||
When the user activates "Log out"
|
||||
Then POST /v1/auth/logout is sent
|
||||
When it resolves 200
|
||||
Then the session store is cleared
|
||||
And the app navigates to /login
|
||||
|
||||
Scenario: No session — the inline log-in form replaces the spinner
|
||||
Given the user is on "/register/validate?c=abc123" in the Verifying state
|
||||
When POST /v1/auth/verify resolves 401
|
||||
Then the spinner is replaced by a form titled "Log in to finish verifying" with description
|
||||
"You opened this link somewhere you're not logged in. Log in here and we'll verify your
|
||||
account right after."
|
||||
And it has a Username field (placeholder "Your username") and a Password field with a show/hide
|
||||
toggle, and a submit button "Log in and verify"
|
||||
And there is no "Forgot password?" link and no registration-open footer
|
||||
|
||||
Scenario: No session — empty fields are caught on submit, as on Log in
|
||||
Given the inline log-in form from "No session" is shown
|
||||
Then the submit button is enabled
|
||||
When the user submits with both fields empty
|
||||
Then Username shows "Use at least 3 characters." and Password shows "Enter your password."
|
||||
And POST /v1/auth/login is not called
|
||||
|
||||
Scenario: No session — successful login retries verification with the same code
|
||||
Given the inline log-in form from "No session" is shown on "/register/validate?c=abc123"
|
||||
When the user submits Username "marta" and Password "limewash2026"
|
||||
And POST /v1/auth/login resolves 200
|
||||
Then the full-card spinner is shown again with the text "Verifying your account…"
|
||||
And POST /v1/auth/verify is sent again, with body {"code": "abc123"} and the same passthrough
|
||||
options as the first attempt
|
||||
|
||||
Scenario: No session — wrong credentials during the inline login
|
||||
Given the inline log-in form from "No session" is shown
|
||||
When the user submits Username "marta" and Password "wrong-password"
|
||||
And POST /v1/auth/login resolves 403
|
||||
Then an error alert is shown with title "That username and password don't match" and
|
||||
description "Check both and try again. Passwords are case-sensitive."
|
||||
And the Password field is cleared and refocused
|
||||
And POST /v1/auth/verify is not retried
|
||||
|
||||
Scenario: No session — too many login attempts
|
||||
Given the inline log-in form from "No session" is shown
|
||||
When the user submits Username "marta" and Password "limewash2026"
|
||||
And POST /v1/auth/login resolves 429 with Retry-After 30
|
||||
Then an info alert is shown with title "Too many attempts" and description "For your security,
|
||||
wait a moment before trying again."
|
||||
And the submit button reads "Try again in 0:30" and it and both fields are disabled until the
|
||||
countdown ends, mirroring LoginView
|
||||
|
||||
Scenario Outline: No session — server or network error during the inline login
|
||||
Given the inline log-in form from "No session" is shown
|
||||
When the user submits Username "marta" and Password "limewash2026"
|
||||
And POST /v1/auth/login <failure>
|
||||
Then an error alert is shown with title "Couldn't reach RenewCMS" and description "Check your
|
||||
connection and try again."
|
||||
And the fields keep their values and are re-enabled
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| fails with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Missing code — no request is made
|
||||
When the user navigates to "/register/validate" with no "c" query parameter
|
||||
Then no request is made
|
||||
And a ResultPanel is shown with tone "error", icon "i-lucide-link-2-off", title "This link is
|
||||
incomplete", text "The verification code is missing. Open the link from your email again, or
|
||||
copy the full address into your browser."
|
||||
And its single action is a secondary "Go to log in"
|
||||
When the user activates "Go to log in"
|
||||
Then the app navigates to /login
|
||||
@@ -0,0 +1,97 @@
|
||||
# QA: Verify account screen
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi` (`POST /v1/auth/verify`, `POST /v1/auth/login`, `DELETE /v1/auth/register`,
|
||||
`POST /v1/auth/logout`) and `useToast` per case; no real backend, no httptest server. Mount
|
||||
VerifyPage inside AuthShell with a test router whose current route starts at the "c" query the case
|
||||
needs; use fake timers for the 800 ms success delay and the 429 countdown.
|
||||
|
||||
1. **Verifying state** — mount at "/register/validate?c=abc123"; assert the full-card spinner title
|
||||
"Verifying your account…" and that `POST /v1/auth/verify` was called exactly once with
|
||||
`{ code: 'abc123' }` and `{ onUnauthorized: 'passthrough', onForbidden: 'passthrough' }`.
|
||||
2. **Success state** — mock `POST /v1/auth/verify` resolving 200; mock the session store's
|
||||
`retry()` to resolve; mount and let it settle; assert the ResultPanel (success, shield-check,
|
||||
"Your account is verified", "Taking you to your dashboard…", no actions); assert a toast was
|
||||
already raised with title "Your account is verified", description "Welcome to RenewCMS." while
|
||||
the path is still /register/validate; advance by 800 ms; assert `retry()` was called and the
|
||||
router's current path is /home, with no second toast.
|
||||
3. **Invalid or expired state** — mock `POST /v1/auth/verify` resolving 403; assert the ResultPanel
|
||||
(error, link-2-off, "This verification link doesn't work", the text and note above) with actions
|
||||
"Cancel registration and start over" (primary) and "Log out" (secondary).
|
||||
4. **Cancel registration and start over — success** — from state 3, mock
|
||||
`DELETE /v1/auth/register` resolving 200; click the action; assert it was called with
|
||||
`{ onForbidden: 'passthrough' }` and that no ConfirmDialog was opened; assert the router's current
|
||||
path is /register and a toast was raised with title "Registration cancelled", description "You
|
||||
can create the owner account again."
|
||||
5. **Cancel registration and start over — failure shows an alert, not a redirect** — three
|
||||
sub-cases from state 3: (a) `DELETE /v1/auth/register` resolves 403 (already verified elsewhere),
|
||||
(b) resolves 500, (c) a rejected fetch. In each, click the action and assert an alert is shown
|
||||
with color "error", icon "i-lucide-circle-x", title "Couldn't cancel registration", description
|
||||
"Something went wrong on the server. Try again in a moment."; assert the ResultPanel and both of
|
||||
its actions ("Cancel registration and start over", "Log out") are still rendered, unchanged;
|
||||
assert the router's current path did not change (in particular, sub-case (a) must NOT navigate to
|
||||
/home — unlike Pending's own already-verified handling, this screen never redirects on this 403).
|
||||
6. **Log out from Invalid or expired** — from state 3, mock `POST /v1/auth/logout` resolving 200;
|
||||
click "Log out"; assert the session store's `clear()` was called and the router's current path is
|
||||
/login.
|
||||
7. **No session state** — mock `POST /v1/auth/verify` resolving 401; assert the form (title "Log in
|
||||
to finish verifying", description, Username placeholder "Your username", Password with show/hide,
|
||||
submit "Log in and verify") and assert no "Forgot password?" link and no registration footer are
|
||||
rendered.
|
||||
8. **No session — empty fields are caught on submit** — from state 7, assert the submit button is
|
||||
enabled; submit with both fields empty; assert "Use at least 3 characters." and "Enter your
|
||||
password." are shown and `POST /v1/auth/login` was not called.
|
||||
9. **No session — successful login retries** — from state 7, fill valid credentials; mock
|
||||
`POST /v1/auth/login` resolving 200; mock a second `POST /v1/auth/verify` call; submit; assert the
|
||||
spinner "Verifying your account…" reappears and the second `POST /v1/auth/verify` call was made
|
||||
with the same code and the same passthrough options.
|
||||
10. **No session — wrong credentials** — from state 7, mock `POST /v1/auth/login` resolving 403;
|
||||
submit "marta" / "wrong-password"; assert the error alert ("That username and password don't
|
||||
match", "Check both and try again. Passwords are case-sensitive."), the Password field cleared
|
||||
and focused, and that `POST /v1/auth/verify` was not called again.
|
||||
11. **No session — too many attempts** — from state 7, mock `POST /v1/auth/login` resolving 429 with
|
||||
`Retry-After: 30`; submit; assert the info alert ("Too many attempts", "For your security, wait a
|
||||
moment before trying again."), the submit button reading "Try again in 0:30" and disabled;
|
||||
advance fake timers 30s; assert it re-enables.
|
||||
12. **No session — server/network error** — two sub-cases (500, rejected fetch) from state 7; submit
|
||||
valid-looking credentials; assert the error alert ("Couldn't reach RenewCMS", "Check your
|
||||
connection and try again.") and that both fields keep their values and re-enable.
|
||||
13. **Missing code state** — mount at "/register/validate" (no "c"); assert no request was made
|
||||
(neither `POST /v1/auth/verify` nor `POST /v1/auth/login`); assert the ResultPanel (error,
|
||||
link-2-off, "This link is incomplete", the text above) with a single secondary action "Go to log
|
||||
in"; click it and assert the router's current path is /login.
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/source/RenewCMS Screens Auth.dc.html`, group "Verify". Run
|
||||
`task visual:check -- verify` for each state and compare against the board frame.
|
||||
|
||||
- [ ] Verifying state: the full-card spinner (the only one in the product) matches the board's
|
||||
layout exactly, with no title header shown.
|
||||
- [ ] Success state: ResultPanel with no actions and the success toast over it, captured before
|
||||
the 800 ms redirect (the visual setup freezes timers with `paused: true`).
|
||||
- [ ] Invalid or expired state: ResultPanel with note and both actions, button order (primary
|
||||
"Cancel registration and start over" first per components.md's safe/primary-on-top rule for
|
||||
stacked mobile buttons).
|
||||
- [ ] Cancel-failed alert: the error alert renders above the ResultPanel via AuthShell's #notice
|
||||
slot, matching Register's own server-error alert styling exactly.
|
||||
- [ ] No session state: the trimmed-down log-in form (no forgot-password link, no footer) matches
|
||||
the board.
|
||||
- [ ] No session, retrying state: identical spinner to Verifying.
|
||||
- [ ] Missing code state: ResultPanel with a single secondary action.
|
||||
- [ ] Keyboard/focus: focus moves to the ResultPanel title on the Success, Invalid-or-expired, and
|
||||
Missing-code states; on No session, focus order is Username → Password → show/hide → submit;
|
||||
the spinner's `role="status"` text is announced once, not on every re-render; the cancel-failed
|
||||
alert is `role="alert"` and does not steal focus from the ResultPanel title.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real POST /v1/auth/verify and POST /v1/auth/login against a running server** — Vitest only mocks
|
||||
`useApi`; confirm the real 200/403/401 round-trips (including the actual bcrypt code check and
|
||||
2-hour expiry) against `task dev` with a live SQLite file. The endpoint's own contract is already
|
||||
covered by `features/account_verification.qa.md` and `features/login_screen.qa.md`; this slice
|
||||
only re-verifies the screen consumes it correctly.
|
||||
- **Real DELETE /v1/auth/register from this screen** — confirm the 200/403/500 round-trips against a
|
||||
live server; the endpoint's own contract is already covered by
|
||||
`features/cancel_pending_registration.qa.md`.
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
)
|
||||
|
||||
type SmtpSettings struct {
|
||||
Email string
|
||||
Login string
|
||||
Sender string
|
||||
Password string
|
||||
Host string
|
||||
Port int
|
||||
@@ -61,7 +62,8 @@ func NewSettingsFromEnv() (Settings, error) {
|
||||
DBFile: os.Getenv("DB_FILE"),
|
||||
Port: os.Getenv("PORT"),
|
||||
Smtp: SmtpSettings{
|
||||
Email: os.Getenv("SMTP_EMAIL"),
|
||||
Login: os.Getenv("SMTP_LOGIN"),
|
||||
Sender: os.Getenv("SMTP_SENDER"),
|
||||
Password: os.Getenv("SMTP_PASSWORD"),
|
||||
Host: os.Getenv("SMTP_HOST"),
|
||||
Port: smtpPort,
|
||||
|
||||
@@ -22,37 +22,53 @@ func NewMailRepository(smtp config.SmtpSettings) *MailRepository {
|
||||
return &MailRepository{smtp: smtp}
|
||||
}
|
||||
|
||||
func (m MailRepository) Send(receiverAddress string, templateName string, data any) error {
|
||||
from := m.smtp.Email
|
||||
password := m.smtp.Password
|
||||
smtpHost := m.smtp.Host
|
||||
smtpPort := m.smtp.Port
|
||||
const defaultSubject = "RenewCMS | Action required"
|
||||
|
||||
d := gomail.NewDialer(smtpHost, smtpPort, from, password)
|
||||
func (m MailRepository) Render(templateName string, data any) (string, string, error) {
|
||||
subject := defaultSubject
|
||||
switch templateName {
|
||||
case "verification":
|
||||
subject = "Verify your RenewCMS account"
|
||||
case "password-reset":
|
||||
subject = "Reset your RenewCMS password"
|
||||
}
|
||||
|
||||
tmpl, err := template.ParseFS(mailTemplateFiles, "templates/"+templateName+".html")
|
||||
if err != nil {
|
||||
log.Println("error when template.ParseFS:", err)
|
||||
return err
|
||||
return subject, "", err
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
err = tmpl.Execute(&body, data)
|
||||
if err != nil {
|
||||
if err := tmpl.Execute(&body, data); err != nil {
|
||||
log.Println("error when tmpl.Execute:", err)
|
||||
return subject, "", err
|
||||
}
|
||||
|
||||
return subject, body.String(), nil
|
||||
}
|
||||
|
||||
func (m MailRepository) Send(receiverAddress string, templateName string, data any) error {
|
||||
login := m.smtp.Login
|
||||
sender := m.smtp.Sender
|
||||
password := m.smtp.Password
|
||||
smtpHost := m.smtp.Host
|
||||
smtpPort := m.smtp.Port
|
||||
|
||||
d := gomail.NewDialer(smtpHost, smtpPort, login, password)
|
||||
|
||||
subject, body, err := m.Render(templateName, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := gomail.NewMessage()
|
||||
msg.SetHeaders(map[string][]string{
|
||||
"From": {"RenewCMS <" + from + ">"},
|
||||
"From": {"RenewCMS <" + sender + ">"},
|
||||
"To": {receiverAddress},
|
||||
"MIME-version": {"1.0"},
|
||||
"Content-Type": {"text/html"},
|
||||
"charset": {"UTF-8"},
|
||||
"Subject": {"RenewCMS | Action required"},
|
||||
"Subject": {subject},
|
||||
})
|
||||
msg.SetBody("text/html", body.String())
|
||||
msg.SetBody("text/html", body)
|
||||
|
||||
if err := d.DialAndSend(msg); err != nil {
|
||||
log.Println("error when sending email:", err)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<body>
|
||||
<h1>
|
||||
<img src="{{.Host}}/static/renewcms-favicon-64.png" alt="G" style="width: 2.5rem"/>
|
||||
RenewCMS
|
||||
</h1>
|
||||
<main>
|
||||
<p>You or someone tried to create a RenewCMS admin account with this e-mail address. If you are not responsible for
|
||||
this, please do not validate the account creation.</p>
|
||||
<p><b>If you want to validate the account creation, please <a
|
||||
href="{{.Host}}/register/validate?c={{.VerificationCode}}" target="_blank">click HERE</a>.</b>
|
||||
</p>
|
||||
<p>If you can't click the link:</p>
|
||||
<p>{{.Host}}/register/validate?c={{.VerificationCode}}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>Reset your password</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
|
||||
table { border-collapse: collapse; }
|
||||
a { color: #111111; }
|
||||
@media (max-width: 620px) {
|
||||
.rc-card { padding: 28px 20px !important; }
|
||||
.rc-btn a { display: block !important; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.rc-bg { background-color: #0a0a0a !important; }
|
||||
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
|
||||
.rc-h1, .rc-word { color: #fafafa !important; }
|
||||
.rc-text { color: #e6e4e0 !important; }
|
||||
.rc-muted { color: #999999 !important; }
|
||||
.rc-mark { border: 1px solid #444444 !important; }
|
||||
.rc-btn td { background-color: #fafafa !important; }
|
||||
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
|
||||
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
|
||||
.rc-rule { border-color: #333333 !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Choose a new password for RenewCMS. The link expires in 1 hour.</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
|
||||
<tr>
|
||||
<td align="center" style="padding:40px 12px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
|
||||
<tr>
|
||||
<td style="padding:0 0 20px 0;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
|
||||
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
|
||||
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Reset your password</h1>
|
||||
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">Someone asked to reset the password for the RenewCMS account that uses {{.Email}} at {{.Host}}.</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
|
||||
<tr>
|
||||
<td bgcolor="#111111" style="background-color:#111111;">
|
||||
<a href="{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Choose a new password</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="rc-muted" style="margin:16px 0 0 0;font-size:14px;line-height:22px;color:#666666;">This link expires in 1 hour and works once.</p>
|
||||
<p class="rc-muted" style="margin:28px 0 8px 0;font-size:14px;line-height:22px;color:#666666;">If the button doesn't work, copy this address into your browser:</p>
|
||||
<p class="rc-url" style="margin:0;padding:10px 12px;background-color:#f3f2f0;font-family:'IBM Plex Mono',Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#222222;word-break:break-all;">{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}</p>
|
||||
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">If this wasn't you, ignore this email; nothing will change.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,18 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<body>
|
||||
<h1>
|
||||
<img src="{{.Host}}/static/renewcms-favicon-64.png" alt="G" style="width: 2.5rem"/>
|
||||
RenewCMS
|
||||
</h1>
|
||||
<main>
|
||||
<p>You or someone tried to create reset your RenewCMS account with this e-mail address. If you are not responsible for
|
||||
this, please do not validate the password reset.</p>
|
||||
<p><b>If you want to reset your password, please <a
|
||||
href="{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}" target="_blank">click HERE</a>.</b>
|
||||
</p>
|
||||
<p>If you can't click the link:</p>
|
||||
<p>{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>Verify your account</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
|
||||
table { border-collapse: collapse; }
|
||||
a { color: #111111; }
|
||||
@media (max-width: 620px) {
|
||||
.rc-card { padding: 28px 20px !important; }
|
||||
.rc-btn a { display: block !important; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.rc-bg { background-color: #0a0a0a !important; }
|
||||
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
|
||||
.rc-h1, .rc-word { color: #fafafa !important; }
|
||||
.rc-text { color: #e6e4e0 !important; }
|
||||
.rc-muted { color: #999999 !important; }
|
||||
.rc-mark { border: 1px solid #444444 !important; }
|
||||
.rc-btn td { background-color: #fafafa !important; }
|
||||
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
|
||||
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
|
||||
.rc-rule { border-color: #333333 !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Confirm your email to finish setting up RenewCMS. The link expires in 2 hours.</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
|
||||
<tr>
|
||||
<td align="center" style="padding:40px 12px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
|
||||
<tr>
|
||||
<td style="padding:0 0 20px 0;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
|
||||
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
|
||||
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Verify your account</h1>
|
||||
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">Someone, hopefully you, created the owner account for RenewCMS at {{.Host}}. Verify this email address to finish setting it up.</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
|
||||
<tr>
|
||||
<td bgcolor="#111111" style="background-color:#111111;">
|
||||
<a href="{{.Host}}/register/validate?c={{.VerificationCode}}" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Verify my account</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="rc-muted" style="margin:16px 0 0 0;font-size:14px;line-height:22px;color:#666666;">This link expires in 2 hours.</p>
|
||||
<p class="rc-muted" style="margin:28px 0 8px 0;font-size:14px;line-height:22px;color:#666666;">If the button doesn't work, copy this address into your browser:</p>
|
||||
<p class="rc-url" style="margin:0;padding:10px 12px;background-color:#f3f2f0;font-family:'IBM Plex Mono',Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#222222;word-break:break-all;">{{.Host}}/register/validate?c={{.VerificationCode}}</p>
|
||||
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">If this wasn't you, ignore this email; nothing will change.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -18,7 +18,7 @@ func NewArticleRepository(db *gorm.DB) *ArticleRepository {
|
||||
}
|
||||
|
||||
func (a *ArticleRepository) FindByFilters(filters domain.Filters) ([]domain.Article, error) {
|
||||
query := a.db.Model(&entity.Article{})
|
||||
query := a.db.Model(&entity.Article{}).Preload("Images")
|
||||
|
||||
if filters.IsOnline != nil {
|
||||
query = query.Where("is_online = ?", *filters.IsOnline)
|
||||
|
||||
@@ -65,7 +65,7 @@ func (g *CreateUserUseCase) CreateUser(createUser CreateUserCommand) (user.User,
|
||||
|
||||
createdUser.VerificationCode = rawUuid
|
||||
|
||||
if err := g.mailRepository.Send(createdUser.Email, "mailValidation", MailValidationData{
|
||||
if err := g.mailRepository.Send(createdUser.Email, "verification", MailValidationData{
|
||||
Host: g.publicURL,
|
||||
VerificationCode: rawUuid,
|
||||
}); err != nil {
|
||||
|
||||
@@ -46,7 +46,7 @@ func (g *RequestPasswordResetUseCase) RequestPasswordReset(email string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := g.mailRepository.Send(foundUser.Email, "passwordReset", MailPasswordResetData{
|
||||
if err := g.mailRepository.Send(foundUser.Email, "password-reset", MailPasswordResetData{
|
||||
Host: g.publicURL,
|
||||
VerificationCode: rawCode,
|
||||
Email: foundUser.Email,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/handlers/article"
|
||||
domainArticle "RenewCMS/internal/domain/article"
|
||||
domainImage "RenewCMS/internal/domain/image"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
|
||||
validator "RenewCMS/api/pkg/validator"
|
||||
)
|
||||
|
||||
func newArticlesListRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
|
||||
userRepo := newVerifiedUserRepo()
|
||||
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
|
||||
|
||||
articleHandler := article.NewHandler(
|
||||
useCases.NewCreateArticleUseCase(articleRepo),
|
||||
useCases.NewGetArticleUseCase(articleRepo),
|
||||
useCases.NewListArticlesUseCase(articleRepo),
|
||||
useCases.NewDeleteArticleUseCase(articleRepo, newMockImageRepo()),
|
||||
useCases.NewUpdateArticleUseCase(articleRepo),
|
||||
getUserUseCase,
|
||||
)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Route("/v1", func(r chi.Router) {
|
||||
r.Route("/article", func(r chi.Router) {
|
||||
r.Use(jwtauth.Verifier(token))
|
||||
r.Get("/", articleHandler.ListArticles)
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestArticlesListHttp(t *testing.T) {
|
||||
validator.InitValidator()
|
||||
token := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
|
||||
t.Run("GET /v1/article/ returns each article's images array populated", func(t *testing.T) {
|
||||
repo := newMockArticleRepo()
|
||||
repo.articles[4] = domainArticle.Article{
|
||||
ID: 4,
|
||||
Title: "Winter checklist for timber windows",
|
||||
Images: []*domainImage.Image{
|
||||
{ID: 10, ArticleID: 4},
|
||||
{ID: 11, ArticleID: 4},
|
||||
},
|
||||
}
|
||||
|
||||
router := newArticlesListRouter(repo, token)
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/article/", nil)
|
||||
setJwtCookie(t, req, token, 1)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", rec.Code)
|
||||
}
|
||||
|
||||
var got []domainArticle.Article
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
var found *domainArticle.Article
|
||||
for i := range got {
|
||||
if got[i].ID == 4 {
|
||||
found = &got[i]
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("expected article id 4 in response, got %v", got)
|
||||
}
|
||||
if len(found.Images) != 2 {
|
||||
t.Fatalf("expected 2 images, got %d", len(found.Images))
|
||||
}
|
||||
ids := map[uint32]bool{}
|
||||
for _, img := range found.Images {
|
||||
ids[img.ID] = true
|
||||
}
|
||||
if !ids[10] || !ids[11] {
|
||||
t.Errorf("expected images 10 and 11, got %v", found.Images)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -90,8 +90,8 @@ func TestRegistrationGateAndVerificationEmailHttp(t *testing.T) {
|
||||
if sent.Address != "alice@example.com" {
|
||||
t.Errorf("expected email sent to alice@example.com, got %q", sent.Address)
|
||||
}
|
||||
if sent.Template != "mailValidation" {
|
||||
t.Errorf("expected template mailValidation, got %q", sent.Template)
|
||||
if sent.Template != "verification" {
|
||||
t.Errorf("expected template verification, got %q", sent.Template)
|
||||
}
|
||||
|
||||
storedUser, err := userRepo.GetByUsername("alice")
|
||||
@@ -260,8 +260,8 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) {
|
||||
if mailRepo.SentEmails[0].Address != "carol@example.com" {
|
||||
t.Errorf("expected email sent to carol@example.com, got %q", mailRepo.SentEmails[0].Address)
|
||||
}
|
||||
if mailRepo.SentEmails[0].Template != "mailValidation" {
|
||||
t.Errorf("expected template mailValidation, got %q", mailRepo.SentEmails[0].Template)
|
||||
if mailRepo.SentEmails[0].Template != "verification" {
|
||||
t.Errorf("expected template verification, got %q", mailRepo.SentEmails[0].Template)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ func TestRequestPasswordResetHTTP(t *testing.T) {
|
||||
if mailRepo.SentEmails[0].Address != "alice@example.com" {
|
||||
t.Errorf("expected email to alice@example.com, got %s", mailRepo.SentEmails[0].Address)
|
||||
}
|
||||
if mailRepo.SentEmails[0].Template != "passwordReset" {
|
||||
t.Errorf("expected template passwordReset, got %s", mailRepo.SentEmails[0].Template)
|
||||
if mailRepo.SentEmails[0].Template != "password-reset" {
|
||||
t.Errorf("expected template password-reset, got %s", mailRepo.SentEmails[0].Template)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -204,8 +204,8 @@ func TestRequestPasswordResetHTTP(t *testing.T) {
|
||||
if mailRepo.SentEmails[0].Address != "bob@example.com" {
|
||||
t.Errorf("expected email to bob@example.com, got %s", mailRepo.SentEmails[0].Address)
|
||||
}
|
||||
if mailRepo.SentEmails[0].Template != "passwordReset" {
|
||||
t.Errorf("expected template passwordReset, got %s", mailRepo.SentEmails[0].Template)
|
||||
if mailRepo.SentEmails[0].Template != "password-reset" {
|
||||
t.Errorf("expected template password-reset, got %s", mailRepo.SentEmails[0].Template)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ func TestRequestPasswordResetUseCase(t *testing.T) {
|
||||
if sent.Address != "alice@example.com" {
|
||||
t.Errorf("expected email to alice@example.com, got %s", sent.Address)
|
||||
}
|
||||
if sent.Template != "passwordReset" {
|
||||
t.Errorf("expected template passwordReset, got %s", sent.Template)
|
||||
if sent.Template != "password-reset" {
|
||||
t.Errorf("expected template password-reset, got %s", sent.Template)
|
||||
}
|
||||
data, ok := sent.Data.(useCases.MailPasswordResetData)
|
||||
if !ok {
|
||||
|
||||
@@ -14,7 +14,8 @@ func setBaseSettingsEnv(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", "test-secret")
|
||||
t.Setenv("DB_FILE", "./data/renewcms.db")
|
||||
t.Setenv("PORT", "8080")
|
||||
t.Setenv("SMTP_EMAIL", "notify@example.com")
|
||||
t.Setenv("SMTP_LOGIN", "smtp-login@example.com")
|
||||
t.Setenv("SMTP_SENDER", "notify@example.com")
|
||||
t.Setenv("SMTP_PASSWORD", "hunter2")
|
||||
t.Setenv("SMTP_HOST", "smtp.example.com")
|
||||
t.Setenv("SMTP_PORT", "465")
|
||||
@@ -50,8 +51,11 @@ func TestNewSettingsFromEnvReadsAllFields(t *testing.T) {
|
||||
if settings.Port != "8080" {
|
||||
t.Errorf("expected Port %q, got %q", "8080", settings.Port)
|
||||
}
|
||||
if settings.Smtp.Email != "notify@example.com" {
|
||||
t.Errorf("expected Smtp.Email %q, got %q", "notify@example.com", settings.Smtp.Email)
|
||||
if settings.Smtp.Login != "smtp-login@example.com" {
|
||||
t.Errorf("expected Smtp.Login %q, got %q", "smtp-login@example.com", settings.Smtp.Login)
|
||||
}
|
||||
if settings.Smtp.Sender != "notify@example.com" {
|
||||
t.Errorf("expected Smtp.Sender %q, got %q", "notify@example.com", settings.Smtp.Sender)
|
||||
}
|
||||
if settings.Smtp.Password != "hunter2" {
|
||||
t.Errorf("expected Smtp.Password %q, got %q", "hunter2", settings.Smtp.Password)
|
||||
|
||||
@@ -230,7 +230,8 @@ func TestRuntimeSettingsInjectionUploadDirectory(t *testing.T) {
|
||||
|
||||
func TestRuntimeSettingsInjectionMailer(t *testing.T) {
|
||||
smtp := config.SmtpSettings{
|
||||
Email: "notify@example.com",
|
||||
Login: "smtp-login@example.com",
|
||||
Sender: "notify@example.com",
|
||||
Host: "smtp.example.com",
|
||||
Port: 587,
|
||||
Password: "unused-in-test",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"RenewCMS/internal/infrastructure/mailer"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTransactionalEmailTemplates(t *testing.T) {
|
||||
t.Run("render() produces the verification email's subject, link and copy", func(t *testing.T) {
|
||||
repo := mailer.NewMailRepository(config.SmtpSettings{})
|
||||
|
||||
subject, body, err := repo.Render("verification", useCases.MailValidationData{
|
||||
Host: "https://cms.example.com",
|
||||
VerificationCode: "abc123",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if subject != "Verify your RenewCMS account" {
|
||||
t.Errorf("expected subject %q, got %q", "Verify your RenewCMS account", subject)
|
||||
}
|
||||
if !strings.Contains(body, "https://cms.example.com/register/validate?c=abc123") {
|
||||
t.Errorf("expected body to contain the verification link, got %q", body)
|
||||
}
|
||||
if !strings.Contains(body, "Verify my account") {
|
||||
t.Errorf("expected body to contain the button text, got %q", body)
|
||||
}
|
||||
if !strings.Contains(body, "This link expires in 2 hours.") {
|
||||
t.Errorf("expected body to contain the expiry copy, got %q", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("render() produces the password-reset email's subject, link and copy", func(t *testing.T) {
|
||||
repo := mailer.NewMailRepository(config.SmtpSettings{})
|
||||
|
||||
subject, body, err := repo.Render("password-reset", useCases.MailPasswordResetData{
|
||||
Host: "https://cms.example.com",
|
||||
VerificationCode: "abc123",
|
||||
Email: "alice@example.com",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if subject != "Reset your RenewCMS password" {
|
||||
t.Errorf("expected subject %q, got %q", "Reset your RenewCMS password", subject)
|
||||
}
|
||||
if !strings.Contains(body, "https://cms.example.com/register/reset/validate?c=abc123&email=alice%40example.com") {
|
||||
t.Errorf("expected body to contain the password-reset link, got %q", body)
|
||||
}
|
||||
if !strings.Contains(body, "Choose a new password") {
|
||||
t.Errorf("expected body to contain the button text, got %q", body)
|
||||
}
|
||||
if !strings.Contains(body, "This link expires in 1 hour and works once.") {
|
||||
t.Errorf("expected body to contain the expiry copy, got %q", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("render() falls back to the old default subject for an unrecognized template name", func(t *testing.T) {
|
||||
repo := mailer.NewMailRepository(config.SmtpSettings{})
|
||||
|
||||
subject, body, err := repo.Render("does-not-exist", nil)
|
||||
|
||||
if subject != "RenewCMS | Action required" {
|
||||
t.Errorf("expected fallback subject, got %q", subject)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected an error because templates/does-not-exist.html cannot be found")
|
||||
}
|
||||
if body != "" {
|
||||
t.Errorf("expected empty body, got %q", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A template execution failure is returned without sending", func(t *testing.T) {
|
||||
repo := mailer.NewMailRepository(config.SmtpSettings{})
|
||||
|
||||
type incompatibleData struct {
|
||||
Unrelated int
|
||||
}
|
||||
|
||||
_, body, err := repo.Render("verification", incompatibleData{Unrelated: 1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error from a failed template execution")
|
||||
}
|
||||
if body != "" {
|
||||
t.Errorf("expected empty body, got %q", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Send() returns a render error without dialing SMTP", func(t *testing.T) {
|
||||
repo := mailer.NewMailRepository(config.SmtpSettings{})
|
||||
|
||||
_, _, renderErr := repo.Render("does-not-exist", nil)
|
||||
err := repo.Send("someone@example.com", "does-not-exist", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error from a failed render, got nil")
|
||||
}
|
||||
if err.Error() != renderErr.Error() {
|
||||
t.Errorf("expected Send to return the render error %q, got %q", renderErr, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The old template files no longer exist under their previous names", func(t *testing.T) {
|
||||
for _, oldFile := range []string{"mailValidation.html", "passwordReset.html"} {
|
||||
path := "../internal/infrastructure/mailer/templates/" + oldFile
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("expected %s to not exist, got err=%v", path, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -28,6 +28,9 @@ coverage
|
||||
|
||||
.eslintcache
|
||||
|
||||
/auto-imports.d.ts
|
||||
/components.d.ts
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
+743
-22
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>RenewCMS</title>
|
||||
</head>
|
||||
|
||||
+7
-4
@@ -16,17 +16,19 @@
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^2.0.2",
|
||||
"@nuxt/ui": "^4.11.2",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@vueuse/core": "14.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.5.4",
|
||||
"shiki": "^4.4.3",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss-primeui": "^0.6.1",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.136",
|
||||
"@iconify/utils": "^3.1.7",
|
||||
"@tsconfig/node24": "^24.0.3",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
@@ -39,6 +41,7 @@
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "25",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"playwright": "^1.63.0",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.2.4",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" shape-rendering="crispEdges" xmlns:c2pa="http://c2pa.org/manifest"><metadata><c2pa:manifest>AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTplOTQ3NWYzYi1iMzk1LTQwYTItYTc0NC02OTBiNWNhN2ExYmEAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaBZb8xjia8MHL36GYqgFh9IAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDphMzk4M2EzMy03MTA2LTQwODctYjI1Yy05ZDBjMDgyNTBhNWNscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNoZLnQVKFQmfShOH339JG7cQAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggm30c/9ae4hGpd7ZqF9l6g7kujDirGaXyTi536jB+bcekZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaLY9Kv67+CqoKTGwY2CZrToAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCB5yyMJKOtdrWszMUivonxA1KFo3L7r2RCA4Mx1H0/3uWRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBiYZmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOmU5NDc1ZjNiLWIzOTUtNDBhMi1hNzQ0LTY5MGI1Y2E3YTFiYS9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjViZTQwMjZjLWM3ZjctNDUzNC1hYmFiLTY3OTc0OWZjNGFkY3JjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCbfRz/1p7iEal3tmoX2XqDuS6MOKsZpfJOLnfqMH5tx6JjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggM1R3FsRboXyA4rm7pAVOcC/HIi2cgqmrpa/mZrSD7n6iY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggoD1kSgBM7/QspOsFvy5FPeDatbpPPR1Z+79+Ox5QvZZ0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALine truncated
|
||||
|
After Width: | Height: | Size: 7.9 KiB |
+3
-7
@@ -1,13 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import NavComponent from './components/NavComponent.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav-component />
|
||||
<main>
|
||||
<router-view />
|
||||
</main>
|
||||
<UApp class="isolate">
|
||||
<RouterView />
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
+370
-7
@@ -1,14 +1,377 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss-primeui";
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&display=swap');
|
||||
@import 'tailwindcss';
|
||||
@import '@nuxt/ui';
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@theme static {
|
||||
--font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-serif: 'Source Serif 4', ui-serif, Georgia, serif;
|
||||
--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace;
|
||||
|
||||
--color-ink-50: #fafafa;
|
||||
--color-ink-100: #f2f2f2;
|
||||
--color-ink-200: #e5e5e5;
|
||||
--color-ink-300: #cccccc;
|
||||
--color-ink-400: #999999;
|
||||
--color-ink-500: #666666;
|
||||
--color-ink-600: #444444;
|
||||
--color-ink-700: #333333;
|
||||
--color-ink-800: #222222;
|
||||
--color-ink-900: #111111;
|
||||
--color-ink-950: #0a0a0a;
|
||||
|
||||
--color-chalk-50: #fafafa;
|
||||
--color-chalk-100: #f3f2f0;
|
||||
--color-chalk-200: #e6e4e0;
|
||||
--color-chalk-300: #cfccc7;
|
||||
--color-chalk-400: #999999;
|
||||
--color-chalk-450: #8a8783;
|
||||
--color-chalk-500: #666666;
|
||||
--color-chalk-600: #444444;
|
||||
--color-chalk-700: #333333;
|
||||
--color-chalk-800: #222222;
|
||||
--color-chalk-850: #1a1a1a;
|
||||
--color-chalk-900: #111111;
|
||||
--color-chalk-950: #0a0a0a;
|
||||
|
||||
--color-sand-50: #f7f4ef;
|
||||
--color-sand-100: #f0eae1;
|
||||
--color-sand-200: #e8dfd1;
|
||||
--color-sand-300: #d4c5b0;
|
||||
--color-sand-400: #bba88e;
|
||||
--color-sand-500: #9c8a70;
|
||||
--color-sand-600: #7d6d56;
|
||||
--color-sand-700: #635643;
|
||||
--color-sand-800: #4a4033;
|
||||
--color-sand-900: #322b23;
|
||||
--color-sand-950: #211c17;
|
||||
|
||||
--color-moss-50: #f0faf0;
|
||||
--color-moss-100: #e1f3e1;
|
||||
--color-moss-200: #c7e2c7;
|
||||
--color-moss-300: #a3caa3;
|
||||
--color-moss-400: #7fac7f;
|
||||
--color-moss-500: #5f8e60;
|
||||
--color-moss-600: #457347;
|
||||
--color-moss-700: #315b33;
|
||||
--color-moss-800: #1f4422;
|
||||
--color-moss-900: #143015;
|
||||
--color-moss-950: #081e0a;
|
||||
|
||||
--color-slate-50: #f0f8ff;
|
||||
--color-slate-100: #e2effa;
|
||||
--color-slate-200: #c8dcee;
|
||||
--color-slate-300: #a5c2db;
|
||||
--color-slate-400: #81a3c0;
|
||||
--color-slate-500: #6284a2;
|
||||
--color-slate-600: #486a86;
|
||||
--color-slate-700: #34536d;
|
||||
--color-slate-800: #223d53;
|
||||
--color-slate-900: #162b3c;
|
||||
--color-slate-950: #0a1a26;
|
||||
|
||||
--color-ochre-50: #fff5e8;
|
||||
--color-ochre-100: #fdead1;
|
||||
--color-ochre-200: #f1d4ad;
|
||||
--color-ochre-300: #dfb67d;
|
||||
--color-ochre-400: #c4954f;
|
||||
--color-ochre-500: #a67628;
|
||||
--color-ochre-600: #8a5b00;
|
||||
--color-ochre-700: #6c4700;
|
||||
--color-ochre-800: #503400;
|
||||
--color-ochre-900: #3a2400;
|
||||
--color-ochre-950: #241500;
|
||||
|
||||
--color-brick-50: #fef4f2;
|
||||
--color-brick-100: #ffe7e2;
|
||||
--color-brick-200: #ffcbc2;
|
||||
--color-brick-300: #fba394;
|
||||
--color-brick-400: #e37e6e;
|
||||
--color-brick-500: #c45d4e;
|
||||
--color-brick-600: #a54134;
|
||||
--color-brick-700: #882c21;
|
||||
--color-brick-800: #6a1a11;
|
||||
--color-brick-900: #4d0f08;
|
||||
--color-brick-950: #330503;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-xs--line-height: 16px;
|
||||
--text-sm: 0.8125rem;
|
||||
--text-sm--line-height: 20px;
|
||||
--text-base: 0.875rem;
|
||||
--text-base--line-height: 22px;
|
||||
--text-lg: 1rem;
|
||||
--text-lg--line-height: 24px;
|
||||
--text-xl: 1.25rem;
|
||||
--text-xl--line-height: 28px;
|
||||
--text-2xl: 1.75rem;
|
||||
--text-2xl--line-height: 36px;
|
||||
--text-code: 0.75rem;
|
||||
--text-code--line-height: 19px;
|
||||
|
||||
--breakpoint-xs: 22.5rem;
|
||||
--breakpoint-sm: 40rem;
|
||||
--breakpoint-md: 48rem;
|
||||
--breakpoint-lg: 64rem;
|
||||
--breakpoint-xl: 80rem;
|
||||
|
||||
--shadow-popover: 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);
|
||||
--shadow-modal: 0 16px 48px rgb(17 17 17 / 0.18);
|
||||
|
||||
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||
--ease-enter: cubic-bezier(0, 0, 0, 1);
|
||||
--ease-exit: cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
body{
|
||||
:root {
|
||||
--ui-radius: 0rem;
|
||||
--ui-header-height: 3rem;
|
||||
--ui-container: 72rem;
|
||||
--duration-fast: 120ms;
|
||||
--duration-base: 180ms;
|
||||
--duration-slow: 240ms;
|
||||
--z-sticky: 10;
|
||||
--z-sidebar: 20;
|
||||
--z-banner: 30;
|
||||
--z-popover: 40;
|
||||
--z-overlay: 50;
|
||||
--z-toast: 60;
|
||||
--z-tooltip: 70;
|
||||
--ui-bg: var(--color-white);
|
||||
--ui-bg-muted: var(--color-chalk-50);
|
||||
--ui-bg-elevated: var(--color-chalk-100);
|
||||
--ui-bg-accented: var(--color-chalk-200);
|
||||
--ui-bg-inverted: var(--color-chalk-900);
|
||||
--ui-text-dimmed: var(--color-chalk-400);
|
||||
--ui-text-muted: var(--color-chalk-500);
|
||||
--ui-text-toned: var(--color-chalk-600);
|
||||
--ui-text: var(--color-chalk-800);
|
||||
--ui-text-highlighted: var(--color-chalk-900);
|
||||
--ui-text-inverted: var(--color-white);
|
||||
--ui-border: var(--color-chalk-200);
|
||||
--ui-border-muted: var(--color-chalk-100);
|
||||
--ui-border-accented: var(--color-chalk-450);
|
||||
--ui-border-inverted: var(--color-chalk-900);
|
||||
--ui-primary: var(--ui-color-primary-900);
|
||||
--ui-secondary: var(--ui-color-secondary-700);
|
||||
--ui-success: var(--ui-color-success-700);
|
||||
--ui-info: var(--ui-color-info-700);
|
||||
--ui-warning: var(--ui-color-warning-700);
|
||||
--ui-error: var(--ui-color-error-700);
|
||||
--rc-success-soft: #eaefeb;
|
||||
--rc-info-soft: #ebeef0;
|
||||
--rc-warning-soft: #f0ede6;
|
||||
--rc-error-soft: #f3eae9;
|
||||
--rc-overlay: rgb(10 10 10 / 0.4);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--ui-bg: var(--color-chalk-900);
|
||||
--ui-bg-muted: var(--color-chalk-950);
|
||||
--ui-bg-elevated: var(--color-chalk-850);
|
||||
--ui-bg-accented: var(--color-chalk-800);
|
||||
--ui-bg-inverted: var(--color-chalk-50);
|
||||
--ui-text-dimmed: var(--color-chalk-500);
|
||||
--ui-text-muted: var(--color-chalk-400);
|
||||
--ui-text-toned: var(--color-chalk-300);
|
||||
--ui-text: var(--color-chalk-200);
|
||||
--ui-text-highlighted: var(--color-chalk-50);
|
||||
--ui-text-inverted: var(--color-chalk-900);
|
||||
--ui-border: var(--color-chalk-700);
|
||||
--ui-border-muted: var(--color-chalk-800);
|
||||
--ui-border-accented: var(--color-chalk-500);
|
||||
--ui-border-inverted: var(--color-chalk-50);
|
||||
--ui-primary: var(--ui-color-primary-50);
|
||||
--ui-secondary: var(--ui-color-secondary-300);
|
||||
--ui-success: var(--ui-color-success-300);
|
||||
--ui-info: var(--ui-color-info-300);
|
||||
--ui-warning: var(--ui-color-warning-300);
|
||||
--ui-error: var(--ui-color-error-300);
|
||||
--rc-success-soft: #202420;
|
||||
--rc-info-soft: #202325;
|
||||
--rc-warning-soft: #26221c;
|
||||
--rc-error-soft: #28201e;
|
||||
--rc-overlay: rgb(0 0 0 / 0.6);
|
||||
--shadow-popover: 0 4px 16px rgb(0 0 0 / 0.5);
|
||||
--shadow-modal: 0 16px 48px rgb(0 0 0 / 0.6);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--ui-bg-muted);
|
||||
color: var(--ui-text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--text-sm--line-height);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.article-content {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.75rem;
|
||||
color: var(--ui-text-highlighted);
|
||||
max-width: 42rem;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.article-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.article-content h1,
|
||||
.article-content h2,
|
||||
.article-content h3,
|
||||
.article-content h4 {
|
||||
font-weight: 600;
|
||||
text-wrap: balance;
|
||||
color: var(--ui-text-highlighted);
|
||||
}
|
||||
.article-content h1 {
|
||||
font-size: 1.75rem;
|
||||
line-height: 2.25rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.article-content h2 {
|
||||
font-size: 1.4375rem;
|
||||
line-height: 1.9375rem;
|
||||
margin: 2.5rem 0 0.75rem;
|
||||
}
|
||||
.article-content h3 {
|
||||
font-size: 1.1875rem;
|
||||
line-height: 1.75rem;
|
||||
margin: 2rem 0 0.5rem;
|
||||
}
|
||||
.article-content h4 {
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.625rem;
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
}
|
||||
.article-content p {
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.75rem;
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
.article-content ul,
|
||||
.article-content ol {
|
||||
margin: 0 0 1.25rem;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
.article-content ul {
|
||||
list-style: square;
|
||||
}
|
||||
.article-content ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
.article-content li {
|
||||
margin: 0 0 0.375rem;
|
||||
}
|
||||
.article-content li > p {
|
||||
margin: 0;
|
||||
}
|
||||
.article-content li::marker {
|
||||
color: var(--ui-text-muted);
|
||||
}
|
||||
.article-content [data-indent='1'] {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
.article-content [data-indent='2'] {
|
||||
padding-left: 3rem;
|
||||
}
|
||||
.article-content [data-indent='3'] {
|
||||
padding-left: 4.5rem;
|
||||
}
|
||||
.article-content [data-indent='4'] {
|
||||
padding-left: 6rem;
|
||||
}
|
||||
.article-content blockquote {
|
||||
margin: 1.75rem 0;
|
||||
padding-left: 1.25rem;
|
||||
border-left: 2px solid var(--ui-primary);
|
||||
font-style: italic;
|
||||
color: var(--ui-text-toned);
|
||||
}
|
||||
.article-content a {
|
||||
color: var(--ui-text-highlighted);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.article-content a:hover {
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
.article-content img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 1.75rem 0;
|
||||
}
|
||||
.article-content figure {
|
||||
margin: 1.75rem 0;
|
||||
}
|
||||
.article-content figure img {
|
||||
margin: 0;
|
||||
}
|
||||
.article-content figcaption {
|
||||
margin-top: 0.5rem;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--ui-text-muted);
|
||||
}
|
||||
@media (min-width: 48rem) {
|
||||
.article-content h1 {
|
||||
font-size: 2rem;
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
.article-content h2 {
|
||||
font-size: 1.625rem;
|
||||
line-height: 2.125rem;
|
||||
}
|
||||
.article-content h3 {
|
||||
font-size: 1.3125rem;
|
||||
line-height: 1.875rem;
|
||||
}
|
||||
.article-content h4 {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
.article-content p {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.875rem;
|
||||
}
|
||||
.article-content {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.875rem;
|
||||
}
|
||||
.article-content figcaption {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.button-position {
|
||||
@apply fixed bottom-0 left-0 m-3;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
.animate-spin {
|
||||
animation-duration: 1.6s !important;
|
||||
animation-iteration-count: infinite !important;
|
||||
}
|
||||
}
|
||||
|
||||
.shiki-block :where(pre.shiki) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.shiki-block :where(pre.shiki),
|
||||
.shiki-block :where(pre.shiki span) {
|
||||
color: var(--shiki-light);
|
||||
}
|
||||
|
||||
.dark .shiki-block :where(pre.shiki),
|
||||
.dark .shiki-block :where(pre.shiki span) {
|
||||
color: var(--shiki-dark);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4 p-6" role="status" aria-label="Loading">
|
||||
<USkeleton class="h-6 w-40" />
|
||||
<USkeleton class="h-32 w-full" />
|
||||
<USkeleton class="h-32 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
}>(),
|
||||
{ size: 32 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
role="img"
|
||||
aria-label="RenewCMS"
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 16 16"
|
||||
shape-rendering="crispEdges"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect width="16" height="16" fill="#111111" />
|
||||
<path
|
||||
fill="#ffffff"
|
||||
fill-rule="evenodd"
|
||||
d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import { formatRelativeDate } from '@/lib/relativeDate'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
|
||||
const props = defineProps<{
|
||||
articles: Article[]
|
||||
loading: boolean
|
||||
busyIds: Set<number>
|
||||
now?: Date
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
publish: [id: number]
|
||||
unpublish: [id: number]
|
||||
delete: [article: Article]
|
||||
'copy-url': [id: number]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function editPath(article: Article) {
|
||||
return `/article/${article.id}/edit`
|
||||
}
|
||||
|
||||
function isBusy(article: Article) {
|
||||
return props.busyIds.has(article.id)
|
||||
}
|
||||
|
||||
function pendingFor(article: Article) {
|
||||
if (!isBusy(article)) {
|
||||
return undefined
|
||||
}
|
||||
return article.is_online ? ('unpublish' as const) : ('publish' as const)
|
||||
}
|
||||
|
||||
function actionsFor(article: Article) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: 'i-lucide-pencil',
|
||||
onSelect: () => router.push(editPath(article)),
|
||||
},
|
||||
{
|
||||
label: article.is_online ? 'Unpublish' : 'Publish',
|
||||
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
|
||||
disabled: isBusy(article),
|
||||
onSelect: () => {
|
||||
if (article.is_online) {
|
||||
emit('unpublish', article.id)
|
||||
} else {
|
||||
emit('publish', article.id)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Copy API URL',
|
||||
icon: 'i-lucide-link',
|
||||
onSelect: () => emit('copy-url', article.id),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Delete…',
|
||||
icon: 'i-lucide-trash-2',
|
||||
color: 'error' as const,
|
||||
onSelect: () => emit('delete', article),
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
function onRowClick(article: Article, event: MouseEvent) {
|
||||
if ((event.target as HTMLElement).closest('[data-row-menu]')) {
|
||||
return
|
||||
}
|
||||
router.push(editPath(article))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
class="-mx-4 border-y border-default bg-default md:hidden"
|
||||
:aria-busy="loading ? 'true' : undefined"
|
||||
>
|
||||
<template v-if="loading">
|
||||
<li
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
class="flex min-h-17 flex-col justify-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
|
||||
>
|
||||
<USkeleton class="h-3.5 w-3/4 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-2/5 rounded-none bg-accented" />
|
||||
</li>
|
||||
</template>
|
||||
<template v-else>
|
||||
<li v-if="articles.length === 0">
|
||||
<slot name="empty" />
|
||||
</li>
|
||||
<li
|
||||
v-for="article in articles"
|
||||
:key="article.id"
|
||||
class="relative flex min-h-17 items-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
|
||||
:class="{ 'opacity-55': isBusy(article) }"
|
||||
@click="onRowClick(article, $event)"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<RouterLink
|
||||
:to="editPath(article)"
|
||||
:data-row-title="article.id"
|
||||
class="truncate text-[0.9375rem]/[21px] font-medium text-highlighted"
|
||||
@click.stop
|
||||
>
|
||||
{{ article.title }}
|
||||
</RouterLink>
|
||||
<div class="flex items-center gap-2.5 text-xs text-muted">
|
||||
<ArticleStatusBadge
|
||||
:online="article.is_online"
|
||||
size="sm"
|
||||
:pending="pendingFor(article)"
|
||||
/>
|
||||
<span :title="article.updated_at"
|
||||
>Updated {{ formatRelativeDate(article.updated_at, now) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<UDropdownMenu
|
||||
:items="actionsFor(article)"
|
||||
:content="{ align: 'end' }"
|
||||
data-row-menu
|
||||
>
|
||||
<UButton
|
||||
icon="i-lucide-ellipsis-vertical"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="`Actions for ${article.title}`"
|
||||
class="size-11 justify-center text-toned"
|
||||
@click.stop
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
online: boolean
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
pending?: 'publish' | 'unpublish'
|
||||
}>(),
|
||||
{
|
||||
size: 'md',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
v-if="pending"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-loader-circle"
|
||||
:size="size"
|
||||
:class="size === 'lg' ? undefined : 'px-2'"
|
||||
:ui="{ leadingIcon: 'animate-spin' }"
|
||||
>
|
||||
{{ pending === 'publish' ? 'Publishing…' : 'Unpublishing…' }}
|
||||
</UBadge>
|
||||
<UBadge v-else-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
|
||||
Online
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-else
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil-line"
|
||||
:size="size"
|
||||
:class="size === 'lg' ? undefined : 'px-2'"
|
||||
>
|
||||
Draft
|
||||
</UBadge>
|
||||
</template>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
|
||||
const props = defineProps<{
|
||||
articles: Article[]
|
||||
loading: boolean
|
||||
busyIds: Set<number>
|
||||
now?: Date
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
publish: [id: number]
|
||||
unpublish: [id: number]
|
||||
delete: [article: Article]
|
||||
'copy-url': [id: number]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const columnWidths =
|
||||
'[&:nth-child(2)]:w-27.5 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-35 [&:nth-child(5)]:w-11'
|
||||
|
||||
const columns = [
|
||||
{ accessorKey: 'title', header: 'Title' },
|
||||
{ accessorKey: 'is_online', header: 'Status', size: 110 },
|
||||
{ accessorKey: 'created_at', header: 'Created', size: 130 },
|
||||
{ accessorKey: 'updated_at', header: 'Updated', size: 140 },
|
||||
{ id: 'actions', header: '', size: 44 },
|
||||
]
|
||||
|
||||
function editPath(article: Article) {
|
||||
return `/article/${article.id}/edit`
|
||||
}
|
||||
|
||||
function isBusy(article: Article) {
|
||||
return props.busyIds.has(article.id)
|
||||
}
|
||||
|
||||
function pendingFor(article: Article) {
|
||||
if (!isBusy(article)) {
|
||||
return undefined
|
||||
}
|
||||
return article.is_online ? ('unpublish' as const) : ('publish' as const)
|
||||
}
|
||||
|
||||
function actionsFor(article: Article) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: 'i-lucide-pencil',
|
||||
onSelect: () => router.push(editPath(article)),
|
||||
},
|
||||
{
|
||||
label: article.is_online ? 'Unpublish' : 'Publish',
|
||||
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
|
||||
disabled: isBusy(article),
|
||||
onSelect: () => {
|
||||
if (article.is_online) {
|
||||
emit('unpublish', article.id)
|
||||
} else {
|
||||
emit('publish', article.id)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Copy API URL',
|
||||
icon: 'i-lucide-link',
|
||||
onSelect: () => emit('copy-url', article.id),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Delete…',
|
||||
icon: 'i-lucide-trash-2',
|
||||
color: 'error' as const,
|
||||
onSelect: () => emit('delete', article),
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
function onSelect(row: { original: Article }) {
|
||||
router.push(editPath(row.original))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
|
||||
<span role="status" class="sr-only">Loading articles</span>
|
||||
<div
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0"
|
||||
>
|
||||
<USkeleton class="h-3 max-w-[55%] flex-1 rounded-none bg-accented" />
|
||||
<USkeleton class="h-5 w-15 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="hidden border border-default bg-default md:block">
|
||||
<UTable
|
||||
:data="articles"
|
||||
:columns="columns"
|
||||
:ui="{
|
||||
base: 'table-fixed',
|
||||
tr: 'cursor-pointer',
|
||||
th: `px-3 ${columnWidths}`,
|
||||
td: `h-11 px-3 py-0 ${columnWidths}`,
|
||||
}"
|
||||
@select="onSelect"
|
||||
>
|
||||
<template #caption>
|
||||
<caption class="sr-only">
|
||||
Articles, sorted by last updated, newest first
|
||||
</caption>
|
||||
</template>
|
||||
|
||||
<template #title-cell="{ row }">
|
||||
<RouterLink
|
||||
:to="editPath(row.original)"
|
||||
:data-row-title="row.original.id"
|
||||
class="font-medium text-highlighted"
|
||||
@click.stop
|
||||
>
|
||||
{{ row.original.title }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
<template #is_online-cell="{ row }">
|
||||
<ArticleStatusBadge
|
||||
:online="row.original.is_online"
|
||||
size="sm"
|
||||
:pending="pendingFor(row.original)"
|
||||
:aria-busy="isBusy(row.original) ? 'true' : undefined"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #updated_at-header>
|
||||
<span class="inline-flex items-center gap-1 text-highlighted">
|
||||
Updated
|
||||
<UIcon name="i-lucide-arrow-down" class="size-3" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #created_at-cell="{ row }">
|
||||
<span class="text-muted">{{
|
||||
formatAbsoluteDateFromIso(row.original.created_at)
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #updated_at-cell="{ row }">
|
||||
<span class="text-muted" :title="row.original.updated_at">{{
|
||||
formatRelativeDate(row.original.updated_at, now)
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<template #actions-cell="{ row }">
|
||||
<UDropdownMenu :items="actionsFor(row.original)" :content="{ align: 'end' }">
|
||||
<UButton
|
||||
icon="i-lucide-ellipsis"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:aria-label="`Actions for ${row.original.title}`"
|
||||
class="size-8 justify-center text-toned"
|
||||
@click.stop
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<slot name="empty" />
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, useSlots, watch } from 'vue'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import { isInAppNavigation } from '@/composables/useAuthShellNavigation'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
const titleEl = ref<HTMLHeadingElement | null>(null)
|
||||
const belowSm = ref(false)
|
||||
let mediaQuery: MediaQueryList | null = null
|
||||
|
||||
function updateBelowSm() {
|
||||
belowSm.value = mediaQuery?.matches ?? false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
mediaQuery = window.matchMedia('(max-width: 39.9975rem)')
|
||||
updateBelowSm()
|
||||
mediaQuery.addEventListener('change', updateBelowSm)
|
||||
}
|
||||
if (isInAppNavigation()) {
|
||||
titleEl.value?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mediaQuery?.removeEventListener('change', updateBelowSm)
|
||||
})
|
||||
|
||||
const controlSize = computed(() => (belowSm.value ? 'xl' : undefined))
|
||||
const controlClass = computed(() =>
|
||||
belowSm.value ? '!h-11 !text-base' : '!h-10 !text-base',
|
||||
)
|
||||
const inputClass = computed(() =>
|
||||
belowSm.value ? '!h-11 !text-lg' : '!h-10 !text-base',
|
||||
)
|
||||
|
||||
const mainClass = computed(() => [
|
||||
'flex min-h-screen flex-col items-center gap-5',
|
||||
belowSm.value ? 'bg-default pt-10' : 'bg-muted pt-[12vh]',
|
||||
])
|
||||
|
||||
const cardClass = computed(() =>
|
||||
belowSm.value ? 'w-full' : 'w-full max-w-[400px]',
|
||||
)
|
||||
|
||||
const cardUi = computed(() => ({
|
||||
root: belowSm.value
|
||||
? 'rounded-none ring-0'
|
||||
: 'rounded-none ring-0 border border-default',
|
||||
container: belowSm.value
|
||||
? 'flex flex-col gap-6 px-5 pt-2 pb-0 lg:flex'
|
||||
: 'flex flex-col gap-6 p-10 sm:p-10 lg:flex',
|
||||
header: props.title ? 'mb-6' : 'mb-0',
|
||||
title: 'text-xl/7 font-semibold',
|
||||
description: 'mt-1.5 text-base text-muted',
|
||||
}))
|
||||
|
||||
const footerClass = computed(() => [
|
||||
'flex w-full flex-wrap items-center justify-between gap-3 text-sm text-muted',
|
||||
belowSm.value ? 'px-5 pb-6' : 'p-0',
|
||||
cardClass.value,
|
||||
])
|
||||
|
||||
const colorModeSelectUi = {
|
||||
base: 'h-7 ps-7 pe-2 text-sm text-toned',
|
||||
leading: 'ps-2',
|
||||
leadingIcon: 'size-3.5 text-toned',
|
||||
trailing: 'hidden',
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.title,
|
||||
(title) => {
|
||||
if (!title) {
|
||||
return
|
||||
}
|
||||
document.title = `${title} · RenewCMS`
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.title,
|
||||
(title) => {
|
||||
if (!title) {
|
||||
return
|
||||
}
|
||||
titleEl.value?.focus()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main :class="mainClass">
|
||||
<UPageCard
|
||||
variant="outline"
|
||||
:description="description"
|
||||
:ui="cardUi"
|
||||
data-testid="auth-card"
|
||||
:class="cardClass"
|
||||
>
|
||||
<template #header>
|
||||
<AppLogo :size="32" />
|
||||
</template>
|
||||
|
||||
<template v-if="title" #title>
|
||||
<h1 ref="titleEl" tabindex="-1" class="outline-none">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</template>
|
||||
|
||||
<slot v-if="slots.notice" name="notice" />
|
||||
|
||||
<slot
|
||||
:control-size="controlSize"
|
||||
:control-class="controlClass"
|
||||
:input-class="inputClass"
|
||||
/>
|
||||
</UPageCard>
|
||||
|
||||
<footer v-if="!belowSm || $slots.footer" :class="footerClass">
|
||||
<span class="flex flex-wrap items-center gap-1.5">
|
||||
<slot name="footer" />
|
||||
</span>
|
||||
<UColorModeSelect size="sm" :ui="colorModeSelectUi" />
|
||||
</footer>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,139 @@
|
||||
<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' | 'under'>(() => {
|
||||
if (props.min > 0 && props.value < props.min) {
|
||||
return 'under'
|
||||
}
|
||||
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 = ''
|
||||
}
|
||||
})
|
||||
|
||||
const isErrorStatus = computed(() => status.value === 'over' || status.value === 'under')
|
||||
</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': isErrorStatus,
|
||||
'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': isErrorStatus,
|
||||
'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-xs"
|
||||
:class="{
|
||||
'text-warning': status === 'warn',
|
||||
'text-error': isErrorStatus,
|
||||
'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>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { highlightToHtml, useShikiHighlighter } from '@/composables/useShikiHighlighter'
|
||||
|
||||
const props = defineProps<{
|
||||
code: string
|
||||
lang: string
|
||||
label?: string
|
||||
}>()
|
||||
|
||||
const { copied, copy } = useCopyToClipboard()
|
||||
|
||||
const buttonLabel = computed(() => (copied.value ? 'Copied' : 'Copy'))
|
||||
const buttonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
|
||||
|
||||
const highlightedHtml = ref<string | null>(null)
|
||||
|
||||
watchEffect(async () => {
|
||||
const highlighter = await useShikiHighlighter()
|
||||
highlightedHtml.value = highlightToHtml(highlighter, props.code, props.lang)
|
||||
})
|
||||
|
||||
function onCopy() {
|
||||
copy(props.code)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-elevated">
|
||||
<div class="flex h-8 items-center justify-between border-b border-default pr-2 pl-4">
|
||||
<span class="font-mono text-xs leading-tight font-medium text-muted">{{
|
||||
label ?? lang
|
||||
}}</span>
|
||||
<UButton
|
||||
:icon="buttonIcon"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:aria-label="`Copy ${label ?? lang} example`"
|
||||
aria-live="polite"
|
||||
class="gap-1.5 px-1.5 font-medium text-toned"
|
||||
:ui="{ leadingIcon: 'size-3.5' }"
|
||||
@click="onCopy"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</UButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="highlightedHtml"
|
||||
tabindex="0"
|
||||
class="shiki-block overflow-x-auto px-4 py-3.5 font-mono text-code whitespace-pre"
|
||||
v-html="highlightedHtml"
|
||||
/>
|
||||
<pre
|
||||
v-else
|
||||
tabindex="0"
|
||||
class="overflow-x-auto px-4 py-3.5 font-mono text-code whitespace-pre text-default"
|
||||
>{{ code }}</pre
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel: string
|
||||
cancelLabel: string
|
||||
pendingLabel: string
|
||||
tone: 'danger' | 'neutral'
|
||||
action: () => Promise<void>
|
||||
dismissResolvesTo?: 'cancel' | 'confirm'
|
||||
}>(),
|
||||
{
|
||||
dismissResolvesTo: 'cancel',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ close: [confirmed: boolean] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: true })
|
||||
const pending = ref(false)
|
||||
const error = ref('')
|
||||
const cancelButton = ref<{ $el: HTMLElement } | null>(null)
|
||||
let settled = false
|
||||
|
||||
const confirmColor = computed(() =>
|
||||
props.tone === 'danger' ? 'error' : 'primary',
|
||||
)
|
||||
const confirmText = computed(() =>
|
||||
pending.value ? props.pendingLabel : props.confirmLabel,
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
cancelButton.value?.$el?.focus()
|
||||
})
|
||||
|
||||
function settle(confirmed: boolean) {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
open.value = false
|
||||
emit('close', confirmed)
|
||||
}
|
||||
|
||||
watch(open, (value) => {
|
||||
if (!value) {
|
||||
settle(props.dismissResolvesTo === 'confirm')
|
||||
}
|
||||
})
|
||||
|
||||
async function onConfirm() {
|
||||
if (pending.value) {
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await props.action()
|
||||
settle(true)
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Something went wrong. Try again.'
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (pending.value) {
|
||||
return
|
||||
}
|
||||
settle(false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:dismissible="!pending"
|
||||
:close="false"
|
||||
:content="{
|
||||
role: 'alertdialog',
|
||||
'aria-busy': pending ? 'true' : undefined,
|
||||
}"
|
||||
:ui="{
|
||||
content:
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-[220px] sm:max-w-110 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
|
||||
header: 'min-h-0 px-6 pt-6 pb-0 sm:px-6 max-sm:px-5 max-sm:pt-5',
|
||||
title: 'text-lg max-sm:text-[1.0625rem]/6',
|
||||
description:
|
||||
'mt-2 text-base text-toned max-sm:text-[0.9375rem]/[23px]',
|
||||
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',
|
||||
}"
|
||||
>
|
||||
<template v-if="error" #body>
|
||||
<UAlert
|
||||
color="error"
|
||||
icon="i-lucide-circle-x"
|
||||
:description="error"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
role="alert"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div
|
||||
class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end"
|
||||
>
|
||||
<UButton
|
||||
ref="cancelButton"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
class="h-11 justify-center text-[0.9375rem] text-highlighted disabled:text-dimmed disabled:opacity-100 sm:h-8 sm:w-auto sm:px-3 sm:text-sm"
|
||||
:disabled="pending"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ cancelLabel }}
|
||||
</UButton>
|
||||
<UButton
|
||||
:color="confirmColor"
|
||||
:loading="pending"
|
||||
:disabled="pending"
|
||||
block
|
||||
class="h-11 justify-center text-[0.9375rem] sm:h-8 sm:w-auto sm:px-3 sm:text-sm"
|
||||
@click="onConfirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import AppBoot from '@/components/AppBoot.vue'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import ErrorView from '@/components/ErrorView.vue'
|
||||
import OfflineBanner from '@/components/OfflineBanner.vue'
|
||||
import UserMenu from '@/components/UserMenu.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mobileTitle?: string
|
||||
fullBleed?: boolean
|
||||
}>(),
|
||||
{
|
||||
mobileTitle: undefined,
|
||||
fullBleed: false,
|
||||
},
|
||||
)
|
||||
|
||||
const bodyUi = computed(() =>
|
||||
props.fullBleed
|
||||
? 'gap-0 p-0 sm:gap-0 sm:p-0 lg:p-0'
|
||||
: 'gap-0 px-4 pt-5 pb-22 sm:gap-0 sm:px-4 sm:pt-5 sm:pb-22 lg:p-8',
|
||||
)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
const api = useApi()
|
||||
|
||||
const navItems = computed(() =>
|
||||
[
|
||||
{ label: 'Home', to: '/home', icon: 'i-lucide-home' },
|
||||
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
|
||||
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
|
||||
].map((item) => ({
|
||||
...item,
|
||||
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
|
||||
})),
|
||||
)
|
||||
|
||||
const routeTitle = computed(() => (route.meta.title as string) ?? '')
|
||||
const pageTitle = computed(() => props.mobileTitle ?? routeTitle.value)
|
||||
const sidebarCollapsed = ref(false)
|
||||
const loggingOut = ref(false)
|
||||
|
||||
function onRetry() {
|
||||
session.retry()
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
loggingOut.value = true
|
||||
try {
|
||||
await api.request('/v1/auth/logout', { method: 'POST' })
|
||||
} finally {
|
||||
loggingOut.value = false
|
||||
}
|
||||
session.clear()
|
||||
await router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDashboardGroup unit="rem">
|
||||
<a href="#dashboard-content" class="sr-only focus:not-sr-only"
|
||||
>Skip to content</a
|
||||
>
|
||||
|
||||
<UDashboardSidebar
|
||||
v-model:collapsed="sidebarCollapsed"
|
||||
collapsible
|
||||
:resizable="false"
|
||||
:default-size="14"
|
||||
:collapsed-size="3.5"
|
||||
:ui="{
|
||||
root: 'min-w-14 p-3',
|
||||
header: 'mb-3 h-10 gap-2.5 px-1.5',
|
||||
body: 'gap-0.5 p-0',
|
||||
footer: 'p-0',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<AppLogo :size="26" />
|
||||
<span
|
||||
v-if="!sidebarCollapsed"
|
||||
aria-hidden="true"
|
||||
class="text-base font-semibold text-highlighted"
|
||||
>RenewCMS</span
|
||||
>
|
||||
</template>
|
||||
|
||||
<UNavigationMenu
|
||||
orientation="vertical"
|
||||
:items="navItems"
|
||||
:collapsed="sidebarCollapsed"
|
||||
aria-label="Main"
|
||||
:ui="{
|
||||
list: 'flex flex-col gap-0.5',
|
||||
link: 'h-8 gap-2.5 px-2.5 py-0 text-sm',
|
||||
linkLeadingIcon: 'size-4',
|
||||
}"
|
||||
/>
|
||||
|
||||
<template #footer>
|
||||
<UserMenu
|
||||
:username="session.session?.username ?? ''"
|
||||
:collapsed="sidebarCollapsed"
|
||||
@logout="onLogout"
|
||||
/>
|
||||
</template>
|
||||
</UDashboardSidebar>
|
||||
|
||||
<UDashboardPanel :ui="{ body: bodyUi }">
|
||||
<template #header>
|
||||
<UDashboardNavbar
|
||||
:toggle="{ ui: { leadingIcon: 'size-5' } }"
|
||||
:title="pageTitle"
|
||||
:ui="{
|
||||
root: 'h-14 gap-1 bg-default px-1.5 sm:px-1.5 lg:h-12 lg:gap-2 lg:px-6',
|
||||
left: 'min-w-0 flex-1 gap-1 lg:gap-2',
|
||||
title: 'text-lg lg:sr-only',
|
||||
toggle: 'size-11 justify-center',
|
||||
}"
|
||||
>
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="-mx-1.5 text-muted"
|
||||
:aria-expanded="!sidebarCollapsed"
|
||||
data-testid="sidebar-collapse"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="mx-1.5 hidden h-4 w-px bg-(--ui-border) lg:block"
|
||||
/>
|
||||
<slot name="breadcrumb">
|
||||
<UBreadcrumb
|
||||
:items="[{ label: routeTitle }]"
|
||||
color="neutral"
|
||||
class="hidden lg:block"
|
||||
:ui="{ link: 'text-sm font-medium' }"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
<template #right>
|
||||
<slot name="navbar-right" />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div
|
||||
id="dashboard-content"
|
||||
:class="
|
||||
fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''
|
||||
"
|
||||
>
|
||||
<OfflineBanner />
|
||||
|
||||
<AppBoot v-if="session.status === 'checking'" />
|
||||
<ErrorView
|
||||
v-else-if="session.status === 'error'"
|
||||
kind="error"
|
||||
@retry="onRetry"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
:class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UDashboardPanel>
|
||||
</UDashboardGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const QUERY_MAX_LENGTH = 40
|
||||
|
||||
const props = defineProps<{
|
||||
variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online'
|
||||
query?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ action: [] }>()
|
||||
|
||||
const truncatedQuery = computed(() => {
|
||||
const value = props.query ?? ''
|
||||
return value.length > QUERY_MAX_LENGTH ? value.slice(0, QUERY_MAX_LENGTH) : value
|
||||
})
|
||||
|
||||
const variants = computed(() => ({
|
||||
'no-articles': {
|
||||
icon: 'i-lucide-file-text',
|
||||
title: 'No articles yet',
|
||||
description: 'Write your first one. It stays a draft until you publish it.',
|
||||
action: 'New article',
|
||||
primary: true,
|
||||
},
|
||||
'no-results': {
|
||||
icon: 'i-lucide-search',
|
||||
title: `No articles match "${truncatedQuery.value}"`,
|
||||
description: 'Check the spelling, or search for a shorter part of the title.',
|
||||
action: 'Clear search',
|
||||
primary: false,
|
||||
},
|
||||
'no-drafts': {
|
||||
icon: 'i-lucide-pencil-line',
|
||||
title: 'No drafts',
|
||||
description: "Everything you've written is online.",
|
||||
action: 'New article',
|
||||
primary: false,
|
||||
},
|
||||
'no-online': {
|
||||
icon: 'i-lucide-globe',
|
||||
title: 'Nothing is online yet',
|
||||
description: 'Publish a draft and it will appear on your website.',
|
||||
action: 'Show drafts',
|
||||
primary: false,
|
||||
},
|
||||
}))
|
||||
|
||||
const current = computed(() => variants.value[props.variant])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
:icon="current.icon"
|
||||
:title="current.title"
|
||||
:description="current.description"
|
||||
:ui="{
|
||||
root: 'gap-3 border border-default bg-default px-6 py-14',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
|
||||
title: 'text-[0.9375rem] font-semibold',
|
||||
description: 'max-w-70 text-muted text-pretty',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
:color="current.primary ? 'primary' : 'neutral'"
|
||||
:variant="current.primary ? 'solid' : 'outline'"
|
||||
class="h-8 px-3"
|
||||
@click="emit('action')"
|
||||
>
|
||||
{{ current.action }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import CodeBlock from '@/components/CodeBlock.vue'
|
||||
|
||||
defineProps<{
|
||||
method: 'GET'
|
||||
path: string
|
||||
description: string
|
||||
examples: { fetch: string; curl: string; response: string }
|
||||
}>()
|
||||
|
||||
const activeTab = ref('fetch')
|
||||
|
||||
const tabItems = [
|
||||
{ label: 'fetch', value: 'fetch' },
|
||||
{ label: 'curl', value: 'curl' },
|
||||
{ label: 'Response', value: 'response' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
:ui="{
|
||||
root: 'border border-default ring-0 divide-default',
|
||||
header: 'p-4 sm:p-4',
|
||||
body: 'p-0 sm:p-0',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-center gap-2.5">
|
||||
<UBadge :label="method" color="neutral" variant="solid" class="font-mono" />
|
||||
<span class="font-mono text-base font-medium text-highlighted">{{ path }}</span>
|
||||
</div>
|
||||
<span class="text-toned">{{ description }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<UTabs
|
||||
v-model="activeTab"
|
||||
:items="tabItems"
|
||||
variant="link"
|
||||
color="primary"
|
||||
:ui="{
|
||||
root: 'gap-0',
|
||||
list: 'gap-5 border-b border-default px-4 py-0 before:hidden',
|
||||
trigger: 'h-9 grow-0 px-0 text-sm font-normal data-[state=active]:font-medium',
|
||||
content: 'mt-0',
|
||||
}"
|
||||
>
|
||||
<template #content="{ item }">
|
||||
<CodeBlock
|
||||
v-if="item.value === 'fetch'"
|
||||
label="JavaScript"
|
||||
lang="javascript"
|
||||
:code="examples.fetch"
|
||||
/>
|
||||
<pre
|
||||
v-else-if="item.value === 'curl'"
|
||||
tabindex="0"
|
||||
class="overflow-x-auto bg-elevated px-4 py-3.5 font-mono text-code whitespace-pre text-default"
|
||||
>{{ examples.curl }}</pre
|
||||
>
|
||||
<pre
|
||||
v-else
|
||||
tabindex="0"
|
||||
class="overflow-x-auto bg-elevated px-4 py-3.5 font-mono text-code whitespace-pre text-default"
|
||||
>{{ examples.response }}</pre
|
||||
>
|
||||
</template>
|
||||
</UTabs>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
kind: 'not-found' | 'error' | 'article-not-found'
|
||||
breadcrumb?: string
|
||||
}>(),
|
||||
{
|
||||
breadcrumb: 'Home',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const copy = computed(() => {
|
||||
if (props.kind === 'not-found') {
|
||||
return {
|
||||
title: 'Page not found',
|
||||
text: 'The link may be mistyped, or the page may have moved.',
|
||||
primary: 'Go to Home',
|
||||
secondary: 'All articles',
|
||||
}
|
||||
}
|
||||
if (props.kind === 'article-not-found') {
|
||||
return {
|
||||
title: "This article doesn't exist.",
|
||||
text: 'It may have been deleted, or the link is wrong.',
|
||||
primary: 'All articles',
|
||||
secondary: '',
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: 'Something went wrong',
|
||||
text: "This page couldn't load. Your saved work is safe. Try again, and if it keeps happening, restart the RenewCMS server.",
|
||||
primary: 'Try again',
|
||||
secondary: 'Go to Home',
|
||||
}
|
||||
})
|
||||
|
||||
function onPrimary() {
|
||||
if (props.kind === 'error') {
|
||||
emit('retry')
|
||||
return
|
||||
}
|
||||
if (props.kind === 'article-not-found') {
|
||||
router.push('/article')
|
||||
return
|
||||
}
|
||||
router.push('/home')
|
||||
}
|
||||
|
||||
function onSecondary() {
|
||||
if (props.kind === 'error') {
|
||||
router.push('/home')
|
||||
return
|
||||
}
|
||||
router.push('/article')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div role="alert" class="flex max-w-140 flex-col gap-3.5 p-8">
|
||||
<nav v-if="kind !== 'article-not-found'" aria-label="Breadcrumb">
|
||||
<span>{{ breadcrumb }}</span>
|
||||
</nav>
|
||||
<span
|
||||
v-if="kind === 'article-not-found'"
|
||||
aria-hidden="true"
|
||||
class="font-mono text-sm text-muted"
|
||||
>404</span
|
||||
>
|
||||
<h1 tabindex="-1" class="text-2xl font-semibold text-highlighted">
|
||||
{{ copy.title }}
|
||||
</h1>
|
||||
<p v-if="copy.text" class="text-sm text-toned">{{ copy.text }}</p>
|
||||
<div class="flex gap-2">
|
||||
<UButton class="h-8" @click="onPrimary">{{ copy.primary }}</UButton>
|
||||
<UButton
|
||||
v-if="copy.secondary"
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
class="h-8"
|
||||
@click="onSecondary"
|
||||
>{{ copy.secondary }}</UButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Button, Drawer } from 'primevue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const visible = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<Drawer v-model:visible="visible">
|
||||
<template #container="{ closeCallback }">
|
||||
<ul @click="closeCallback">
|
||||
<li><RouterLink to="/">Home</RouterLink></li>
|
||||
<li><RouterLink to="/article">Article</RouterLink></li>
|
||||
</ul>
|
||||
<div class="button-position">
|
||||
<Button
|
||||
type="button"
|
||||
@click="closeCallback"
|
||||
icon="pi pi-times"
|
||||
variant="outlined"
|
||||
></Button>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</header>
|
||||
<div class="button-position">
|
||||
<Button icon="pi pi-bars" @click="visible = true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
|
||||
const TITLE_MIN_LENGTH = 3
|
||||
const TITLE_MAX_LENGTH = 200
|
||||
const TITLE_PLACEHOLDER = 'e.g. Choosing tiles for a small bathroom'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const emit = defineEmits<{ created: [article: Article] }>()
|
||||
|
||||
const api = useApi()
|
||||
|
||||
const title = ref('')
|
||||
const body = ref('')
|
||||
const titleTouched = ref(false)
|
||||
const bodyTouched = ref(false)
|
||||
const titleFocused = ref(false)
|
||||
const submitting = ref(false)
|
||||
const titleServerError = ref('')
|
||||
const formAlert = ref<'' | 'invalid' | 'failed'>('')
|
||||
|
||||
const titleLength = computed(() => [...title.value].length)
|
||||
const titleValid = computed(
|
||||
() => titleLength.value >= TITLE_MIN_LENGTH && titleLength.value <= TITLE_MAX_LENGTH,
|
||||
)
|
||||
const bodyValid = computed(() => body.value.length > 0)
|
||||
|
||||
const titleLengthError = computed(() => {
|
||||
if (!titleTouched.value) {
|
||||
return ''
|
||||
}
|
||||
if (titleLength.value < TITLE_MIN_LENGTH) {
|
||||
return 'Use at least 3 characters.'
|
||||
}
|
||||
if (titleLength.value > TITLE_MAX_LENGTH) {
|
||||
return 'Use 3 to 200 characters.'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const titleError = computed(() => titleServerError.value || titleLengthError.value)
|
||||
|
||||
const bodyError = computed(() =>
|
||||
bodyTouched.value && !bodyValid.value
|
||||
? 'Add a first paragraph. It can be a single sentence.'
|
||||
: '',
|
||||
)
|
||||
|
||||
const titleCounterMin = computed(() => (titleTouched.value ? TITLE_MIN_LENGTH : 0))
|
||||
|
||||
const showHelp = computed(() => !submitting.value && formAlert.value === '')
|
||||
|
||||
const alertMessage = computed(() => {
|
||||
if (formAlert.value === 'invalid') {
|
||||
return "Something in the form isn't valid. Check the highlighted fields."
|
||||
}
|
||||
if (formAlert.value === 'failed') {
|
||||
return "Couldn't create the article. Your text is still here. Try again in a moment."
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
title.value = ''
|
||||
body.value = ''
|
||||
titleTouched.value = false
|
||||
bodyTouched.value = false
|
||||
titleFocused.value = false
|
||||
submitting.value = false
|
||||
titleServerError.value = ''
|
||||
formAlert.value = ''
|
||||
}
|
||||
|
||||
async function focusTitleField() {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
document.querySelector<HTMLInputElement>(`input[placeholder="${TITLE_PLACEHOLDER}"]`)?.focus()
|
||||
titleFocused.value = true
|
||||
}
|
||||
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
return
|
||||
}
|
||||
resetForm()
|
||||
focusTitleField()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function onTitleBlur() {
|
||||
titleTouched.value = true
|
||||
titleFocused.value = false
|
||||
}
|
||||
|
||||
function onBodyBlur() {
|
||||
bodyTouched.value = true
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
titleTouched.value = true
|
||||
bodyTouched.value = true
|
||||
titleFocused.value = false
|
||||
|
||||
if (!titleValid.value || !bodyValid.value) {
|
||||
return
|
||||
}
|
||||
|
||||
titleServerError.value = ''
|
||||
formAlert.value = ''
|
||||
submitting.value = true
|
||||
try {
|
||||
const article = await api.request<Article>('/v1/article/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: title.value, body: body.value }),
|
||||
})
|
||||
open.value = false
|
||||
emit('created', article)
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 400) {
|
||||
titleServerError.value = 'Check this title and try again.'
|
||||
formAlert.value = 'invalid'
|
||||
} else {
|
||||
formAlert.value = 'failed'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
resetForm()
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="open"
|
||||
title="New article"
|
||||
description="It's saved as a draft. Titles can't be changed later, so check it now."
|
||||
:dismissible="!submitting"
|
||||
:ui="{
|
||||
content:
|
||||
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-140 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: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
|
||||
wrapper: 'pe-10',
|
||||
title: 'text-lg/6 font-semibold',
|
||||
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
|
||||
footer: 'px-6 py-5 sm:px-6',
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<UAlert
|
||||
v-if="alertMessage"
|
||||
role="alert"
|
||||
color="error"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
icon="i-lucide-circle-x"
|
||||
:ui="{ root: 'gap-2 px-3 py-2.5' }"
|
||||
:description="alertMessage"
|
||||
/>
|
||||
<UForm :state="{ title, body }" class="flex flex-col gap-4">
|
||||
<UFormField label="Title" name="title" :error="titleError || undefined">
|
||||
<template #hint>
|
||||
<CharacterCounter
|
||||
:value="titleLength"
|
||||
:max="TITLE_MAX_LENGTH"
|
||||
:min="titleCounterMin"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="titleError" #error="{ error }">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<UIcon
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="showHelp" #help>3 to 200 characters.</template>
|
||||
<UInput
|
||||
v-model="title"
|
||||
:placeholder="TITLE_PLACEHOLDER"
|
||||
:disabled="submitting"
|
||||
color="primary"
|
||||
:highlight="titleFocused || !!titleError"
|
||||
:ui="{ base: 'h-11 text-lg md:h-10 md:text-base' }"
|
||||
class="w-full"
|
||||
@focus="titleFocused = true"
|
||||
@blur="onTitleBlur"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="First paragraph" name="body" :error="bodyError || undefined">
|
||||
<template v-if="bodyError" #error="{ error }">
|
||||
<span class="flex items-center gap-1.5">
|
||||
<UIcon
|
||||
name="i-lucide-circle-alert"
|
||||
class="size-3.5 shrink-0 text-error"
|
||||
/>
|
||||
{{ error }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="showHelp" #help>
|
||||
You can format it and add images in the editor.
|
||||
</template>
|
||||
<UTextarea
|
||||
v-model="body"
|
||||
placeholder="Start writing. You'll continue in the editor."
|
||||
:disabled="submitting"
|
||||
:highlight="!!bodyError"
|
||||
:rows="3"
|
||||
:ui="{
|
||||
base: 'h-30 text-lg disabled:bg-elevated disabled:text-dimmed disabled:opacity-100 md:h-24 md:text-base',
|
||||
}"
|
||||
class="w-full"
|
||||
@blur="onBodyBlur"
|
||||
/>
|
||||
</UFormField>
|
||||
</UForm>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
:ui="{ base: 'h-11 md:h-8' }"
|
||||
class="sm:w-auto"
|
||||
:disabled="submitting"
|
||||
@click="onCancel"
|
||||
>
|
||||
Cancel
|
||||
</UButton>
|
||||
<UButton
|
||||
block
|
||||
:ui="{ base: 'h-11 md:h-8' }"
|
||||
class="sm:w-auto"
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ submitting ? 'Creating…' : 'Create draft' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const isOffline = ref(!navigator.onLine)
|
||||
|
||||
function updateOnlineStatus() {
|
||||
isOffline.value = !navigator.onLine
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('online', updateOnlineStatus)
|
||||
window.addEventListener('offline', updateOnlineStatus)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('online', updateOnlineStatus)
|
||||
window.removeEventListener('offline', updateOnlineStatus)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBanner
|
||||
v-if="isOffline"
|
||||
color="neutral"
|
||||
title="You're offline. You can keep writing, but saving and publishing will wait until you're back online."
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface PasswordRule {
|
||||
label: string
|
||||
met: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
rules: PasswordRule[]
|
||||
showErrors: boolean
|
||||
}>()
|
||||
|
||||
function iconClass(rule: PasswordRule) {
|
||||
if (rule.met) {
|
||||
return 'text-success'
|
||||
}
|
||||
return props.showErrors ? 'text-error' : 'text-muted'
|
||||
}
|
||||
|
||||
function textClass(rule: PasswordRule) {
|
||||
if (rule.met) {
|
||||
return 'text-default'
|
||||
}
|
||||
return props.showErrors ? 'text-error' : 'text-muted'
|
||||
}
|
||||
|
||||
function itemIcon(rule: PasswordRule) {
|
||||
if (rule.met) {
|
||||
return 'i-lucide-check'
|
||||
}
|
||||
return props.showErrors ? 'i-lucide-circle-alert' : 'i-lucide-circle'
|
||||
}
|
||||
|
||||
const items = computed(() =>
|
||||
props.rules.map((rule) => ({
|
||||
...rule,
|
||||
iconClass: iconClass(rule),
|
||||
textClass: textClass(rule),
|
||||
icon: itemIcon(rule),
|
||||
})),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
class="flex items-center gap-2 text-xs/[18px]"
|
||||
>
|
||||
<UIcon
|
||||
:name="item.icon"
|
||||
:class="['size-3.5 shrink-0', item.iconClass]"
|
||||
/>
|
||||
<span :class="item.textClass">{{ item.label }}</span>
|
||||
<span class="sr-only">{{ item.met ? 'met' : 'not met' }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -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,74 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
interface ResultPanelAction {
|
||||
label: string
|
||||
color?: 'primary' | 'neutral'
|
||||
variant?: 'solid' | 'soft' | 'outline' | 'ghost'
|
||||
to?: string
|
||||
disabled?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
tone: 'success' | 'error' | 'info'
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
actions: ResultPanelAction[]
|
||||
}>()
|
||||
|
||||
const role = computed(() => (props.tone === 'error' ? 'alert' : 'status'))
|
||||
|
||||
const toneTileClass: Record<'success' | 'error' | 'info', string> = {
|
||||
success: 'bg-(--rc-success-soft) text-success',
|
||||
error: 'bg-(--rc-error-soft) text-error',
|
||||
info: 'bg-(--rc-info-soft) text-info',
|
||||
}
|
||||
|
||||
const tileClass = computed(() => toneTileClass[props.tone])
|
||||
|
||||
const titleEl = ref<HTMLHeadingElement | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
titleEl.value?.focus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :role="role" class="flex flex-col gap-4">
|
||||
<div :class="['flex size-11 items-center justify-center', tileClass]">
|
||||
<UIcon :name="icon" class="size-5.5" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<h1
|
||||
ref="titleEl"
|
||||
tabindex="-1"
|
||||
class="text-xl/7 font-semibold text-highlighted outline-none"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p class="text-base text-pretty text-toned">{{ description }}</p>
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="actions.length" class="flex w-full flex-col gap-2">
|
||||
<UButton
|
||||
v-for="action in actions"
|
||||
:key="action.label"
|
||||
block
|
||||
:color="action.color ?? 'primary'"
|
||||
:variant="action.variant ?? 'solid'"
|
||||
:class="[
|
||||
'h-11 justify-center text-base sm:h-10',
|
||||
action.variant === 'outline' ? 'text-highlighted' : '',
|
||||
]"
|
||||
:to="action.to"
|
||||
:disabled="action.disabled"
|
||||
@click="action.onClick"
|
||||
>
|
||||
{{ action.label }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
useColorMode,
|
||||
type ColorModePreference,
|
||||
} from '@/composables/useColorMode'
|
||||
|
||||
const props = defineProps<{
|
||||
username: string
|
||||
collapsed: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
logout: []
|
||||
}>()
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const initials = computed(() => {
|
||||
const parts = props.username.split(/[^a-zA-Z0-9]+/).filter(Boolean)
|
||||
if (parts.length >= 2) {
|
||||
return (
|
||||
(parts[0] ?? '').charAt(0) + (parts[1] ?? '').charAt(0)
|
||||
).toUpperCase()
|
||||
}
|
||||
return (parts[0] ?? '').slice(0, 2).toUpperCase()
|
||||
})
|
||||
|
||||
const triggerAriaLabel = computed(() =>
|
||||
props.collapsed ? `${props.username} — account menu` : undefined,
|
||||
)
|
||||
|
||||
const colorModeOptions: { label: string; value: ColorModePreference }[] = [
|
||||
{ label: 'System', value: 'system' },
|
||||
{ label: 'Light', value: 'light' },
|
||||
{ label: 'Dark', value: 'dark' },
|
||||
]
|
||||
|
||||
const items = computed(() => [
|
||||
[{ type: 'label' as const, label: props.username }],
|
||||
colorModeOptions.map((option) => ({
|
||||
type: 'checkbox' as const,
|
||||
label: option.label,
|
||||
checked: colorMode.preference === option.value,
|
||||
onUpdateChecked: (checked: boolean) => {
|
||||
if (checked) {
|
||||
colorMode.preference = option.value
|
||||
}
|
||||
},
|
||||
})),
|
||||
[
|
||||
{
|
||||
label: 'Log out',
|
||||
onSelect: () => emit('logout'),
|
||||
},
|
||||
],
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UDropdownMenu :items="items" :content="{ side: 'top', align: 'start' }">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
block
|
||||
class="h-10 justify-start gap-2.5 px-1.5 font-normal text-highlighted"
|
||||
trailing-icon="i-lucide-chevrons-up-down"
|
||||
:ui="{ trailingIcon: 'ms-auto size-3.5 text-default' }"
|
||||
:aria-label="triggerAriaLabel"
|
||||
>
|
||||
<template #leading>
|
||||
<UAvatar
|
||||
aria-hidden="true"
|
||||
:text="initials"
|
||||
size="sm"
|
||||
:ui="{
|
||||
root: 'size-6.5 bg-secondary-300',
|
||||
fallback:
|
||||
'text-[0.6875rem] font-semibold text-primary-900',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
<span v-if="!collapsed" class="truncate">{{ username }}</span>
|
||||
</UButton>
|
||||
</UDropdownMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import AppLogo from '../AppLogo.vue'
|
||||
|
||||
describe('AppLogo renders the brand mark as inline SVG, and theme-switches', () => {
|
||||
it('renders an inline svg at 32px by default, not an img tag', () => {
|
||||
const wrapper = mount(AppLogo)
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
const svg = wrapper.find('svg')
|
||||
expect(svg.exists()).toBe(true)
|
||||
expect(svg.attributes('width')).toBe('32')
|
||||
expect(svg.attributes('height')).toBe('32')
|
||||
})
|
||||
|
||||
it('carries alt text through role=img and aria-label, and is not wrapped in a link', () => {
|
||||
const wrapper = mount(AppLogo)
|
||||
|
||||
const svg = wrapper.find('svg')
|
||||
expect(svg.attributes('role')).toBe('img')
|
||||
expect(svg.attributes('aria-label')).toBe('RenewCMS')
|
||||
expect(wrapper.find('a').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('sizes the svg through the size prop', () => {
|
||||
const wrapper = mount(AppLogo, { props: { size: 48 } })
|
||||
|
||||
const svg = wrapper.find('svg')
|
||||
expect(svg.attributes('width')).toBe('48')
|
||||
expect(svg.attributes('height')).toBe('48')
|
||||
})
|
||||
|
||||
it('has no edge stroke, in light mode or in dark mode', () => {
|
||||
const wrapper = mount(AppLogo)
|
||||
|
||||
const square = wrapper.find('rect')
|
||||
expect(square.attributes('fill')).toBe('#111111')
|
||||
expect(square.attributes('stroke')).toBeUndefined()
|
||||
expect(square.classes().some((c) => c.includes('stroke'))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import AuthShell from '../AuthShell.vue'
|
||||
import { resetAuthShellNavigation } from '@/composables/useAuthShellNavigation'
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthShellNavigation()
|
||||
})
|
||||
|
||||
function mountShell(
|
||||
props: Record<string, unknown>,
|
||||
slots: Record<string, unknown> = {},
|
||||
) {
|
||||
return mount(AuthShell, {
|
||||
props,
|
||||
slots,
|
||||
global: {
|
||||
plugins: [uiPlugin],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AuthShell renders its title as the page heading and sets the document title', () => {
|
||||
it('renders an h1 containing the title and sets document.title', () => {
|
||||
const wrapper = mountShell({ title: 'Log in' })
|
||||
|
||||
const heading = wrapper.find('h1')
|
||||
expect(heading.text()).toBe('Log in')
|
||||
expect(heading.attributes('tabindex')).toBe('-1')
|
||||
expect(document.title).toBe('Log in · RenewCMS')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AuthShell with an empty title renders no heading and leaves document.title untouched', () => {
|
||||
it('mounts directly with an empty title without rendering an h1 or touching document.title', () => {
|
||||
document.title = 'Existing title · RenewCMS'
|
||||
|
||||
const wrapper = mountShell({ title: '' })
|
||||
|
||||
expect(wrapper.find('h1').exists()).toBe(false)
|
||||
expect(document.title).toBe('Existing title · RenewCMS')
|
||||
})
|
||||
|
||||
it('leaves document.title untouched and does not crash when the title changes to empty', async () => {
|
||||
const wrapper = mountShell({ title: 'Create the owner account' })
|
||||
|
||||
expect(document.title).toBe('Create the owner account · RenewCMS')
|
||||
|
||||
await wrapper.setProps({ title: '' })
|
||||
|
||||
expect(wrapper.find('h1').exists()).toBe(false)
|
||||
expect(document.title).toBe('Create the owner account · RenewCMS')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AuthShell renders its notice and footer slots', () => {
|
||||
it('renders the notice slot as-is inside the card, after the title, and the footer slot outside the card', () => {
|
||||
const wrapper = mountShell(
|
||||
{ title: 'Log in' },
|
||||
{
|
||||
notice: '<span>Your session expired.</span>',
|
||||
footer: '<a href="/register/reset/request">Forgot password?</a>',
|
||||
},
|
||||
)
|
||||
|
||||
const notice = wrapper.find('span')
|
||||
expect(notice.text()).toBe('Your session expired.')
|
||||
expect(
|
||||
wrapper.findAll('[data-slot="root"][data-orientation]').length,
|
||||
).toBe(1)
|
||||
|
||||
const card = wrapper.find('[data-testid="auth-card"]')
|
||||
expect(card.element.contains(notice.element)).toBe(true)
|
||||
|
||||
const html = wrapper.html()
|
||||
expect(html.indexOf('<h1')).toBeLessThan(
|
||||
html.indexOf('Your session expired.'),
|
||||
)
|
||||
|
||||
const footerLink = wrapper.find('a[href="/register/reset/request"]')
|
||||
expect(footerLink.exists()).toBe(true)
|
||||
expect(footerLink.text()).toBe('Forgot password?')
|
||||
expect(card.element.contains(footerLink.element)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function mountDialog(action: () => Promise<void>) {
|
||||
return mount(ConfirmDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
title: 'Cancel your registration?',
|
||||
description:
|
||||
'Your account will be deleted. You can register again with the same or a different email.',
|
||||
confirmLabel: 'Cancel registration',
|
||||
cancelLabel: 'Keep my account',
|
||||
pendingLabel: 'Cancelling…',
|
||||
tone: 'danger',
|
||||
action,
|
||||
},
|
||||
global: {
|
||||
plugins: [uiPlugin],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function findButtonElement(text: string) {
|
||||
return Array.from(document.body.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === text,
|
||||
)
|
||||
}
|
||||
|
||||
async function clickButton(text: string) {
|
||||
findButtonElement(text)?.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('Confirm cancel — the destructive dialog opens', () => {
|
||||
it('renders title, description, both buttons, role and starts focus on the cancel button', async () => {
|
||||
const wrapper = mountDialog(() => new Promise(() => {}))
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('Cancel your registration?')
|
||||
expect(document.body.textContent).toContain(
|
||||
'Your account will be deleted. You can register again with the same or a different email.',
|
||||
)
|
||||
|
||||
const cancelButton = findButtonElement('Keep my account')
|
||||
const confirmButton = findButtonElement('Cancel registration')
|
||||
expect(cancelButton).toBeTruthy()
|
||||
expect(confirmButton).toBeTruthy()
|
||||
|
||||
expect(document.querySelector('[role="alertdialog"]')).toBeTruthy()
|
||||
expect(cancelButton).toBe(document.activeElement)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancelling — the action is in flight and the dialog cannot be dismissed', () => {
|
||||
it('disables both buttons, shows the pending label and sets aria-busy', async () => {
|
||||
let resolveAction: (() => void) | null = null
|
||||
const action = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveAction = resolve
|
||||
}),
|
||||
)
|
||||
const wrapper = mountDialog(action)
|
||||
await flushPromises()
|
||||
|
||||
await clickButton('Cancel registration')
|
||||
|
||||
expect(action).toHaveBeenCalledTimes(1)
|
||||
expect(
|
||||
(findButtonElement('Keep my account') as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true)
|
||||
expect(findButtonElement('Cancelling…')).toBeTruthy()
|
||||
expect(
|
||||
(findButtonElement('Cancelling…') as HTMLButtonElement).disabled,
|
||||
).toBe(true)
|
||||
expect(document.querySelector('[aria-busy="true"]')).toBeTruthy()
|
||||
|
||||
resolveAction?.()
|
||||
await flushPromises()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keep my account — the dialog is dismissed without deleting anything', () => {
|
||||
it('emits close(false) without ever calling the action', async () => {
|
||||
const action = vi.fn(() => Promise.resolve())
|
||||
const wrapper = mountDialog(action)
|
||||
await flushPromises()
|
||||
|
||||
await clickButton('Keep my account')
|
||||
|
||||
expect(action).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('close')).toEqual([[false]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancelled — resolving the action closes the dialog', () => {
|
||||
it('emits close(true) once the action resolves', async () => {
|
||||
const action = vi.fn(() => Promise.resolve())
|
||||
const wrapper = mountDialog(action)
|
||||
await flushPromises()
|
||||
|
||||
await clickButton('Cancel registration')
|
||||
|
||||
expect(wrapper.emitted('close')).toEqual([[true]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dismissing (Esc or the overlay) defaults to the cancel outcome', () => {
|
||||
it('emits close(false) without calling the action', async () => {
|
||||
const action = vi.fn(() => Promise.resolve())
|
||||
const wrapper = mountDialog(action)
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.setProps({ open: false })
|
||||
|
||||
expect(action).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('close')).toEqual([[false]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dismissResolvesTo="confirm" makes Esc/overlay resolve as confirmed', () => {
|
||||
it('emits close(true) on dismiss without calling the action', async () => {
|
||||
const action = vi.fn(() => Promise.resolve())
|
||||
const wrapper = mount(ConfirmDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
title: 'Leave without saving?',
|
||||
description:
|
||||
'Your changes to "How we price a bathroom renovation" haven’t been saved.',
|
||||
confirmLabel: 'Keep editing',
|
||||
cancelLabel: 'Discard changes',
|
||||
pendingLabel: 'Keep editing',
|
||||
tone: 'neutral',
|
||||
action,
|
||||
dismissResolvesTo: 'confirm',
|
||||
},
|
||||
global: {
|
||||
plugins: [uiPlugin],
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.setProps({ open: false })
|
||||
|
||||
expect(action).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('close')).toEqual([[true]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancel failed — the dialog stays open and shows the failure inline', () => {
|
||||
it('re-enables both buttons and shows the rejection message inline', async () => {
|
||||
const action = vi.fn(() =>
|
||||
Promise.reject(new Error("Couldn't cancel registration.")),
|
||||
)
|
||||
const wrapper = mountDialog(action)
|
||||
await flushPromises()
|
||||
|
||||
await clickButton('Cancel registration')
|
||||
|
||||
expect(wrapper.emitted('close')).toBeFalsy()
|
||||
expect(document.body.textContent).toContain(
|
||||
"Couldn't cancel registration.",
|
||||
)
|
||||
expect(
|
||||
(findButtonElement('Keep my account') as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false)
|
||||
expect(
|
||||
(findButtonElement('Cancel registration') as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterEach, 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 { useApi } from '@/composables/useApi'
|
||||
import DashboardShell from '../DashboardShell.vue'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return {
|
||||
...actual,
|
||||
useApi: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
async function mountShell(path: string, username = 'marta') {
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username }),
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
await router.push(path)
|
||||
const wrapper = mount(DashboardShell, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [router, uiPlugin],
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await router.push('/login')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('DashboardShell renders the three navigation destinations', () => {
|
||||
it('lists Home, Articles and Integration, with only Articles carrying aria-current', async () => {
|
||||
const wrapper = await mountShell('/article')
|
||||
|
||||
const home = wrapper.find('a[href="/home"]')
|
||||
const articles = wrapper.find('a[href="/article"]')
|
||||
const integration = wrapper.find('a[href="/integration"]')
|
||||
|
||||
expect(home.exists()).toBe(true)
|
||||
expect(articles.exists()).toBe(true)
|
||||
expect(integration.exists()).toBe(true)
|
||||
|
||||
expect(articles.attributes('aria-current')).toBe('page')
|
||||
expect(home.attributes('aria-current')).toBeUndefined()
|
||||
expect(integration.attributes('aria-current')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DashboardShell shows the offline banner when the browser reports offline', () => {
|
||||
it('shows the banner while offline and hides it once back online', async () => {
|
||||
const onLineSpy = vi
|
||||
.spyOn(navigator, 'onLine', 'get')
|
||||
.mockReturnValue(false)
|
||||
|
||||
const wrapper = await mountShell('/article')
|
||||
|
||||
expect(wrapper.text()).toContain("You're offline")
|
||||
|
||||
onLineSpy.mockReturnValue(true)
|
||||
window.dispatchEvent(new Event('online'))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain("You're offline")
|
||||
|
||||
onLineSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('The sidebar header renders AppLogo and the "RenewCMS" wordmark', () => {
|
||||
it('renders the logo before the wordmark text, neither as a link', async () => {
|
||||
const wrapper = await mountShell('/home')
|
||||
|
||||
const header = wrapper.find('[data-slot="header"]')
|
||||
expect(
|
||||
header.find('svg[role="img"][aria-label="RenewCMS"]').exists(),
|
||||
).toBe(true)
|
||||
expect(header.text()).toContain('RenewCMS')
|
||||
expect(header.find('a').exists()).toBe(false)
|
||||
|
||||
const headerHtml = header.html()
|
||||
expect(headerHtml.indexOf('<svg')).toBeLessThan(
|
||||
headerHtml.indexOf('RenewCMS</span>'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The sidebar collapse trigger toggles the rail and exposes aria-expanded', () => {
|
||||
it('flips the rail and aria-expanded on repeated activation', async () => {
|
||||
await mountShell('/home')
|
||||
|
||||
const collapseButton = () =>
|
||||
document.body.querySelector(
|
||||
'[data-testid="sidebar-collapse"]',
|
||||
) as HTMLButtonElement
|
||||
|
||||
expect(collapseButton().getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
collapseButton().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(collapseButton().getAttribute('aria-expanded')).toBe('false')
|
||||
|
||||
collapseButton().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(collapseButton().getAttribute('aria-expanded')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A successful logout clears the session and navigates to /login', () => {
|
||||
it('calls POST /v1/auth/logout, clears the session and navigates to /login', async () => {
|
||||
await mountShell('/home')
|
||||
|
||||
const logOutItem = () =>
|
||||
Array.from(
|
||||
document.body.querySelectorAll('[role="menuitem"]'),
|
||||
).find((el) => el.textContent?.trim() === 'Log out') as HTMLElement
|
||||
|
||||
const trigger = document.body.querySelector(
|
||||
'button[aria-haspopup]',
|
||||
) as HTMLButtonElement
|
||||
trigger.click()
|
||||
await flushPromises()
|
||||
|
||||
logOutItem().click()
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(useApi)().request).toHaveBeenCalledWith(
|
||||
'/v1/auth/logout',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Collapsed sidebar hides the wordmark and username text but keeps an accessible name', () => {
|
||||
it('hides visible text but keeps accessible names for the logo and the user menu', async () => {
|
||||
await mountShell('/home')
|
||||
|
||||
const collapseButton = document.body.querySelector(
|
||||
'[data-testid="sidebar-collapse"]',
|
||||
) as HTMLButtonElement
|
||||
collapseButton.click()
|
||||
await flushPromises()
|
||||
|
||||
const header = document.body.querySelector(
|
||||
'[data-slot="header"]',
|
||||
) as HTMLElement
|
||||
expect(
|
||||
header.querySelector('svg[role="img"][aria-label="RenewCMS"]'),
|
||||
).toBeTruthy()
|
||||
expect(header.textContent).not.toContain('RenewCMS')
|
||||
expect(header.querySelector('span[aria-hidden="true"]')).toBeNull()
|
||||
|
||||
const footer = document.body.querySelector(
|
||||
'[data-slot="footer"]',
|
||||
) as HTMLElement
|
||||
expect(footer.textContent).not.toContain('marta')
|
||||
|
||||
const userMenuTrigger = footer.querySelector(
|
||||
'button[aria-haspopup]',
|
||||
) as HTMLButtonElement
|
||||
expect(userMenuTrigger.getAttribute('aria-label')).toBe(
|
||||
'marta — account menu',
|
||||
)
|
||||
expect(
|
||||
footer.querySelector('[data-slot="fallback"]')?.textContent,
|
||||
).toBe('MA')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import ErrorView from '../ErrorView.vue'
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: { template: '<div />' } },
|
||||
{ path: '/home', component: { template: '<div />' } },
|
||||
{ path: '/article', component: { template: '<div />' } },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function mountNotFound(breadcrumb?: string) {
|
||||
const router = makeRouter()
|
||||
await router.push('/')
|
||||
return mount(ErrorView, {
|
||||
props: breadcrumb
|
||||
? { kind: 'not-found', breadcrumb }
|
||||
: { kind: 'not-found' },
|
||||
global: {
|
||||
plugins: [router],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('ErrorView not-found kind', () => {
|
||||
it('renders the not-found copy with a breadcrumb, primary and secondary actions', async () => {
|
||||
const wrapper = await mountNotFound('Not found')
|
||||
|
||||
expect(wrapper.find('nav[aria-label="Breadcrumb"]').text()).toBe(
|
||||
'Not found',
|
||||
)
|
||||
expect(wrapper.find('h1').text()).toBe('Page not found')
|
||||
expect(wrapper.text()).toContain(
|
||||
'The link may be mistyped, or the page may have moved.',
|
||||
)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
expect(buttons.some((button) => button.text() === 'Go to Home')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(buttons.some((button) => button.text() === 'All articles')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('routes the primary action to /home and the secondary action to /article', async () => {
|
||||
const wrapper = await mountNotFound()
|
||||
const router = wrapper.vm.$router
|
||||
|
||||
const primary = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'Go to Home')
|
||||
await primary?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/home')
|
||||
|
||||
const secondary = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'All articles')
|
||||
await secondary?.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/article')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,361 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import NewArticleModal from '../NewArticleModal.vue'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return {
|
||||
...actual,
|
||||
useApi: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const TITLE = 'Choosing tiles for a small bathroom'
|
||||
const BODY = 'Small rooms show every grout line, so we start with the floor.'
|
||||
|
||||
function articleFixture(): Article {
|
||||
return {
|
||||
id: 42,
|
||||
title: TITLE,
|
||||
body: BODY,
|
||||
images: [],
|
||||
is_online: false,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
const Host = defineComponent({
|
||||
components: { NewArticleModal },
|
||||
emits: ['created'],
|
||||
setup(_props, { emit }) {
|
||||
const open = ref(true)
|
||||
return { open, onCreated: (article: Article) => emit('created', article) }
|
||||
},
|
||||
template: `<NewArticleModal v-model:open="open" @created="onCreated" />`,
|
||||
})
|
||||
|
||||
function findButton(text: string) {
|
||||
return Array.from(document.body.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === text,
|
||||
)
|
||||
}
|
||||
|
||||
async function clickButton(text: string) {
|
||||
findButton(text)?.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
let requestMock: ReturnType<typeof vi.fn>
|
||||
|
||||
function mountModal() {
|
||||
requestMock = vi.fn()
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn(),
|
||||
request: requestMock,
|
||||
})
|
||||
return mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
}
|
||||
|
||||
function titleInput() {
|
||||
return document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="e.g. Choosing tiles for a small bathroom"]',
|
||||
)!
|
||||
}
|
||||
|
||||
function bodyTextarea() {
|
||||
return document.body.querySelector<HTMLTextAreaElement>(
|
||||
'textarea[placeholder="Start writing. You\'ll continue in the editor."]',
|
||||
)!
|
||||
}
|
||||
|
||||
function dispatch(element: HTMLElement, type: string) {
|
||||
element.dispatchEvent(new Event(type, { bubbles: true }))
|
||||
}
|
||||
|
||||
async function fillTitle(value: string) {
|
||||
const input = titleInput()
|
||||
input.value = value
|
||||
dispatch(input, 'input')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function blurTitle() {
|
||||
dispatch(titleInput(), 'blur')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function fillBody(value: string) {
|
||||
const textarea = bodyTextarea()
|
||||
textarea.value = value
|
||||
dispatch(textarea, 'input')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function blurBody() {
|
||||
dispatch(bodyTextarea(), 'blur')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function fillValidForm() {
|
||||
await fillTitle(TITLE)
|
||||
await fillBody(BODY)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('Opening the modal shows the Default state', () => {
|
||||
it('renders empty, focused fields with the Default copy', async () => {
|
||||
mountModal()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('New article')
|
||||
expect(document.body.textContent).toContain(
|
||||
"It's saved as a draft. Titles can't be changed later, so check it now.",
|
||||
)
|
||||
|
||||
expect(titleInput().value).toBe('')
|
||||
expect(document.activeElement).toBe(titleInput())
|
||||
expect(document.body.textContent).toContain('0 / 200')
|
||||
expect(document.body.textContent).toContain('3 to 200 characters.')
|
||||
|
||||
expect(bodyTextarea().value).toBe('')
|
||||
expect(document.body.textContent).toContain(
|
||||
'You can format it and add images in the editor.',
|
||||
)
|
||||
|
||||
expect(findButton('Cancel')).toBeTruthy()
|
||||
expect(findButton('Create draft')).toBeTruthy()
|
||||
expect(requestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Validation — a title under 3 characters on blur', () => {
|
||||
it('shows the error in place of help and the counter in its error style', async () => {
|
||||
mountModal()
|
||||
await flushPromises()
|
||||
|
||||
await fillTitle('Ti')
|
||||
await blurTitle()
|
||||
|
||||
expect(document.body.textContent).toContain('Use at least 3 characters.')
|
||||
expect(document.body.textContent).not.toContain('3 to 200 characters.')
|
||||
expect(document.body.textContent).toContain('2 / 200')
|
||||
expect(requestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Validation — an empty first paragraph on blur', () => {
|
||||
it('shows the error once the field is left empty and blurred', async () => {
|
||||
mountModal()
|
||||
await flushPromises()
|
||||
|
||||
await fillTitle(TITLE)
|
||||
await blurTitle()
|
||||
await blurBody()
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
'Add a first paragraph. It can be a single sentence.',
|
||||
)
|
||||
expect(requestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Validation — submitting with both fields invalid shows both errors and sends nothing', () => {
|
||||
it('shows both errors and never sends the request', async () => {
|
||||
mountModal()
|
||||
await flushPromises()
|
||||
|
||||
await fillTitle('Ti')
|
||||
await clickButton('Create draft')
|
||||
|
||||
expect(document.body.textContent).toContain('Use at least 3 characters.')
|
||||
expect(document.body.textContent).toContain(
|
||||
'Add a first paragraph. It can be a single sentence.',
|
||||
)
|
||||
expect(requestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Submitting disables the form and cannot be dismissed', () => {
|
||||
it('sends the request, disables the form and blocks Esc/backdrop dismissal', async () => {
|
||||
let resolveRequest: ((article: Article) => void) | null = null
|
||||
requestMock = vi.fn(
|
||||
() =>
|
||||
new Promise<Article>((resolve) => {
|
||||
resolveRequest = resolve
|
||||
}),
|
||||
)
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
|
||||
const wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await fillValidForm()
|
||||
await clickButton('Create draft')
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith(
|
||||
'/v1/article/',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title: TITLE, body: BODY }),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(titleInput().disabled).toBe(true)
|
||||
expect(bodyTextarea().disabled).toBe(true)
|
||||
expect((findButton('Cancel') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(findButton('Creating…')).toBeTruthy()
|
||||
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
keyCode: 27,
|
||||
which: 27,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
|
||||
|
||||
resolveRequest?.(articleFixture())
|
||||
await flushPromises()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A successful create closes the modal and hands off to the editor with a toast', () => {
|
||||
it('closes the modal and emits created with the response body', async () => {
|
||||
requestMock = vi.fn().mockResolvedValue(articleFixture())
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
|
||||
const wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await fillValidForm()
|
||||
await clickButton('Create draft')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('created')).toEqual([[articleFixture()]])
|
||||
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Title rejected — a 400 that got past this form's own check", () => {
|
||||
it('keeps the values, shows the title error and the form alert', async () => {
|
||||
requestMock = vi.fn().mockRejectedValue(new ApiError(400))
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
|
||||
const wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await fillValidForm()
|
||||
await clickButton('Create draft')
|
||||
await flushPromises()
|
||||
|
||||
expect(titleInput().value).toBe(TITLE)
|
||||
expect(bodyTextarea().value).toBe(BODY)
|
||||
expect(document.body.textContent).toContain('Check this title and try again.')
|
||||
expect(document.body.textContent).toContain(
|
||||
"Something in the form isn't valid. Check the highlighted fields.",
|
||||
)
|
||||
expect(document.body.textContent).not.toContain(
|
||||
'Add a first paragraph. It can be a single sentence.',
|
||||
)
|
||||
expect(titleInput().disabled).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error — a 500 or a network failure keeps the text and offers to retry', () => {
|
||||
it.each([
|
||||
{ name: 'a 500 response', error: () => new ApiError(500) },
|
||||
{ name: 'a network failure', error: () => new NetworkError() },
|
||||
])('keeps the values and shows the generic alert for $name', async ({ error }) => {
|
||||
requestMock = vi.fn().mockRejectedValue(error())
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
|
||||
const wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await fillValidForm()
|
||||
await clickButton('Create draft')
|
||||
await flushPromises()
|
||||
|
||||
expect(titleInput().value).toBe(TITLE)
|
||||
expect(bodyTextarea().value).toBe(BODY)
|
||||
expect(document.body.textContent).toContain(
|
||||
"Couldn't create the article. Your text is still here. Try again in a moment.",
|
||||
)
|
||||
expect(document.body.textContent).not.toContain('Check this title and try again.')
|
||||
expect(document.body.textContent).not.toContain(
|
||||
'Add a first paragraph. It can be a single sentence.',
|
||||
)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Retrying after an Error succeeds', () => {
|
||||
it('sends the same body again and hands off to the editor on success', async () => {
|
||||
requestMock = vi.fn().mockRejectedValueOnce(new ApiError(500))
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
|
||||
const wrapper = mount(Host, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await fillValidForm()
|
||||
await clickButton('Create draft')
|
||||
await flushPromises()
|
||||
|
||||
requestMock.mockResolvedValueOnce(articleFixture())
|
||||
await clickButton('Create draft')
|
||||
await flushPromises()
|
||||
|
||||
expect(requestMock).toHaveBeenCalledTimes(2)
|
||||
expect(requestMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/v1/article/',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({ title: TITLE, body: BODY }),
|
||||
}),
|
||||
)
|
||||
expect(wrapper.emitted('created')).toEqual([[articleFixture()]])
|
||||
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cancel discards the fields with no confirmation', () => {
|
||||
it('closes the modal immediately without sending a request', async () => {
|
||||
mountModal()
|
||||
await flushPromises()
|
||||
|
||||
await fillValidForm()
|
||||
await clickButton('Cancel')
|
||||
|
||||
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
|
||||
expect(requestMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import UserMenu from '../UserMenu.vue'
|
||||
import { useColorMode } from '@/composables/useColorMode'
|
||||
|
||||
function mountMenu(props: { username: string; collapsed: boolean }) {
|
||||
return mount(UserMenu, {
|
||||
attachTo: document.body,
|
||||
props,
|
||||
global: {
|
||||
plugins: [uiPlugin],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function findTriggerButton() {
|
||||
return document.body.querySelector('button')
|
||||
}
|
||||
|
||||
async function openMenu() {
|
||||
findTriggerButton()?.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function findMenuItemsByRole(role: string) {
|
||||
return Array.from(document.body.querySelectorAll(`[role="${role}"]`))
|
||||
}
|
||||
|
||||
function findCheckboxItem(label: string) {
|
||||
return findMenuItemsByRole('menuitemcheckbox').find(
|
||||
(el) => el.textContent?.trim() === label,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.documentElement.classList.remove('dark', 'light')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe("UserMenu's dropdown lists the account label, color-mode checkboxes, then Log out", () => {
|
||||
it('shows avatar initials MA, the username, and the three ordered groups', async () => {
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||
|
||||
const avatar = document.body.querySelector('[data-slot="fallback"]')
|
||||
expect(avatar?.textContent).toBe('MA')
|
||||
expect(avatar?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(wrapper.text()).toContain('marta')
|
||||
|
||||
await openMenu()
|
||||
|
||||
const groups = findMenuItemsByRole('group')
|
||||
expect(groups.length).toBe(3)
|
||||
|
||||
expect(groups[0].textContent).toContain('marta')
|
||||
|
||||
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map(
|
||||
(el) => el.textContent?.trim(),
|
||||
)
|
||||
expect(checkboxLabels).toEqual(['System', 'Light', 'Dark'])
|
||||
|
||||
expect(groups[2].textContent?.trim()).toBe('Log out')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('derives initials from the first letter of the first two words for a multi-word username', () => {
|
||||
const wrapper = mountMenu({ username: 'jane doe', collapsed: false })
|
||||
|
||||
const avatar = document.body.querySelector('[data-slot="fallback"]')
|
||||
expect(avatar?.textContent).toBe('JD')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('checks exactly the item matching the current color-mode preference', async () => {
|
||||
const colorMode = useColorMode()
|
||||
colorMode.preference = 'dark'
|
||||
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||
await openMenu()
|
||||
|
||||
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe(
|
||||
'true',
|
||||
)
|
||||
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
)
|
||||
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selecting a color-mode item updates the preference and its checked state', () => {
|
||||
it.each([
|
||||
['system', 'light'],
|
||||
['light', 'dark'],
|
||||
['dark', 'system'],
|
||||
])('moves from %s to %s', async (from, to) => {
|
||||
const colorMode = useColorMode()
|
||||
colorMode.preference = from as 'system' | 'light' | 'dark'
|
||||
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||
await openMenu()
|
||||
|
||||
const label = to.charAt(0).toUpperCase() + to.slice(1)
|
||||
const fromLabel = from.charAt(0).toUpperCase() + from.slice(1)
|
||||
|
||||
findCheckboxItem(label)?.dispatchEvent(
|
||||
new MouseEvent('pointerup', { bubbles: true }),
|
||||
)
|
||||
findCheckboxItem(label)?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true }),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(colorMode.preference).toBe(to)
|
||||
|
||||
await openMenu()
|
||||
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe(
|
||||
'true',
|
||||
)
|
||||
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('A successful logout clears the session and navigates to /login', () => {
|
||||
it('emits logout when the user activates Log out', async () => {
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: false })
|
||||
await openMenu()
|
||||
|
||||
const logOutItem = Array.from(
|
||||
document.body.querySelectorAll('[role="menuitem"]'),
|
||||
).find((el) => el.textContent?.trim() === 'Log out')
|
||||
;(logOutItem as HTMLElement)?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('logout')).toBeTruthy()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Collapsed sidebar hides the wordmark and username text but keeps an accessible name', () => {
|
||||
it('hides the visible username but keeps it in the accessible name, and opens the same dropdown', async () => {
|
||||
const wrapper = mountMenu({ username: 'marta', collapsed: true })
|
||||
|
||||
expect(wrapper.text()).not.toContain('marta')
|
||||
expect(findTriggerButton()?.getAttribute('aria-label')).toBe(
|
||||
'marta — account menu',
|
||||
)
|
||||
expect(
|
||||
document.body.querySelector('[data-slot="fallback"]')?.textContent,
|
||||
).toBe('MA')
|
||||
|
||||
await openMenu()
|
||||
|
||||
const groups = findMenuItemsByRole('group')
|
||||
expect(groups.length).toBe(3)
|
||||
expect(groups[0].textContent).toContain('marta')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect, afterEach, beforeEach } 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 AuthShell from '@/components/AuthShell.vue'
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
import { resetAuthShellNavigation } from '@/composables/useAuthShellNavigation'
|
||||
|
||||
function stubMatchMedia(belowSm: boolean) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: belowSm,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia
|
||||
}
|
||||
|
||||
function mockResponse(status: number, body: unknown = null) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: () => null },
|
||||
json: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
|
||||
async function mountLogin() {
|
||||
setActivePinia(createPinia())
|
||||
global.fetch = (() =>
|
||||
mockResponse(200, { open: false })) as unknown as typeof fetch
|
||||
await router.push('/login')
|
||||
const wrapper = mount(LoginView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
currentWrapper = wrapper
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthShellNavigation()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
currentWrapper?.unmount()
|
||||
currentWrapper = null
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('AppLogo renders the brand mark as inline SVG, and theme-switches', () => {
|
||||
it("renders as UPageCard's first element, inside the card, left-aligned, at 32px, not as an img, alt text, not a link", () => {
|
||||
const wrapper = mount(AuthShell, {
|
||||
props: { title: 'Log in', description: 'Welcome back.' },
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
|
||||
const card = wrapper.find('[data-testid="auth-card"]')
|
||||
const svg = wrapper.find('svg')
|
||||
expect(svg.exists()).toBe(true)
|
||||
expect(svg.attributes('width')).toBe('32')
|
||||
|
||||
expect(card.element.contains(svg.element)).toBe(true)
|
||||
|
||||
const html = wrapper.html()
|
||||
expect(html.indexOf('<svg')).toBeGreaterThan(
|
||||
html.indexOf('data-testid="auth-card"'),
|
||||
)
|
||||
expect(html.indexOf('<svg')).toBeLessThan(html.indexOf('<h1'))
|
||||
|
||||
let centeringAncestor = svg.element.parentElement
|
||||
while (centeringAncestor && centeringAncestor !== card.element) {
|
||||
expect(centeringAncestor.className).not.toMatch(
|
||||
/items-center|justify-center|text-center/,
|
||||
)
|
||||
centeringAncestor = centeringAncestor.parentElement
|
||||
}
|
||||
|
||||
const header = wrapper.find('[data-slot="header"]')
|
||||
expect(header.exists()).toBe(true)
|
||||
expect(header.classes()).toContain('mb-6')
|
||||
expect(header.classes()).not.toContain('mb-4')
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(svg.attributes('role')).toBe('img')
|
||||
expect(svg.attributes('aria-label')).toBe('RenewCMS')
|
||||
|
||||
expect(svg.element.closest('a')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("UPageCard's title is the page h1, and its description uses UPageCard's own styling", () => {
|
||||
it('renders variant outline, the title through the title slot as h1, and the description through the prop', () => {
|
||||
const wrapper = mount(AuthShell, {
|
||||
props: {
|
||||
title: 'Log in',
|
||||
description: 'Welcome back. Log in to manage your articles.',
|
||||
},
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
|
||||
const card = wrapper.find('[data-testid="auth-card"]')
|
||||
expect(card.exists()).toBe(true)
|
||||
|
||||
const heading = wrapper.find('h1')
|
||||
expect(heading.text()).toBe('Log in')
|
||||
expect(heading.attributes('tabindex')).toBe('-1')
|
||||
|
||||
expect(wrapper.text()).toContain(
|
||||
'Welcome back. Log in to manage your articles.',
|
||||
)
|
||||
expect(wrapper.findAll('p').length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The page sits on the muted background from sm up, and the card keeps its border', () => {
|
||||
it('uses the muted background token, keeps the card border, capped width and 12vh top offset', () => {
|
||||
stubMatchMedia(false)
|
||||
const wrapper = mount(AuthShell, {
|
||||
props: { title: 'Log in', description: 'Welcome back.' },
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
|
||||
const main = wrapper.find('main')
|
||||
expect(main.classes()).toContain('bg-muted')
|
||||
expect(main.classes()).not.toContain('bg-default')
|
||||
expect(main.classes()).toContain('pt-[12vh]')
|
||||
|
||||
const card = wrapper.find('[data-testid="auth-card"]')
|
||||
expect(card.classes()).toContain('max-w-[400px]')
|
||||
expect(card.classes()).toContain('border')
|
||||
|
||||
const container = wrapper.find('[data-slot="container"]')
|
||||
expect(container.classes()).not.toContain('p-5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Below sm, AuthShell drops the muted background, the card border and the card padding', () => {
|
||||
it('uses the default background, an unbordered full-width card with the board padding split, and xl controls', async () => {
|
||||
stubMatchMedia(true)
|
||||
const wrapper = mount(AuthShell, {
|
||||
props: { title: 'Log in', description: 'Welcome back.' },
|
||||
slots: {
|
||||
default: `<template #default="{ controlSize }"><button :data-size="controlSize">Submit</button></template>`,
|
||||
},
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const main = wrapper.find('main')
|
||||
expect(main.classes()).toContain('bg-default')
|
||||
expect(main.classes()).not.toContain('bg-muted')
|
||||
expect(main.classes()).not.toContain('pt-[12vh]')
|
||||
|
||||
const card = wrapper.find('[data-testid="auth-card"]')
|
||||
expect(card.classes()).not.toContain('max-w-[400px]')
|
||||
expect(card.classes()).toContain('ring-0')
|
||||
|
||||
const button = wrapper.find('button')
|
||||
expect(button.attributes('data-size')).toBe('xl')
|
||||
|
||||
const container = wrapper.find('[data-slot="container"]')
|
||||
expect(container.classes()).toContain('pt-2')
|
||||
expect(container.classes()).toContain('px-5')
|
||||
expect(container.classes()).toContain('pb-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The login form is a UForm', () => {
|
||||
it('wraps the Username field, the Password field and the submit button in a UForm, not a plain form', async () => {
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const form = wrapper.find('form')
|
||||
expect(form.exists()).toBe(true)
|
||||
expect(form.attributes('id')).toMatch(/^v-/)
|
||||
expect(form.find('input[placeholder="Your username"]').exists()).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
form.find('input[autocomplete="current-password"]').exists(),
|
||||
).toBe(true)
|
||||
expect(form.find('button[type="submit"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('"Forgot password?" renders in the Password field\'s hint slot', () => {
|
||||
it('renders the link through the hint slot, and the label reads only Password', async () => {
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const passwordInput = wrapper.find(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
const fieldRoot = passwordInput.element.closest(
|
||||
'[data-slot="root"][data-orientation]',
|
||||
)
|
||||
expect(fieldRoot).toBeTruthy()
|
||||
|
||||
const hint = fieldRoot!.querySelector('[data-slot="hint"]')
|
||||
expect(hint).toBeTruthy()
|
||||
expect(hint!.textContent?.trim()).toBe('Forgot password?')
|
||||
expect(
|
||||
hint!.querySelector('a[href="/register/reset/request"]'),
|
||||
).toBeTruthy()
|
||||
|
||||
const label = fieldRoot!.querySelector('[data-slot="label"]')
|
||||
expect(label!.textContent?.trim()).toBe('Password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Inputs are neutral by default and turn to the error edge only when their field has an error', () => {
|
||||
it('starts neutral, then turns to the error edge with the field messages on invalid submit', async () => {
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
||||
const passwordInput = wrapper.find(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
expect(usernameInput.classes()).toContain('ring-accented')
|
||||
expect(usernameInput.classes()).not.toContain('ring-error')
|
||||
expect(passwordInput.classes()).toContain('ring-accented')
|
||||
expect(passwordInput.classes()).not.toContain('ring-error')
|
||||
|
||||
await usernameInput.setValue('ma')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const usernameAfter = wrapper.find('input[placeholder="Your username"]')
|
||||
const passwordAfter = wrapper.find(
|
||||
'input[autocomplete="current-password"]',
|
||||
)
|
||||
expect(usernameAfter.classes()).toContain('ring-error')
|
||||
expect(usernameAfter.classes()).not.toContain('ring-accented')
|
||||
expect(passwordAfter.classes()).toContain('ring-error')
|
||||
expect(passwordAfter.classes()).not.toContain('ring-accented')
|
||||
|
||||
expect(wrapper.text()).toContain('Use at least 3 characters.')
|
||||
expect(wrapper.text()).toContain('Enter your password.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, afterEach, beforeEach } 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 AuthShell from '@/components/AuthShell.vue'
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
import { resetAuthShellNavigation } from '@/composables/useAuthShellNavigation'
|
||||
|
||||
function stubMatchMedia(belowSm: boolean) {
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: belowSm,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
})) as unknown as typeof window.matchMedia
|
||||
}
|
||||
|
||||
function mockResponse(status: number, body: unknown = null) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: () => null },
|
||||
json: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
|
||||
async function mountLogin(path = '/login') {
|
||||
setActivePinia(createPinia())
|
||||
global.fetch = (() =>
|
||||
mockResponse(200, { open: false })) as unknown as typeof fetch
|
||||
await router.push(path)
|
||||
const wrapper = mount(LoginView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
currentWrapper = wrapper
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthShellNavigation()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
currentWrapper?.unmount()
|
||||
currentWrapper = null
|
||||
await router.push('/login')
|
||||
resetAuthShellNavigation()
|
||||
})
|
||||
|
||||
describe('"Forgot password?" is underlined and muted', () => {
|
||||
it('uses the muted toned color, underline and 3px offset, no other emphasis', async () => {
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const link = wrapper.find('a[href="/register/reset/request"]')
|
||||
expect(link.classes()).toContain('text-toned')
|
||||
expect(link.classes()).toContain('underline')
|
||||
expect(link.classes()).toContain('underline-offset-[3px]')
|
||||
expect(link.classes()).not.toContain('text-primary')
|
||||
expect(link.classes()).not.toContain('font-bold')
|
||||
expect(link.classes()).not.toContain('font-semibold')
|
||||
})
|
||||
})
|
||||
|
||||
describe('A field error shows the circle-alert icon with no extra focus halo', () => {
|
||||
it('precedes both field errors with the error-colored icon, and keeps a single error ring when focused', async () => {
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
await wrapper.find('input[placeholder="Your username"]').setValue('ma')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const fieldRoots = wrapper.findAll(
|
||||
'[data-slot="root"][data-orientation]',
|
||||
)
|
||||
const usernameError = fieldRoots[0]!.find('[data-slot="error"]')
|
||||
const passwordError = fieldRoots[1]!.find('[data-slot="error"]')
|
||||
|
||||
for (const error of [usernameError, passwordError]) {
|
||||
const icon = error.find('svg')
|
||||
expect(icon.exists()).toBe(true)
|
||||
expect(icon.html()).toContain('M12 8v4m0 4h.01')
|
||||
expect(icon.classes()).toContain('text-error')
|
||||
}
|
||||
|
||||
const usernameInput = wrapper.find('input[placeholder="Your username"]')
|
||||
await usernameInput.trigger('focus')
|
||||
|
||||
expect(usernameInput.classes()).toContain('ring-error')
|
||||
expect(usernameInput.classes()).toContain('focus-visible:ring-error')
|
||||
expect(usernameInput.classes()).not.toContain('ring-primary')
|
||||
expect(usernameInput.classes()).not.toContain(
|
||||
'focus-visible:ring-primary',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The theme select is right-aligned in the footer with no chevron', () => {
|
||||
it('renders as the last child, showing only the mode icon and label', async () => {
|
||||
stubMatchMedia(false)
|
||||
const wrapper = await mountLogin()
|
||||
|
||||
const footer = wrapper.find('footer')
|
||||
const select = footer.find('button[aria-haspopup="listbox"]')
|
||||
expect(select.exists()).toBe(true)
|
||||
|
||||
const children = Array.from(footer.element.children)
|
||||
expect(children[children.length - 1]).toBe(select.element)
|
||||
|
||||
expect(select.text()).toContain('System')
|
||||
|
||||
const trailing = select.find('[data-slot="trailing"]')
|
||||
expect(trailing.classes()).toContain('hidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The alert renders inside the card, under the description', () => {
|
||||
it.each([
|
||||
{
|
||||
path: '/login?redirect=/article/12/edit&reason=expired',
|
||||
state: 'Session expired',
|
||||
text: 'Your session expired',
|
||||
},
|
||||
{
|
||||
path: '/login?reason=password-changed',
|
||||
state: 'Password changed',
|
||||
text: 'Password changed',
|
||||
},
|
||||
])(
|
||||
'places the $state alert inside the card, after the title/description, before Username',
|
||||
async ({ path, text }) => {
|
||||
const wrapper = await mountLogin(path)
|
||||
|
||||
const card = wrapper.find('[data-testid="auth-card"]')
|
||||
const alerts = wrapper.findAll('[role="alert"], [role="status"]')
|
||||
expect(alerts.length).toBe(1)
|
||||
const alert = alerts[0]!
|
||||
expect(card.element.contains(alert.element)).toBe(true)
|
||||
expect(alert.text()).toContain(text)
|
||||
|
||||
const rootsInCard = card.findAll(
|
||||
'[data-slot="root"][data-orientation]',
|
||||
)
|
||||
expect(
|
||||
rootsInCard.some((root) =>
|
||||
root.classes().includes('bg-primary'),
|
||||
),
|
||||
).toBe(false)
|
||||
|
||||
const usernameInput = wrapper.find(
|
||||
'input[placeholder="Your username"]',
|
||||
)
|
||||
const html = card.html()
|
||||
expect(html.indexOf('Welcome back')).toBeGreaterThan(-1)
|
||||
expect(html.indexOf('Welcome back')).toBeLessThan(
|
||||
html.indexOf(text),
|
||||
)
|
||||
expect(html.indexOf(text)).toBeLessThan(html.indexOf('Username'))
|
||||
expect(card.element.contains(usernameInput.element)).toBe(true)
|
||||
|
||||
expect(
|
||||
alert
|
||||
.classes()
|
||||
.some((c) => /^bg-\(--rc-(info|success)-soft\)$/.test(c)),
|
||||
).toBe(true)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('The h1 is not focused on first load', () => {
|
||||
it('leaves focus on the document body after AuthShell mounts', () => {
|
||||
const wrapper = mount(AuthShell, {
|
||||
props: { title: 'Log in', description: 'Welcome back.' },
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
|
||||
const heading = wrapper.find('h1')
|
||||
expect(heading.element).not.toBe(document.activeElement)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Focus moves to the h1 only on an in-app navigation between public screens', () => {
|
||||
it('does not focus the first AuthShell mount, but focuses the second', () => {
|
||||
const first = mount(AuthShell, {
|
||||
props: { title: 'Log in', description: 'Welcome back.' },
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
expect(first.find('h1').element).not.toBe(document.activeElement)
|
||||
first.unmount()
|
||||
|
||||
const second = mount(AuthShell, {
|
||||
props: { title: 'Forgot password?', description: 'Reset it.' },
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
|
||||
expect(second.find('h1').element).toBe(document.activeElement)
|
||||
expect(second.find('h1').classes()).toContain('outline-none')
|
||||
second.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
|
||||
|
||||
function mockResponse(
|
||||
status: number,
|
||||
body: unknown = null,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: (name: string) => headers[name] ?? null },
|
||||
json: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function setOnline(value: boolean) {
|
||||
Object.defineProperty(navigator, 'onLine', { configurable: true, value })
|
||||
}
|
||||
|
||||
async function expectApiError(
|
||||
call: Promise<unknown>,
|
||||
matcher: Record<string, unknown>,
|
||||
) {
|
||||
await expect(call).rejects.toBeInstanceOf(ApiError)
|
||||
await expect(call).rejects.toMatchObject(matcher)
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
fetchMock.mockReset()
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
setOnline(true)
|
||||
isOffline.value = false
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('useApi global 401/403/429/offline handling', () => {
|
||||
it('A successful authenticated request returns the parsed body', async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse(200, { id: 5 }))
|
||||
|
||||
const result = await useApi().request('/v1/article/5')
|
||||
|
||||
expect(result).toEqual({ id: 5 })
|
||||
expect(isOffline.value).toBe(false)
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/v1/article/5',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('A 401 received mid-session redirects to login with the expired reason and clears the session', async () => {
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true }
|
||||
session.status = 'ready'
|
||||
await router.push('/article/12/edit')
|
||||
fetchMock.mockResolvedValue(mockResponse(401))
|
||||
|
||||
const call = useApi().request('/v1/article/12')
|
||||
|
||||
await expectApiError(call, { status: 401 })
|
||||
|
||||
expect(session.session).toBeNull()
|
||||
expect(session.status).toBe('ready')
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
'/login?redirect=/article/12/edit&reason=expired',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('The boot call to getMe does not trigger the global 401 redirect', async () => {
|
||||
const session = useSessionStore()
|
||||
session.session = null
|
||||
session.status = 'checking'
|
||||
await router.push('/login')
|
||||
const pathBefore = router.currentRoute.value.fullPath
|
||||
fetchMock.mockResolvedValue(mockResponse(401))
|
||||
|
||||
const call = useApi().getMe()
|
||||
|
||||
await expectApiError(call, { status: 401 })
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(pathBefore)
|
||||
})
|
||||
|
||||
it('A 403 while the session is known to be unverified redirects to the pending page', async () => {
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: false }
|
||||
session.status = 'ready'
|
||||
fetchMock.mockResolvedValue(mockResponse(403))
|
||||
|
||||
const call = useApi().request('/v1/article/')
|
||||
|
||||
await expectApiError(call, { status: 403 })
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.path).toBe('/register/pending'),
|
||||
)
|
||||
expect(session.session).toEqual({ verified: false })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'null', session: null },
|
||||
{ name: 'verified true', session: { verified: true } },
|
||||
])(
|
||||
'A 403 that is not the global unverified case passes through untouched ($name)',
|
||||
async ({ session: sessionValue }) => {
|
||||
const session = useSessionStore()
|
||||
session.session = sessionValue
|
||||
session.status = 'ready'
|
||||
await router.push('/login')
|
||||
const pathBefore = router.currentRoute.value.fullPath
|
||||
fetchMock.mockResolvedValue(mockResponse(403))
|
||||
|
||||
const call = useApi().request('/v1/some/endpoint')
|
||||
|
||||
await expectApiError(call, { status: 403 })
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(pathBefore)
|
||||
expect(session.session).toEqual(sessionValue)
|
||||
},
|
||||
)
|
||||
|
||||
it('A 403 with onForbidden passthrough never redirects even when the session is unverified', async () => {
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: false }
|
||||
session.status = 'ready'
|
||||
await router.push('/login')
|
||||
const pathBefore = router.currentRoute.value.fullPath
|
||||
fetchMock.mockResolvedValue(mockResponse(403))
|
||||
|
||||
const call = useApi().request('/v1/auth/register', {
|
||||
method: 'DELETE',
|
||||
onForbidden: 'passthrough',
|
||||
})
|
||||
|
||||
await expectApiError(call, { status: 403 })
|
||||
await settle()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(pathBefore)
|
||||
expect(session.session).toEqual({ verified: false })
|
||||
})
|
||||
|
||||
it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
mockResponse(429, null, { 'Retry-After': '30' }),
|
||||
)
|
||||
|
||||
const call = useApi().request('/v1/auth/login', { method: 'POST' })
|
||||
|
||||
await expectApiError(call, { status: 429, retryAfter: 30 })
|
||||
})
|
||||
|
||||
it('A 429 with no Retry-After header exposes an undefined retryAfter', async () => {
|
||||
fetchMock.mockResolvedValue(mockResponse(429))
|
||||
|
||||
const call = useApi().request('/v1/auth/password-reset', {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
await expectApiError(call, { status: 429, retryAfter: undefined })
|
||||
})
|
||||
|
||||
it('A network failure throws a NetworkError and marks the app offline', async () => {
|
||||
setOnline(true)
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
|
||||
const call = useApi().request('/v1/article/')
|
||||
|
||||
await expect(call).rejects.toBeInstanceOf(NetworkError)
|
||||
await expect(call).rejects.not.toBeInstanceOf(ApiError)
|
||||
expect(isOffline.value).toBe(true)
|
||||
})
|
||||
|
||||
it('A later successful request clears the offline flag', async () => {
|
||||
isOffline.value = true
|
||||
setOnline(true)
|
||||
fetchMock.mockResolvedValue(mockResponse(200, {}))
|
||||
|
||||
await useApi().request('/v1/article/')
|
||||
|
||||
expect(isOffline.value).toBe(false)
|
||||
})
|
||||
|
||||
it("The browser's own online/offline events also drive the shared flag", () => {
|
||||
isOffline.value = false
|
||||
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
expect(isOffline.value).toBe(true)
|
||||
|
||||
window.dispatchEvent(new Event('online'))
|
||||
expect(isOffline.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -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>',
|
||||
)
|
||||
})
|
||||
})
|
||||
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user