feat(SPA): users page and invite modal
This commit is contained in:
1 parent
acdef18b37
commit
6afffc1984
20 files changed
+1880
-55
No files matched your search
@@ -0,0 +1,112 @@
|
||||
# QA: Users screen and InviteUserModal
|
||||
|
||||
## Implementation note for coder (informational, not a QA assertion)
|
||||
|
||||
The invitations API is already built and covered by features/invitations.qa.md: this slice has no
|
||||
Go change, so it has no HTTP-level Go section.
|
||||
|
||||
The shell specs were amended to four destinations (Home, Articles, Users, Integration):
|
||||
nuxt_ui_shell_foundation.feature / .qa.md (case 14) and dashboard_shell_branding.feature. Their
|
||||
Vitest tests must be updated in the same change.
|
||||
|
||||
The Invite sent toast uses `useToast().add({ id: 'invite-sent-{id}', color: 'success', icon:
|
||||
'i-lucide-circle-check', title: 'Invite sent', description: '{email} has 3 days to accept.' })`.
|
||||
The list items are keyed by `kind` and `id`.
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi`'s `request` for `GET /v1/user/` and `POST /v1/user/invite` per case; no real backend.
|
||||
Mount UsersPage inside DashboardShell with a resolved verified session (id 1, username "marta") and
|
||||
a test router at /users. The Default fixture is the Background's 5 items; the invite fixture is
|
||||
id 3, email "lea.martin@renew.example". No test freezes the system date. The specs only assert on
|
||||
the fixture's absolute dates; the sent row's "Today" label is not asserted anywhere (see the Visual
|
||||
part). Run each layout case at a desktop and a below-md width (mock `matchMedia`).
|
||||
|
||||
1. **Default** — assert breadcrumb "Users", title, subtitle, "Invite user", rows in fixture order,
|
||||
"Inès Duarte" with "ines" beside it, the "you" tag only on the "marta" row, badge words and
|
||||
the fixture's absolute dates; assert no accepted row shows an email.
|
||||
2. **Navigation** — assert the sidebar and the drawer list Home, Articles, Users, Integration in
|
||||
that order, and "Users" carries aria-current="page" on /users.
|
||||
3. **Loading** — unresolved `GET`; with fake timers assert nothing is drawn at 149 ms; at 150 ms
|
||||
assert 5 skeleton rows and `aria-busy="true"`; resolve and assert the Default rows.
|
||||
4. **Only you** — a single "marta" item; assert the row, the hint text and "Invite user".
|
||||
5. **Error, Examples-driven** — reject with `ApiError(500)`, then `NetworkError`; assert the alert
|
||||
copy, the "Try again" action, no close button and no rows; click "Try again"; assert `request`
|
||||
is called again, then resolve the Default fixture and assert its rows.
|
||||
6. **Invite: default** — click "Invite user"; assert title, text, an empty focused Email with its
|
||||
placeholder, "Cancel", "Send invite"; below md assert the pinned button is hidden while open.
|
||||
7. **Invite: validation, Examples-driven** — "sam.oliveira@renew" then blur; "not-an-email" then
|
||||
submit; empty then submit; assert the field error, no `request` call, and focus on the field.
|
||||
Assert that fixing the value clears the error.
|
||||
8. **Invite: sending** — valid address, submit; assert one `POST /v1/user/invite` with body
|
||||
`{"email":"lea.martin@renew.example"}`, the field and "Cancel" disabled, "Sending…", and
|
||||
Escape and backdrop click not closing the modal.
|
||||
9. **Invite sent** — resolve 201 with the invite fixture; assert the modal closes, `GET /v1/user/`
|
||||
was not called a second time, the list has 6 rows with the new Pending row first and the
|
||||
others unchanged, the first row has the `--ui-bg-elevated` background class, and the toast
|
||||
was raised with id "invite-sent-3", color success, icon "i-lucide-circle-check", title
|
||||
"Invite sent" and description "lea.martin@renew.example has 3 days to accept." Assert nothing
|
||||
about the "Today" label.
|
||||
10. **Replacement of an expired invite** — invite "p.huber@gmail.com", resolve 201; assert exactly
|
||||
one row for that address, first and Pending.
|
||||
11. **Invite sent into Only you** — assert the new row above "marta" and the hint gone.
|
||||
12. **409, Examples-driven** — reject with `ApiError(409)` carrying each of the two texts; assert
|
||||
the matching field error, the address kept, no alert. Also assert an unknown 409 body falls
|
||||
back to "This address already has an account.".
|
||||
13. **400 got through** — reject with `ApiError(400)`; assert the alert and the field error.
|
||||
14. **Rate limited (429)** — reject with `ApiError(429)` carrying `apperr.TooManyRequestsErrorMessage`
|
||||
as body; assert the alert "Too many invites. Wait a moment, then try again." above the buttons
|
||||
(the raw body is not shown), the address kept, the form re-enabled, no field error and the
|
||||
list unchanged; submit again, resolve 201, and assert the closed modal, the row and the toast.
|
||||
15. **Failed, Examples-driven** — `ApiError(500)`, `ApiError(500)` with the API's own text as body,
|
||||
`NetworkError`; assert the alert "Couldn't send the invite. Try again in a moment." (shown
|
||||
once, not the raw body), the address kept, no field error, the list unchanged.
|
||||
16. **Retry after failure** — from 15, submit again; assert `request` is called a second time with
|
||||
the same body; resolve 201 and assert the closed modal, the row and the toast.
|
||||
17. **Cancel discards** — type, click "Cancel"; assert immediate close, no `request`, an empty
|
||||
field on reopening, and focus back on the opening button.
|
||||
|
||||
## Visual (task visual:check -- users, desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/screens/users.md`, board group "Users" in `docs/design/source/RenewCMS
|
||||
Screens Back-office.dc.html`, exact values in `docs/design/source/AppFrame.dc.html`. Add each
|
||||
state's mock to `web/visual/states.ts` with the board's own sample data. The gate is hard: every
|
||||
capture of every non-skipped state must be within 10 %, for both viewports and both themes.
|
||||
|
||||
- [ ] Default: the 5 board rows (`sam.oliveira@renew.example`, `p.huber@gmail.com`, `ines` with
|
||||
"Inès Duarte", `jonas`, `marta` with "you"), dates 27 Sep 2026, 3 Aug 2026, 18 Jun 2026,
|
||||
2 Apr 2026, 12 Mar 2026; session user `marta`.
|
||||
- [ ] Loading: 5 skeleton rows; the setup must give skeletons time to show (150 ms) before the capture.
|
||||
- [ ] Only you: the single `marta` row plus the hint.
|
||||
- [ ] Error: the alert and "Try again", no table or cards; the header button is still drawn.
|
||||
- [ ] Invite: default: modal over the Default list, empty focused Email; the mobile pinned button hidden.
|
||||
- [ ] Invite: validation: value `sam.oliveira@renew`, error copy.
|
||||
- [ ] Invite: sending: value `lea.martin@renew.example`, disabled field, "Sending…".
|
||||
- [ ] Invite: already a user (409): value `jonas@renew.example`, error "This address already has an account.".
|
||||
- [ ] Invite: failed: value `lea.martin@renew.example`, alert "Couldn't send the invite. Try again in a moment.".
|
||||
- [ ] skip: clock-sensitive. Invite sent: `skip: 'clock-sensitive'` in `web/visual/states.ts`
|
||||
("Today"). It prints SKIP, and is never investigated or date-frozen. Its Vitest coverage is case 9.
|
||||
- [ ] Not drawn, no visual state: the pending-invite 409 variant and the 429 alert. They are
|
||||
covered by Vitest only (cases 12 and 14); do not add them to `web/visual/states.ts`.
|
||||
- [ ] Layout, taken from the board frame (`AppFrame.dc.html`), not `components.md`: desktop header
|
||||
row is title block left and the 32 px "Invite user" button right, aligned to the bottom of
|
||||
the row; on mobile the button is 16 px from left, right and bottom, 48 px high, 18 px icon.
|
||||
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches "Invite user", the modal
|
||||
focus starts on Email, then Cancel, then "Send invite", focus is trapped, Esc closes it in
|
||||
every dismissible state and is blocked while Sending, focus returns to the opening button);
|
||||
hover and active on the buttons; motion (240 ms modal, skeleton pulse 1.6 s, both stopped
|
||||
under reduced motion).
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real round trips against `task dev`** — Vitest only mocks `useApi`. Invite a real address and
|
||||
confirm: the 201 row, the 409 texts for an existing account and a live pending invite, the
|
||||
replacement of an expired invite, the 429 after exceeding `AUTH_RATE_LIMIT_MAX_ATTEMPTS` within
|
||||
`AUTH_RATE_LIMIT_WINDOW_SECONDS`, and the list order after a reload.
|
||||
- **Real invitation email** — needs real or sandboxed SMTP (`SMTP_HOST` and the related env
|
||||
vars): the mail arrives and its link `{PUBLIC_URL}/invite/accept?c=...` carries the code. The
|
||||
link will not resolve in the SPA, because the accept screen is out of scope.
|
||||
- **Toast placement and stacking on a live app** — bottom right on desktop and above the pinned
|
||||
button on mobile.
|
||||
- **"Today" and the elevated row on a live app** — confirm the new row and the "Today" label
|
||||
against the real clock, since visual:check skips this state.
|
||||
Reference in new issue
Block a user