feat: activity screen in the SPA, route /activity
This commit is contained in:
1 parent
e37a46a027
commit
35db442cd3
47 files changed
+7582
-11
No files matched your search
@@ -0,0 +1,257 @@
|
||||
# QA: Activity screen, details slide-over and My activity
|
||||
|
||||
## Implementation note for coder (informational, not a QA assertion)
|
||||
|
||||
The activity API is already built and covered by features/activity-log-backend.qa.md and
|
||||
features/activity-log-producers.qa.md: this slice has no Go change and no HTTP-level Go section.
|
||||
|
||||
The shell specs that list the navigation are amended to seven destinations (Home, Articles, Media,
|
||||
Users, Activity, Integration, Settings): features/dashboard_shell_branding.feature,
|
||||
features/media_browse.feature, features/users_screen.feature and .qa.md,
|
||||
features/settings_page.feature and .qa.md, features/profile_page.feature. Their Vitest tests
|
||||
(DashboardShell.spec.ts, where Settings moves from index 5 to 6) must be updated in the same change.
|
||||
|
||||
Export needs the response body as a file, which `useApi().request` does not return (it calls
|
||||
`response.json()`). Add a raw or blob read to `useApi` that keeps the global 401 handling, builds
|
||||
an object URL and clicks a temporary anchor with `download="activity.csv"`. The toasts use
|
||||
`useToast().add({ color, icon, title })` with the one sentence as the title.
|
||||
|
||||
The page needs a date-range popover and a Custom date pair in the mobile sheet. The clamp ("Show
|
||||
more") is measured from the rendered element, so tests stub `scrollHeight` and `clientHeight`.
|
||||
|
||||
Every new English string needs a French entry in `web/src/i18n/messages.ts`. Sentences are
|
||||
composed by a composable (`useActivitySentence`) that returns segments so the target name can be
|
||||
rendered semibold. The Security sub-line is "{n} flagged" with n = `summary.security`, French
|
||||
"{n} signalés".
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi`'s `request` (and the export transport) per case, matching on method, path and parsed
|
||||
query (`URLSearchParams`); no real backend. Mount ActivityView inside DashboardShell with a
|
||||
resolved verified session (id 2, username "ines", display_name "Inès Duarte", role "owner") and a
|
||||
test router at /activity; the regular-user cases use role "editor" (and one with no role). Run
|
||||
each layout case at a desktop and a below-md width (mock `matchMedia`, see
|
||||
`components/__tests__/viewport.ts`). Timers: use `vi.useFakeTimers({ toFake: ['setTimeout',
|
||||
'clearTimeout'] })` so the 150 ms skeleton delay and the 300 ms search debounce can be advanced
|
||||
while `Date` stays real. No test freezes or asserts the system date.
|
||||
|
||||
Clock-independence rules for fixtures: every event in the Vitest fixtures is dated 3 Oct 2026 or
|
||||
earlier, created at 12:00 to 13:00 UTC so its local day is the same in any time zone from UTC-11
|
||||
to UTC+11; assert day separators with the absolute label "3 Oct 2026" and When cells only against
|
||||
the pattern `^\d{2}:\d{2}$` and a non-empty `title`; never assert "Today", "Yesterday" or a
|
||||
relative "N min ago". UTC lines can be asserted exactly; the local time line only with its
|
||||
"(your time)" suffix.
|
||||
|
||||
Fixtures: `activityItems` (a 15-item list built from the board's events, mapped to the API shape
|
||||
with the action codes of the "Visual" section), `summaryDefault` (total 1284, previous_total
|
||||
1146, active_people {users 7, api_keys 2}, failed 6, security 11, series total [4,6,5,8,7,9,6],
|
||||
active [5,6,6,7,7,8,9], failed [0,1,0,2,1,0,2], security [1,0,2,1,3,2,2]), `summaryZero` (total
|
||||
214, previous_total 223, active_people {4, 1}, failed 0, security 0, series failed and security
|
||||
zeros) and `summaryNone` (all zeros). The list response is `{ items, total, page, size }`.
|
||||
|
||||
1. **Navigation** — sidebar and drawer list Home, Articles, Media, Users, Activity, Integration,
|
||||
Settings in order; "Activity" has the `i-lucide-history` icon, links to /activity, has
|
||||
aria-current="page" on /activity and none elsewhere; breadcrumb and mobile title "Activity".
|
||||
2. **Default (owner)** — assert header texts, enabled "Export CSV", the four strip cards (labels,
|
||||
"1,284", "9", "6", "11", sub-lines "12% vs previous 7 days" with the trending-up icon, "7
|
||||
people, 2 API keys", "0.5% of all actions" with icon `circle-x`, "11 flagged" with icon
|
||||
`shield-alert`), the filter bar (placeholder, "All people", "All categories", Outcome All
|
||||
selected, 7 days selected), no chip, the pager text "Showing 1–25 of 1,284", and that `request`
|
||||
was called exactly twice at mount with the expected queries (`range=7d&page=1&size=25` and
|
||||
`range=7d`).
|
||||
3. **Rows, Examples-driven** — one fixture row per Examples row of the feature; assert the
|
||||
Actor cell (initials, key or cog or user-round-x icon, badge, "Not signed in"), the sentence
|
||||
text and that the target name is in a semibold element, the shield marker only on security
|
||||
rows, and the Outcome badge word.
|
||||
4. **Target cell** — deleted (struck-through class, "Deleted" badge, type line), living (link
|
||||
with `href` "/article/8/edit", "/media", "/users", "/integration/api-keys" per type, plain text
|
||||
for "release"), and null ("—"); clicking the link navigates and does not open the details.
|
||||
5. **Day grouping and When cell** — fixture across 3 Oct and 2 Oct 2026; assert two separators
|
||||
"3 Oct 2026" and "2 Oct 2026" in order, the When cell pattern and `title`.
|
||||
6. **Sentences, Examples-driven** — for each Examples row set the locale (`setLocale`), render
|
||||
and assert the exact sentence, including plurals, `failed_groups`, "Forever", the dropped slot
|
||||
for a missing target name, the unknown code `article.archive`, and the success sentence used
|
||||
for an unlisted failed action (`auth.login` failed). Add a completeness test: every action in
|
||||
the catalogue has an English and a French template, and every key used on the page exists in
|
||||
`messages.ts`.
|
||||
7. **Loading** — unresolved requests; at 149 ms nothing in place of strip and table; at 150 ms 4
|
||||
skeleton cards, 9 skeleton rows (5 cards below md), `aria-busy="true"`, no pager; resolve and
|
||||
assert the Default content.
|
||||
8. **Zero-value strip** — `summaryZero`; assert "214" "4% vs previous 7 days" with the
|
||||
trending-down icon, "5" "4 people, 1 API key", "0" "Nothing failed" and "0" "Nothing to flag"
|
||||
with `circle-check`, no error or warning fill class, bars at 2 px.
|
||||
9. **Total sub-line without a comparison, Examples-driven** — previous_total 0 gives "No earlier
|
||||
data" and equal totals give "No change", both without a trend icon; French "Aucune donnée
|
||||
antérieure" and "Aucun changement"; ranges Today, 30 days and Custom give "vs previous day",
|
||||
"vs previous 30 days" and "vs previous period".
|
||||
10. **Empty, no activity yet** — `summaryNone`, items []; assert "No data yet" on two cards, the
|
||||
calm lines on the other two, the empty panel copy and icon, a disabled "Export CSV", no table
|
||||
header and no pager.
|
||||
11. **Empty, filters match nothing** — type "sitemap", choose Category "Media" and Outcome
|
||||
"Failed", resolve items []; assert chips, empty panel copy, "Clear filters", enabled export;
|
||||
click "Clear filters" and assert a request with `range=7d&page=1&size=25` only.
|
||||
12. **Error, Examples-driven** — list rejects with `ApiError(500)`, `ApiError(400)`, then
|
||||
`NetworkError`, summary resolves; assert the strip, the alert copy, "Try again", a disabled
|
||||
export and no table; click "Try again"; assert the list request is repeated and the Default
|
||||
rows replace the alert.
|
||||
13. **Summary alone fails** — summary rejects with `ApiError(500)`, `ApiError(403)`, then
|
||||
`NetworkError`; assert the strip is absent, nothing is drawn in its place, and the list is
|
||||
fully rendered.
|
||||
14. **Forbidden** — list rejects with `ApiError(403)` and a verified session; assert no export
|
||||
button, no strip, no filter bar, the lock panel copy, and that "Back to home" navigates to
|
||||
/home. Also `ApiError(401)` triggers the global redirect and no panel.
|
||||
15. **Strip cards, Examples-driven** — click each card; assert the effect and `aria-pressed`
|
||||
(Failed: request with `outcome=failed&page=1`, chip "Outcome: Failed"; Security: request with
|
||||
`security=true`, chip "Security events"; Total: filters cleared; Active people: no request)
|
||||
and that the summary is not requested again.
|
||||
16. **Filtered, card applied** — Category "Authentication", 30 days, then the Failed card; assert
|
||||
the chip texts and order, "Clear all", the summary requested only once for `range=30d`, the
|
||||
pager "Showing 1–6 of 6", and below md the Filters badge "3".
|
||||
17. **Filter bar, Examples-driven** — for each Examples row assert the exact query, `page=1` after
|
||||
starting on page 3, and the 300 ms debounce with fake timers (no request at 299 ms, one at
|
||||
300 ms; Enter applies at once and the pending debounce does not send a second request).
|
||||
18. **Custom dates** — pick two dates in the popover (stub the calendar's model); assert no request
|
||||
after the first date, then `range=custom&from=2026-09-01&to=2026-09-30` on the list and the
|
||||
summary, and the chip "Dates: 1 – 30 Sep 2026"; also "28 Sep – 3 Oct 2026".
|
||||
19. **Chips** — remove one chip, then "Clear all"; assert the queries and that the summary is
|
||||
requested again only when the range changes back.
|
||||
20. **Person options** — assert no `GET /v1/user/` or `GET /v1/api-key/` call at mount, both on
|
||||
first open of the menu, none on the second; assert group order and the three query values.
|
||||
21. **Pagination** — next page, size 50, last page "Showing 1,276–1,284 of 1,284" with next
|
||||
disabled; mobile "Page 1 of 52" and previous and next.
|
||||
22. **Mobile list and sheet** — below md assert cards (actor line, sentence, badges, "Target
|
||||
deleted"), "Filters" with the badge count, the sheet content and footer ("Show 6 results", "Show
|
||||
1 result"), two date fields for Custom, and that "Show … results" closes it.
|
||||
23. **Export success** — click "Export CSV" with filters applied; assert one request to
|
||||
`/v1/activity/export` with the list filters and no `page` or `size`, "Preparing file…" and
|
||||
disabled while pending, an object URL created and an anchor clicked with
|
||||
`download="activity.csv"`, the success toast (color success, icon `i-lucide-circle-check`,
|
||||
title "Export started. Your file will download shortly."), and the button back to "Export CSV".
|
||||
24. **Export failure, Examples-driven** — 500, 403 and `NetworkError`; assert the error toast
|
||||
(color error, icon `i-lucide-circle-x`, title "Couldn't export activity. Try again."), no
|
||||
download, button re-enabled.
|
||||
25. **Details open and close** — click, then Enter on a focused row; assert the title, the selected
|
||||
row class, the footer "Event 2 of 1,284"; close with Escape, the close button and the scrim and
|
||||
assert focus on the row.
|
||||
26. **Details content, Examples-driven** — the six board events: Edit with a diff (Title row with
|
||||
both values and the "Body edited (+312 characters)" row, "(+1 character)", "(-48 characters)"),
|
||||
long text (stub overflow; "Show more" on that row only, toggles to "Show less"), failed with a
|
||||
long title (alert "Why it failed", wrapping), deleted target (struck-through name, "Deleted"
|
||||
badge, explanation, no Changes), system event ("System" badge, "System task", two "Not
|
||||
applicable"), failed login ("Not signed in", "Security" badge, reason, IP and Browser). Assert
|
||||
the field labels and the Draft and Published values for `online`, the raw code for an unknown
|
||||
field, and that no body text is ever rendered.
|
||||
27. **Details times** — `created_at` "2026-10-06T10:42:18Z": the UTC line is exactly "6 Oct 2026,
|
||||
10:42:18 UTC" and the local line ends with "(your time)".
|
||||
28. **Previous and Next across pages** — first item: previous disabled, "Event 1 of 1,284"; 24
|
||||
nexts without a request; the 25th next sends `page=2&size=25` with the same filters, shows
|
||||
page 2's first item, "Event 26 of 1,284" and the pager "Showing 26–50 of 1,284"; previous
|
||||
sends `page=1` and shows "Event 25 of 1,284"; the last event disables next; a 500 on the
|
||||
neighbour page keeps the panel on the current event and re-enables the button; the position
|
||||
also follows a non-default size (size 50, page 2, index 0 is "Event 51").
|
||||
29. **Details below md** — the bottom-sheet variant: header "2 of 1,284", full-width buttons, no
|
||||
footer text.
|
||||
30. **My activity, Examples-driven** — role "editor" then no role: title, subtitle, breadcrumb "My
|
||||
activity", no strip, no Person filter, no export, no Actor column or card actor line; assert no
|
||||
request to `/summary` or `/export` and no `actor` parameter; pager "Showing 1–25 of 41"; empty
|
||||
state copy; details "Event 1 of 41" with previous disabled.
|
||||
31. **French** — `setLocale('fr')`; assert the chrome strings (Activity "Activité", My activity
|
||||
"Mon activité", Export CSV "Exporter en CSV", Preparing file… "Préparation du fichier…",
|
||||
Total actions "Actions au total", Active people "Personnes actives", Failed actions "Actions
|
||||
en échec", Security events "Événements de sécurité", Succeeded "Réussie", Failed "Échec", Not
|
||||
signed in "Non connecté", Activity details "Détails de l'activité", Previous event and Next
|
||||
event "Événement précédent" and "Événement suivant", "11 signalés"), the French sentences, the
|
||||
plural and "Forever" ("Toujours") forms, and the day separator format.
|
||||
32. **Hover time** — each When cell has a `title` that is non-empty and contains seconds.
|
||||
|
||||
## Visual (task visual:check -- activity, desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/screens/activity.md`, board `docs/design/source/RenewCMS Screens
|
||||
Activity.dc.html`, groups "Activity, owner and admin", "Details slide-over" and "My activity"
|
||||
(the last two are already declared as `extraSources` in `web/visual/check.ts`); exact values in
|
||||
`docs/design/source/ActivityFrame.dc.html`. Add each state to `visualStates.activity` in
|
||||
`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 (6 gated states, 24
|
||||
captures). No state uses the `now` option or a frozen clock. Mocks are keyed by method and path
|
||||
(`GET /v1/activity`, `GET /v1/activity/summary`, `GET /v1/auth/me`); use `bodyFor` to read the
|
||||
query. Do not mock or call `/v1/user/` or `/v1/api-key/` (Person options load only when the menu
|
||||
opens, which no state does), so there is no unmocked request.
|
||||
|
||||
Sessions: add `activityOwnerSession` (id 2, display_name "Inès Duarte", role "owner") and
|
||||
`activityEditorSession` (id 3, display_name "Marta Alves", role "editor"), as the board draws them
|
||||
in its sidebar chip. The board draws a 4-item sidebar for the regular user as illustrative; the
|
||||
real shell keeps all 7 items, a known small difference in the My activity Empty capture.
|
||||
|
||||
Fixtures (all taken from the frame's script): `activityBoardEvents`, the 15 events of the frame in
|
||||
its order, mapped to API items. Where the frame's sample has no real producer the nearest real
|
||||
code is used and the sentence differs from the board (accepted; those rows only appear in skipped
|
||||
states): 1 `article.publish`, 2 `article.edit` with title and body (+312) changes, 3
|
||||
`auth.login_failed` (anonymous, name "alex@example.com", reason from the frame), 4
|
||||
`api_key.revoke`, 5 `system.media_purge` (system, ip and user_agent null), 6 `invite.accept`
|
||||
(replaces "Changed the role"), 7 `media.upload` by the API key "Website sync", 8 `media.delete`
|
||||
(target not existing), 9 `article.publish` failed with the 150-character title, 10
|
||||
`system.activity_cleanup`, 11 `article.edit` with long title values (replaces the "Site
|
||||
description" setting diff), 12 `api_key.create`, 13 `user.update_profile`, 14 `media.upload`, 15
|
||||
`auth.password_reset_completed`; times are the frame's local times minus one hour as UTC
|
||||
(`created_at`), IPs and browsers as in the frame, security as in the frame. Summary fixtures are
|
||||
the three in the Automated section; the strip of the Default state is "1,284 / 9 / 6 / 11" with
|
||||
the sub-line "11 flagged". Total is 1284 with page size 25 for the owner and 41 for the editor.
|
||||
|
||||
- [ ] Loading: both requests with `hang: true`; the setup waits 150 ms or more so the skeleton
|
||||
strip and rows are drawn before the capture.
|
||||
- [ ] Empty, no activity yet: list `items: [], total: 0`, summary `summaryNone`; "Export CSV"
|
||||
disabled.
|
||||
- [ ] Empty, filters match nothing: the board applies Search "sitemap", Category "Media", Outcome
|
||||
"Failed" (steps type and select them, or the mock answers with the filtered query), list
|
||||
`items: [], total: 0`, summary Default.
|
||||
- [ ] Error with retry: list 500, summary Default; the alert, disabled export, strip drawn.
|
||||
- [ ] Forbidden (403): list 403, summary 403; session with a non-admin role as the board does
|
||||
(`role: 'user'`), so no "Export CSV".
|
||||
- [ ] My activity, Empty: `activityEditorSession`, list `items: [], total: 0`.
|
||||
- [ ] skip: clock-sensitive. Default, large dataset: `skip: 'clock-sensitive'` (day separators and
|
||||
"2 min ago" depend on the real clock). Prints SKIP, never investigated or date-frozen.
|
||||
- [ ] skip: clock-sensitive. Zero-value summary strip, Filtered card applied and Mobile filters
|
||||
sheet (all draw Today rows behind the strip or sheet).
|
||||
- [ ] skip: clock-sensitive. The six Details slide-over states (Edit with a diff, Settings diff
|
||||
long text, Failed action very long target title, Deleted target no diff, System-initiated no
|
||||
human actor, Security event failed login) and the My activity states Default and Details:
|
||||
the rows behind the scrim show relative times and the panel shows the browser's local
|
||||
time. Their Vitest coverage is cases 3, 5, 25 to 30.
|
||||
- [ ] Layout, from `ActivityFrame.dc.html`, not `components.md`: header row is the title block
|
||||
(20 px/28 px title) on the left and the 32 px (44 px mobile) "Export CSV" button on the right,
|
||||
aligned to the bottom; the strip is 4 columns with a 12 px gap on desktop and a clipped row
|
||||
of 156 px cards with an 8 px gap on mobile; the table header is 36 px, day rows 32 px, rows at
|
||||
least 52 px; the slide-over is 480 px wide on desktop, the bottom sheet 88 % high.
|
||||
- [ ] Error is drawn as the frame's alert (error soft background, circle-x, title, text, 28 px
|
||||
outline "Try again"), the empty states as the frame's centred panel (44 px icon box, 15 px
|
||||
semibold title, muted text up to 340 px).
|
||||
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches Export CSV, the strip
|
||||
cards, the search, the selects, the groups, a row; Enter opens the details; focus is trapped
|
||||
in the slide-over and returns to the row on close; Escape closes it); hover (the row, the
|
||||
native `title` tooltip), the 240 ms slide-over and sheet motion and the skeleton pulse, all
|
||||
stopped under reduced motion; the date-range popover.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real round trips against `task dev`** — Vitest only mocks `useApi`. As owner: perform an
|
||||
article publish, an image upload and a failed login, then confirm they appear newest first with
|
||||
the right sentences, the shield marker on the failed login, the Succeeded and Failed badges, the
|
||||
summary numbers and the sparkline bars; apply each filter and each card; page through with size
|
||||
25, 50 and 100; open the details across a page boundary.
|
||||
- **Real CSV** — click "Export CSV" with and without filters and open `activity.csv`: columns
|
||||
`when,actor,action,target,outcome,source,ip`, the filtered rows only, no page or size applied,
|
||||
neutralised cells that start with `=`, `+`, `-` or `@`; confirm the browser download prompt and
|
||||
the two toasts.
|
||||
- **Admin session** — sign in as an admin: the same full view as the owner.
|
||||
- **Time zones** — set the OS time zone to UTC-8 and UTC+9, reload, and confirm the day
|
||||
separators, the clock times, the "(your time)" line and the UTC line are consistent.
|
||||
- **Regular user** — no real account can reach "My activity" today; confirm by a mocked
|
||||
`/v1/auth/me` in the browser (or a future role) that the view has no strip, no Person filter and
|
||||
no export, and that the server answers 403 for `actor=user:2` and for the summary and export.
|
||||
- **API key** — calling `GET /v1/activity` with an API key answers 403 "This action needs a real
|
||||
session." and the SPA never does it.
|
||||
- **"Today", "Yesterday" and relative times** — confirm them against the real clock, since
|
||||
visual:check skips every state that shows them.
|
||||
- **French** — switch the language and read every state, the sentences and the toasts.
|
||||
- **Real screen readers and zoom** — row selection, the sparkline buttons (labelled by the card
|
||||
text), the sheet focus trap.
|
||||
Reference in new issue
Block a user