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,693 @@
|
||||
Feature: Activity screen, details slide-over and My activity
|
||||
|
||||
As a verified RenewCMS user
|
||||
I want /activity to read as a plain-language timeline of who did what and when, with filters,
|
||||
a summary strip, CSV export and a details panel for each event
|
||||
So that an owner can audit the site without reading a log, and any other role sees its own history
|
||||
|
||||
Background:
|
||||
Given /activity is a verified route (meta.auth "verified", meta.title "Activity") rendered as
|
||||
ActivityView (views/ActivityView.vue, the "ActivityPage" of docs/design/components.md) inside
|
||||
DashboardShell, per docs/design/routes.md and docs/design/screens/activity.md
|
||||
And the activity API already exists (features/activity-log-backend.feature and
|
||||
features/activity-log-producers.feature) and this slice wires the SPA to it unchanged, with
|
||||
no Go change
|
||||
And the Home card and the Settings "Retention" card of the same board are not in this slice
|
||||
And where docs/design prose and the board frame ActivityFrame.dc.html differ, the frame is
|
||||
followed
|
||||
And the views read the session role from GET /v1/auth/me: "owner" and "admin" are the full
|
||||
view; any other or absent value is the regular-user view ("My activity"), which no real
|
||||
account can reach today and is covered by Vitest and one visual state only
|
||||
And a full view loads, in parallel, GET /v1/activity?range=7d&page=1&size=25 and GET
|
||||
/v1/activity/summary?range=7d; a regular-user view loads only GET /v1/activity with the same
|
||||
query and never calls /summary or /export, and never sends an actor parameter
|
||||
And the list query always sends range, page and size explicitly, and adds q, actor, category,
|
||||
outcome, security, from and to only when set (outcome "all" is not sent, from and to only
|
||||
with range=custom); the summary query sends only range, from and to; the export query sends
|
||||
the list query without page and size
|
||||
And filters and the page live in component state, not in the URL, and reset when the user
|
||||
leaves /activity
|
||||
And useApi's global handling covers 401 and the unverified 403
|
||||
(features/api_client_global_error_handling.feature); this screen maps every other failure
|
||||
And DashboardShell's navigation gains "Activity" (icon i-lucide-history, linking to /activity)
|
||||
between "Users" and "Integration", in the sidebar and in the mobile drawer, so it lists Home,
|
||||
Articles, Media, Users, Activity, Integration, Settings in that order, for every verified
|
||||
user; "Activity" has aria-current="page" on /activity; the specs that list the destinations
|
||||
are amended accordingly (named in the QA file)
|
||||
And the PageHeader breadcrumb and the mobile title read the page title: "Activity", or
|
||||
"My activity" for the regular-user view
|
||||
And the page padding is 24 px on desktop and 16 px on mobile with a 16 px gap between the
|
||||
header, the strip, the filter bar, the chips, the table and the pager
|
||||
And the header is the title "Activity" (regular user: "My activity") with the subtitle "Who
|
||||
did what, and when, across your site." (regular user: "Everything you've done on this
|
||||
site."), and, in the full view except Forbidden, an outline button with icon download and
|
||||
the label "Export CSV" at the right, aligned to the bottom of the title block
|
||||
And sentences are composed in the SPA from the event's action code, its outcome, its params
|
||||
and its target name through the existing i18n (English keys, French in messages.ts), never
|
||||
stored as text; the target name is user content, is inserted untranslated as a semibold
|
||||
segment, and is the last slot of every template, so a missing target name drops the slot
|
||||
with its leading space and colon
|
||||
And the success sentences are, in English and French (the actor sits in its own column, so
|
||||
sentences start with the verb; {0} is the target name):
|
||||
| action | English | French |
|
||||
| article.create | Created the article {0} | Article créé : {0} |
|
||||
| article.edit | Edited the article {0} | Article modifié : {0} |
|
||||
| article.publish | Published the article {0} | Article publié : {0} |
|
||||
| article.unpublish | Unpublished the article {0} | Article dépublié : {0} |
|
||||
| article.delete | Deleted the article {0} | Article supprimé : {0} |
|
||||
| media.upload | Uploaded the image {0} | Image téléversée : {0} |
|
||||
| media.rename | Renamed the image {0} | Image renommée : {0} |
|
||||
| media.delete | Deleted the image {0} | Image supprimée : {0} |
|
||||
| media.merge | Merged duplicates into the image {0} | Doublons fusionnés dans l'image {0} |
|
||||
| media.purge | Purged duplicate images: {0} merged, {1} articles updated | Doublons purgés : {0} fusionnés, {1} articles mis à jour |
|
||||
| system.media_purge | Ran the scheduled duplicate purge: {0} merged, {1} articles updated | Purge automatique des doublons : {0} fusionnés, {1} articles mis à jour |
|
||||
| user.register | Registered the account {0} | Compte créé : {0} |
|
||||
| user.verify | Verified the account {0} | Compte vérifié : {0} |
|
||||
| user.update_profile | Updated the profile of {0} | Profil mis à jour : {0} |
|
||||
| invite.create | Invited {0} | Invitation envoyée à {0} |
|
||||
| invite.cancel | Cancelled the invite for {0} | Invitation annulée : {0} |
|
||||
| invite.renew | Renewed the invite for {0} | Invitation renouvelée : {0} |
|
||||
| invite.accept | Accepted the invite for {0} | Invitation acceptée : {0} |
|
||||
| api_key.create | Created API key {0} | Clé d'API créée : {0} |
|
||||
| api_key.revoke | Revoked API key {0} | Clé d'API révoquée : {0} |
|
||||
| auth.login | Logged in | Connexion |
|
||||
| auth.logout | Logged out | Déconnexion |
|
||||
| auth.password_reset_requested | Requested a password reset | Réinitialisation du mot de passe demandée |
|
||||
| auth.password_reset_completed | Reset the password | Mot de passe réinitialisé |
|
||||
| system.update_checked | Checked for updates | Recherche de mises à jour |
|
||||
| system.update_downloaded | Downloaded the update {0} | Mise à jour téléchargée : {0} |
|
||||
| system.restart_requested (kind restart) | Requested a restart | Redémarrage demandé |
|
||||
| system.restart_requested (kind update) | Restarted to apply the update {0} | Redémarrage pour appliquer la mise à jour {0} |
|
||||
| system.activity_retention_changed | Changed the activity retention from {0} to {1} | Durée de conservation de l'activité modifiée : {0} → {1} |
|
||||
| system.activity_cleanup | Removed {0} old events from the activity log | {0} anciens événements supprimés du journal d'activité |
|
||||
And the failed sentences, used when outcome is "failed", are:
|
||||
| action | English | French |
|
||||
| article.create | Failed to create the article {0} | Échec de la création de l'article {0} |
|
||||
| article.edit | Failed to edit the article {0} | Échec de la modification de l'article {0} |
|
||||
| article.publish | Failed to publish the article {0} | Échec de la publication de l'article {0} |
|
||||
| article.unpublish | Failed to unpublish the article {0} | Échec de la dépublication de l'article {0} |
|
||||
| article.delete | Failed to delete the article {0} | Échec de la suppression de l'article {0} |
|
||||
| media.upload | Failed to upload the image {0} | Échec du téléversement de l'image {0} |
|
||||
| media.rename | Failed to rename the image {0} | Échec du renommage de l'image {0} |
|
||||
| media.delete | Failed to delete the image {0} | Échec de la suppression de l'image {0} |
|
||||
| media.merge | Failed to merge duplicates into the image {0} | Échec de la fusion des doublons dans l'image {0} |
|
||||
| media.purge | Failed to purge duplicate images | Échec de la purge des doublons |
|
||||
| system.media_purge | Failed to run the scheduled duplicate purge | Échec de la purge automatique des doublons |
|
||||
| user.register | Failed to register the account {0} | Échec de la création du compte {0} |
|
||||
| user.verify | Failed to verify the account {0} | Échec de la vérification du compte {0} |
|
||||
| user.update_profile | Failed to update the profile of {0} | Échec de la mise à jour du profil de {0} |
|
||||
| invite.create | Failed to invite {0} | Échec de l'invitation de {0} |
|
||||
| invite.cancel | Failed to cancel the invite for {0} | Échec de l'annulation de l'invitation de {0} |
|
||||
| invite.renew | Failed to renew the invite for {0} | Échec du renouvellement de l'invitation de {0} |
|
||||
| invite.accept | Failed to accept the invite for {0} | Échec de l'acceptation de l'invitation de {0} |
|
||||
| api_key.create | Failed to create API key {0} | Échec de la création de la clé d'API {0} |
|
||||
| api_key.revoke | Failed to revoke API key {0} | Échec de la révocation de la clé d'API {0} |
|
||||
| auth.login_failed | Failed login attempt for {0} | Échec de connexion pour {0} |
|
||||
| system.update_checked | Failed to check for updates | Échec de la recherche de mises à jour |
|
||||
| system.update_downloaded | Failed to download the update {0} | Échec du téléchargement de la mise à jour {0} |
|
||||
| system.restart_requested | Failed to restart | Échec du redémarrage |
|
||||
And an action whose failed sentence is not listed (for example auth.login) renders its success
|
||||
sentence with the Failed badge; an action code that is not in the catalogue renders the raw
|
||||
code (for example "article.archive") and never throws
|
||||
And for auth.login_failed the slot {0} is the actor's name (the attempted username, since the
|
||||
event has no target), while the Actor column reads "Not signed in"
|
||||
And counts in params are shown with the locale's number format; "{0} old events" and "{1}
|
||||
articles updated" use the singular form for 1 ("1 old event", "1 article updated"); a
|
||||
media.purge or system.media_purge event with params.failed_groups appends ", {2} groups
|
||||
failed" ("1 group failed") in English and ", {2} groupes en échec" in French;
|
||||
system.activity_retention_changed shows days as "{n} days" and 0 as "Forever" ("{n} jours",
|
||||
"Toujours")
|
||||
And the category icons are: articles file-text, media image, users users, api_keys key-round,
|
||||
auth shield-check, system server-cog; the category labels are Articles, Media, Users and
|
||||
invites, API keys, Authentication, System
|
||||
And the Actor cell is the actor's name with: a UAvatar of initials (first letters of the first
|
||||
two words, upper case) for type "user"; a key-round icon avatar and an "API key" badge for
|
||||
"api_key"; a cog icon avatar and a "System" badge for "system"; a user-round-x icon avatar
|
||||
and the text "Not signed in" for "anonymous"
|
||||
And the Target cell is "—" (muted) when target is null; when target.exists it is the name as an
|
||||
underlined link with an arrow-up-right icon, plus the target type as a muted second line
|
||||
(Article, Image, User, Invite, API key, Release); when it does not exist the name is struck
|
||||
through, muted, with a "Deleted" badge (icon trash-2) and the type line; the link goes to
|
||||
/article/{id}/edit for article, /media for image, /users for user and invite,
|
||||
/integration/api-keys for api_key, and a target type with no page (release) is plain text
|
||||
And the Outcome cell is a badge with icon and word, never colour alone: "Succeeded" (check,
|
||||
bordered) or "Failed" (circle-x, --rc-error-soft background, --ui-error text)
|
||||
And a row whose event has security true shows a shield-alert icon in --ui-warning after the
|
||||
sentence and a 2 px inset --ui-warning edge on its left side; on mobile it also shows a
|
||||
"Security" badge (shield-alert, --rc-warning-soft, --ui-warning)
|
||||
And the table (md and up) has the columns When, Actor, Action, Target, Outcome in a grid
|
||||
96px 210px minmax(0,1fr) 230px 120px (regular user: When, Action, Target, Outcome in 96px
|
||||
minmax(0,1fr) 230px 120px), a 36 px header on --ui-bg-elevated with an arrow-down icon on
|
||||
When, day separator rows of 32 px on --ui-bg-muted with the label in 12 px semibold, and
|
||||
rows at least 52 px high with a top border; a selected row (details open) and a hovered row
|
||||
use --ui-bg-elevated
|
||||
And rows are grouped by the user's local calendar day under a separator labelled "Today",
|
||||
"Yesterday" or the date as "3 Oct 2026" (French "3 oct. 2026"), in the order the API
|
||||
returns them (newest first); the When cell shows the time as drawn, relative within today
|
||||
("2 min ago", "29 min ago", "1 h ago") and the local clock time "HH:mm" on earlier days,
|
||||
and carries the full local time "3 Oct 2026, 14:20:05" as its title attribute
|
||||
And below md the table is replaced by a stacked list of cards (ActivityCardList) with the same
|
||||
separators: a first line with the actor (avatar, name, badge; hidden for the regular user)
|
||||
and the time at the right, the category icon and the sentence (15 px, up to 3 lines), then a
|
||||
line of badges: the Outcome badge, "Security" when flagged, and "Target deleted" when the
|
||||
target is gone; each card is at least 44 px high as a touch target
|
||||
And rows and cards are focusable, open the details on click and on Enter, and a click on the
|
||||
target link navigates to the target without opening the details
|
||||
And the summary strip (ActivitySummaryStrip, full view only) has four cards that are real
|
||||
buttons: "Total actions", "Active people", "Failed actions", "Security events", each with
|
||||
the number (locale-formatted), a sub-line, and a seven-bar sparkline of seven div bars,
|
||||
4 px wide, 2 px apart, oldest at the left, bar height round(value / max(series, 1) * H)
|
||||
with a 2 px minimum and H of 20 px on desktop and 16 px on mobile, drawn from
|
||||
series.total, series.active, series.failed and series.security
|
||||
And on desktop the strip is a 4-column grid with a 12 px gap; below md it is, as drawn in
|
||||
ActivityFrame.dc.html, a single row of 156 px cards with an 8 px gap that is clipped at the
|
||||
page edge
|
||||
And the Total sub-line is "{pct}% vs previous 7 days" with icon trending-up when total is at
|
||||
least previous_total and trending-down otherwise, pct being the rounded absolute change
|
||||
against previous_total; for the ranges Today, 30 days and Custom the words read "vs previous
|
||||
day", "vs previous 30 days" and "vs previous period"; when previous_total is 0 the sub-line
|
||||
is "No earlier data" and when the two totals are equal it is "No change", both without an
|
||||
icon (French "Aucune donnée antérieure", "Aucun changement")
|
||||
And the Active people number is active_people.users plus active_people.api_keys with the
|
||||
sub-line "{users} people, {api_keys} API keys" ("1 person", "1 API key")
|
||||
And the Failed sub-line is "{pct}% of all actions" (one decimal, "0.5%") with icon circle-x
|
||||
and the --rc-error-soft background and --ui-error number
|
||||
And the Security sub-line is "{n} flagged" (French "{n} signalés", "1 signalé"), n being
|
||||
summary.security, with icon shield-alert and the --rc-warning-soft background and
|
||||
--ui-warning number
|
||||
And a Failed or Security count of 0 reads "0" with "Nothing failed" or "Nothing to flag" and
|
||||
icon circle-check in --ui-success, drops the emphasis fill and draws its bars in
|
||||
--ui-border-accented; with total 0 Total and Active people read "0" with "No data yet"
|
||||
And the strip follows only the date range; q, person, category, outcome and security never
|
||||
change it, and it is not requested again when they change
|
||||
And when the summary request fails with any status or never reaches the server, the strip is
|
||||
not rendered and nothing is shown in its place
|
||||
And the filter bar (ActivityFilterBar) has: a UInput (icon search, clearable, placeholder
|
||||
"Search activity", 240 px) that applies 300 ms after the last keystroke or at once on Enter;
|
||||
a Person USelectMenu (icon user-round, "All people", 168 px, full view only) with the groups
|
||||
users, API keys and "System"; a Category USelectMenu (icon layout-list, "All categories",
|
||||
176 px); an Outcome button group "All / Succeeded / Failed" (soft is the selected one,
|
||||
outline the others); and the date presets "Today / 7 days / 30 days / Custom" (Custom has
|
||||
icon calendar and opens a date-range popover) at the right; controls are 32 px high
|
||||
And the Person options come from GET /v1/user/ (accepted users, shown by display name or
|
||||
username) and GET /v1/api-key/, requested once, the first time the Person menu opens, so a
|
||||
page load makes no other call than the two above
|
||||
And choosing a Person sends actor=user:{id}, actor=key:{id} or actor=system; a Category sends
|
||||
category=articles|media|users|api_keys|auth|system; Outcome Succeeded sends outcome=success
|
||||
and Failed outcome=failed; Today, 7 days and 30 days send range=today|7d|30d; Custom sends
|
||||
range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD once both dates are chosen; any filter change
|
||||
resets the page to 1 and sends a new list request, and the page size is kept
|
||||
And the active filter chips (UBadge subtle with a trailing ghost x button) read "Search:
|
||||
spring", "Person: Marta Alves", "Category: Authentication", "Outcome: Failed", "Security
|
||||
events", "Dates: 30 days" or "Dates: 1 – 30 Sep 2026" (same month "1 – 30 Sep 2026", across
|
||||
months "28 Sep – 3 Oct 2026"), followed by a link button "Clear all"; the default range 7
|
||||
days is never a chip; removing a chip resets that filter; "Clear all" resets every filter to
|
||||
the default (range 7d)
|
||||
And below md the bar is a search field and a "Filters" button (icon sliders-horizontal) with a
|
||||
count badge (the number of chips, hidden at 0); it opens a bottom sheet titled "Filters"
|
||||
with Person (full view only), Category, Outcome, Dates (two date fields when Custom is
|
||||
chosen) and a footer "Clear all" (disabled at 0 filters) and "Show {total} results" ("Show 1
|
||||
result"); the sheet applies each change at once, so the footer count is the current total,
|
||||
and "Show … results" closes the sheet
|
||||
And the pager (full width under the table) reads "Showing {from}–{to} of {total}" with
|
||||
locale-formatted numbers, a UPagination (previous, 1, 2, 3, …, last, next; previous
|
||||
disabled on page 1), and "Rows per page" with a select of 25, 50 and 100; changing the size
|
||||
resets the page to 1; below md it reads "Page {page} of {pages}" above "{from}–{to} of
|
||||
{total}", with previous and next buttons and the size select
|
||||
And while a list request is in flight for longer than 150 ms, 9 skeleton rows (desktop) or 5
|
||||
skeleton cards (below md) replace the rows and the pager is hidden; the strip shows 4
|
||||
skeleton cards while the summary request is in flight for longer than 150 ms; nothing is
|
||||
drawn in their place for the first 150 ms
|
||||
And the list is "empty" when total is 0: with no chip it is "No activity yet" (icon history,
|
||||
"Actions on articles, media, users and API keys will appear here.", regular user: "Your
|
||||
actions will appear here once you start working on the site."), with at least one chip it is
|
||||
"No activity matches these filters" (icon search-x, action "Clear filters")
|
||||
And the error, empty and forbidden panels are drawn as in ActivityFrame.dc.html: "Couldn't
|
||||
load activity" is an error alert with icon circle-x, the text and an outline "Try again"
|
||||
button, and "You can't view this activity" (no trailing period) uses icon lock and the
|
||||
action "Back to home"
|
||||
And "Export CSV" is disabled in "No activity yet" and while the list is in the Error state,
|
||||
and enabled otherwise
|
||||
And ActivityDetailsPanel is a USlideover: 480 px wide from the right on desktop, an 88 %-high
|
||||
bottom sheet with a 36×4 px handle below md; its header reads "Activity details" with a
|
||||
close button (and "{position} of {total}" below md); it opens from a row or card, closes
|
||||
with Escape, the close button or the scrim, and focus returns to the row that opened it
|
||||
And the panel body has, in this order: the full sentence (18 px semibold, target in semibold)
|
||||
with the Outcome badge, a "Security" badge when flagged and the category icon and label; an
|
||||
error alert "Why it failed" with failure_reason, only when outcome is "failed" and the
|
||||
reason is present; the grid When, Actor, Source, IP address, Browser; a Target section;
|
||||
a Changes section
|
||||
And When shows the local time as "6 Oct 2026, 10:42:18 (your time)" and under it, muted, the
|
||||
UTC time as "6 Oct 2026, 10:42:18 UTC"; Actor is the avatar, the name and the same badges
|
||||
as the table; Source is "Web app" for web, "API key" for api_key and "System task" for
|
||||
system; IP address (IBM Plex Mono 12 px) and Browser read "Not applicable" (muted) when ip
|
||||
or user_agent is null
|
||||
And the Target section shows the name (a link with arrow-up-right when it exists, otherwise
|
||||
struck through with a "Deleted" badge and the line "This item was deleted. Its name is kept
|
||||
for reference."), and the line "{type} · {id}"; it is absent when target is null
|
||||
And the Changes section (ActivityDiff) is absent when changes is empty; each change is a row
|
||||
headed by its field label: a "text" change shows Before and After boxes (side by side with
|
||||
an arrow-right on desktop, stacked with an arrow-down below md), a "body" change shows only
|
||||
the line "Body edited (+312 characters)" with icon file-pen-line ("-48", "+1 character")
|
||||
and never any text
|
||||
And the field labels are title "Title", online "Publish state", body "Body", filename "File
|
||||
name", display_name "Display name" (French "Titre", "État de publication", "Corps", "Nom du
|
||||
fichier", "Nom d'affichage"), an unknown field shows its raw code, and for online the
|
||||
values "false" and "true" read "Draft" and "Published" ("Brouillon", "Publié")
|
||||
And a Before or After value taller than two lines is clamped to two lines and a "Show more"
|
||||
link button appears under that row; it toggles to "Show less"; shorter values show no toggle
|
||||
And the panel footer has "Previous event" (icon chevron-up) and "Next event" (icon
|
||||
chevron-down) buttons that move through the current filtered list, and on desktop the
|
||||
muted text "Event {position} of {total}" with position = (page − 1) × size + index + 1;
|
||||
below md the two buttons are full width and there is no footer text
|
||||
And when Previous or Next crosses a page boundary the list page follows, and a failed request
|
||||
for the neighbour page keeps the panel on the current event with no message
|
||||
|
||||
Scenario: Navigation — "Activity" sits between Users and Integration
|
||||
Given the user is on /activity
|
||||
When DashboardShell mounts
|
||||
Then the sidebar and the mobile drawer list Home, Articles, Media, Users, Activity,
|
||||
Integration, Settings in that order
|
||||
And "Activity" has the history icon, links to /activity and has aria-current="page"
|
||||
And the breadcrumb reads "Activity" (mobile title "Activity")
|
||||
|
||||
Scenario: Default — an owner sees the strip, the filter bar and a day-grouped table
|
||||
Given GET /v1/activity resolves 200 with 25 items and total 1284, and GET /v1/activity/summary
|
||||
resolves 200 with total 1284, previous_total 1146, active_people {users 7, api_keys 2},
|
||||
failed 6, security 11 and seven-day series, and the session role is "owner"
|
||||
When the user navigates to /activity
|
||||
Then the header shows "Activity", "Who did what, and when, across your site." and an enabled
|
||||
"Export CSV"
|
||||
And the strip reads "1,284" "12% vs previous 7 days" with trending-up; "9" "7 people, 2 API
|
||||
keys"; "6" "0.5% of all actions"; "11" "11 flagged"
|
||||
And the filter bar shows "Search activity", "All people", "All categories", All selected in
|
||||
the Outcome group, and 7 days selected, with no chip
|
||||
And the table rows follow the API order under day separators
|
||||
And the pager reads "Showing 1–25 of 1,284" and "Rows per page" 25
|
||||
And GET /v1/activity was requested once with range=7d, page=1, size=25 and no other
|
||||
parameter, and GET /v1/activity/summary once with range=7d
|
||||
|
||||
Scenario Outline: Rows — the Actor cell and the sentence follow the event
|
||||
Given a row for an event with actor type "<type>" named "<name>", action "<action>", outcome
|
||||
"<outcome>" and security <security>
|
||||
Then the Actor cell shows <actor>
|
||||
And the Action cell shows "<sentence>" with the target name in semibold, and <marker>
|
||||
And the Outcome cell shows "<badge>"
|
||||
|
||||
Examples:
|
||||
| type | name | action | outcome | security | actor | sentence | marker | badge |
|
||||
| user | Marta Alves | article.publish | success | false | an avatar "MA" and "Marta Alves" | Published the article Spring release | no shield | Succeeded |
|
||||
| api_key | Website sync | media.upload | success | false | a key icon, "Website sync", "API key" | Uploaded the image north-wall.jpg | no shield | Succeeded |
|
||||
| system | System | system.media_purge | success | false | a cog icon, "System", "System" | Ran the scheduled duplicate purge: 3 merged, 5 articles updated | no shield | Succeeded |
|
||||
| anonymous | alex@example.com | auth.login_failed | failed | true | a user-round-x icon, "Not signed in" | Failed login attempt for alex@example.com | a shield-alert marker | Failed |
|
||||
| user | Inès Duarte | article.publish | failed | false | an avatar "ID" and "Inès Duarte" | Failed to publish the article Spring release | no shield | Failed |
|
||||
| user | Jonas Weber | api_key.revoke | success | true | an avatar "JW" and "Jonas Weber" | Revoked API key Mobile app | a shield-alert marker | Succeeded |
|
||||
|
||||
Scenario: Rows — a deleted target is struck through, a living one is a link
|
||||
Given an event of target {"type": "image", "id": "9", "name": "old-banner.png", "exists": false}
|
||||
and an event of target {"type": "article", "id": "8", "name": "Spring release", "exists": true}
|
||||
Then the first Target cell shows "old-banner.png" struck through with a "Deleted" badge and
|
||||
"Image"
|
||||
And the second Target cell shows "Spring release" as a link to /article/8/edit and "Article"
|
||||
And clicking that link navigates to /article/8/edit and does not open the details
|
||||
|
||||
Scenario: Rows — an event with no target shows a dash
|
||||
Given an event whose target is null
|
||||
Then its Target cell shows a muted "—"
|
||||
|
||||
Scenario: Rows — an unknown action code renders the code
|
||||
Given an event with action "article.archive"
|
||||
Then its Action cell shows "article.archive" and the row still opens the details
|
||||
|
||||
Scenario Outline: Sentences — English and French come from the same code and params
|
||||
Given an event with action "<action>", outcome "<outcome>", params <params> and target
|
||||
"<target>"
|
||||
When the language is <language>
|
||||
Then the sentence reads "<sentence>" with "<target>" untranslated
|
||||
|
||||
Examples:
|
||||
| action | outcome | params | target | language | sentence |
|
||||
| article.publish | success | {} | Spring release | English | Published the article Spring release |
|
||||
| article.publish | success | {} | Spring release | French | Article publié : Spring release |
|
||||
| article.publish | failed | {} | Spring release | French | Échec de la publication de l'article Spring release |
|
||||
| media.purge | success | {"merged": 3, "articles_updated": 5} | | English | Purged duplicate images: 3 merged, 5 articles updated |
|
||||
| media.purge | success | {"merged": 3, "articles_updated": 5, "failed_groups": 1} | | English | Purged duplicate images: 3 merged, 5 articles updated, 1 group failed |
|
||||
| system.activity_cleanup | success | {"removed": 1} | | English | Removed 1 old event from the activity log |
|
||||
| system.activity_retention_changed | success | {"from": 90, "to": 0} | | English | Changed the activity retention from 90 days to Forever |
|
||||
| system.restart_requested | success | {"kind": "update", "tag": "v1.4.0"} | v1.4.0 | English | Restarted to apply the update v1.4.0 |
|
||||
| media.upload | failed | {} | | English | Failed to upload the image |
|
||||
|
||||
Scenario: Loading — skeletons after 150 ms while both requests are in flight
|
||||
Given GET /v1/activity and GET /v1/activity/summary have not resolved
|
||||
When the user navigates to /activity
|
||||
Then the header, the filter bar and an enabled "Export CSV" are drawn at once
|
||||
And nothing is drawn in place of the strip and the table for the first 150 ms
|
||||
And after 150 ms 4 skeleton cards replace the strip and 9 skeleton rows (5 cards below md)
|
||||
replace the table, the pager is hidden and the table container is aria-busy="true"
|
||||
When both requests resolve 200 with the Default data
|
||||
Then the skeletons are replaced by the Default state's strip, rows and pager
|
||||
|
||||
Scenario: Zero-value summary strip — a quiet period shows a calm 0
|
||||
Given GET /v1/activity/summary resolves 200 with total 214, previous_total 223, active_people
|
||||
{users 4, api_keys 1}, failed 0, security 0 and series of zeros for failed and security
|
||||
And GET /v1/activity resolves 200 with 25 items
|
||||
When the user navigates to /activity
|
||||
Then the Total card reads "214" "4% vs previous 7 days" with trending-down
|
||||
And the Active people card reads "5" "4 people, 1 API key"
|
||||
And the Failed card reads "0" "Nothing failed" with circle-check and no error fill
|
||||
And the Security card reads "0" "Nothing to flag" with circle-check and no warning fill
|
||||
And their bars are 2 px high
|
||||
|
||||
Scenario Outline: The Total sub-line when there is nothing to compare
|
||||
Given GET /v1/activity/summary resolves 200 with total <total> and previous_total <previous>
|
||||
When the user navigates to /activity
|
||||
Then the Total card reads "<total>" and "<subline>" without a trend icon
|
||||
|
||||
Examples:
|
||||
| total | previous | subline |
|
||||
| 40 | 0 | No earlier data |
|
||||
| 40 | 40 | No change |
|
||||
|
||||
Scenario: Empty, no activity yet — a new site
|
||||
Given GET /v1/activity resolves 200 with items [] and total 0, and GET /v1/activity/summary
|
||||
resolves 200 with every number 0
|
||||
And no filter chip is shown
|
||||
When the user navigates to /activity
|
||||
Then the strip reads "0" "No data yet" for Total and Active people, "0" "Nothing failed" and
|
||||
"0" "Nothing to flag"
|
||||
And an empty panel with icon history shows "No activity yet" and "Actions on articles,
|
||||
media, users and API keys will appear here."
|
||||
And "Export CSV" is disabled and no table header or pager is shown
|
||||
|
||||
Scenario: Empty, filters match nothing — the panel offers Clear filters
|
||||
Given the Default state has loaded
|
||||
When the user types "sitemap" in Search, chooses Category "Media" and Outcome "Failed", and
|
||||
GET /v1/activity resolves 200 with items [] and total 0
|
||||
Then the chips read "Search: sitemap", "Category: Media", "Outcome: Failed" and "Clear all"
|
||||
And an empty panel with icon search-x shows "No activity matches these filters" and "Try a
|
||||
different search or a wider date range." with the action "Clear filters"
|
||||
And "Export CSV" stays enabled and the strip is unchanged
|
||||
When the user activates "Clear filters"
|
||||
Then every filter is reset, GET /v1/activity is sent with range=7d, page=1, size=25 only and
|
||||
the rows come back
|
||||
|
||||
Scenario Outline: Error with retry — a 500, a 400 or a network failure replaces the table
|
||||
Given GET /v1/activity <failure> and GET /v1/activity/summary resolves 200
|
||||
When the user navigates to /activity
|
||||
Then the strip and the filter bar are shown
|
||||
And an error alert shows "Couldn't load activity" and "Check your connection and try again."
|
||||
with the action "Try again"
|
||||
And "Export CSV" is disabled and no table or pager is shown
|
||||
When the user activates "Try again"
|
||||
Then the Loading state shows for the list and GET /v1/activity is sent again with the same
|
||||
query
|
||||
When it then resolves 200 with the Default data
|
||||
Then the Default rows replace the alert
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| resolves with a 400 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario Outline: The summary alone fails — the strip is not drawn and the list still works
|
||||
Given GET /v1/activity resolves 200 with the Default items and GET /v1/activity/summary
|
||||
<failure>
|
||||
When the user navigates to /activity
|
||||
Then the strip is not rendered, with no error shown for it
|
||||
And the filter bar, the rows and the pager are shown as in Default
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| resolves with a 403 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Forbidden (403) — the page itself is refused
|
||||
Given GET /v1/activity resolves 403 and GET /v1/auth/me resolves 200 with a verified user
|
||||
When the user navigates to /activity
|
||||
Then the header shows "Activity" and no "Export CSV"
|
||||
And no strip, filter bar, chips, table or pager is shown
|
||||
And an empty panel with icon lock shows "You can't view this activity" and "Your role
|
||||
doesn't include this page. Ask an owner or admin if you need access." with the action "Back
|
||||
to home"
|
||||
When the user activates "Back to home"
|
||||
Then the router navigates to /home
|
||||
|
||||
Scenario: An unverified 403 and a 401 are handled globally
|
||||
Given GET /v1/activity resolves 401 (or 403 for an unverified session)
|
||||
When the user navigates to /activity
|
||||
Then useApi redirects to /login?redirect=/activity&reason=expired (or to /register/pending)
|
||||
And no Forbidden or Error panel is shown
|
||||
|
||||
Scenario Outline: Strip cards — each card applies its filter
|
||||
Given the Default state has loaded with the 7 days range
|
||||
When the user activates the "<card>" card
|
||||
Then <effect>
|
||||
And <state>
|
||||
|
||||
Examples:
|
||||
| card | effect | state |
|
||||
| Total actions | every filter is cleared and GET /v1/activity is sent with range=7d | no card is marked active |
|
||||
| Active people | nothing changes and no request is sent | no card is marked active |
|
||||
| Failed actions | GET /v1/activity is sent with outcome=failed and the page 1 | the chip "Outcome: Failed" and "Clear all" appear and the card has aria-pressed="true" with a --ui-primary border |
|
||||
| Security events | GET /v1/activity is sent with security=true and the page 1 | the chip "Security events" and "Clear all" appear and the card has aria-pressed="true" with a --ui-primary border |
|
||||
|
||||
Scenario: Filtered, card applied — the Failed card on top of other filters
|
||||
Given the user has chosen Category "Authentication" and 30 days
|
||||
When the user activates the "Failed actions" card and GET /v1/activity resolves 200 with 1
|
||||
item and total 6
|
||||
Then the chips read "Category: Authentication", "Outcome: Failed", "Dates: 30 days" and
|
||||
"Clear all"
|
||||
And GET /v1/activity/summary was requested for range=30d and is not requested again
|
||||
And the pager reads "Showing 1–6 of 6"
|
||||
And below md the "Filters" button shows the count badge "3"
|
||||
|
||||
Scenario Outline: Filter bar — each control sends its query parameter and resets the page
|
||||
Given the user is on page 3 of the Default list
|
||||
When <action>
|
||||
Then GET /v1/activity is sent with <query>, page=1 and size=25
|
||||
|
||||
Examples:
|
||||
| action | query |
|
||||
| the user types "spring" and waits 300 ms | q=spring and range=7d |
|
||||
| the user types "spring" and presses Enter at once | q=spring and range=7d |
|
||||
| the user chooses Person "Marta Alves" (user 3) | actor=user:3 and range=7d |
|
||||
| the user chooses Person "Website sync" (API key 7) | actor=key:7 and range=7d |
|
||||
| the user chooses Person "System" | actor=system and range=7d |
|
||||
| the user chooses Category "Users and invites" | category=users and range=7d |
|
||||
| the user chooses Outcome "Succeeded" | outcome=success and range=7d |
|
||||
| the user chooses "Today" | range=today |
|
||||
| the user chooses "30 days" | range=30d |
|
||||
|
||||
Scenario: Filter bar — Custom opens a date-range popover and applies on the second date
|
||||
Given the Default state has loaded
|
||||
When the user chooses "Custom" and picks 1 Sep 2026 then 30 Sep 2026 in the popover
|
||||
Then GET /v1/activity and GET /v1/activity/summary are sent with range=custom, from=2026-09-01
|
||||
and to=2026-09-30
|
||||
And the chip reads "Dates: 1 – 30 Sep 2026"
|
||||
And no request is sent after the first date alone
|
||||
|
||||
Scenario: Chips — removing one resets only that filter, Clear all resets everything
|
||||
Given the chips "Search: spring", "Outcome: Failed" and "Dates: 30 days" are shown
|
||||
When the user activates the x of "Outcome: Failed"
|
||||
Then GET /v1/activity is sent with q=spring and range=30d and without outcome
|
||||
When the user activates "Clear all"
|
||||
Then GET /v1/activity is sent with range=7d only, and the summary is requested for range=7d
|
||||
And no chip and no "Clear all" is shown
|
||||
|
||||
Scenario: The Person menu loads its options once, on first open
|
||||
Given the Default state has loaded
|
||||
Then GET /v1/user/ and GET /v1/api-key/ have not been requested
|
||||
When the user opens the Person menu
|
||||
Then both are requested once and the menu lists the users, then the API keys, then "System"
|
||||
And opening it again does not request them again
|
||||
|
||||
Scenario: Pagination — next page, page size and totals
|
||||
Given the Default state has loaded with total 1284
|
||||
When the user activates page "2" of the pager
|
||||
Then GET /v1/activity is sent with page=2 and size=25 and the pager reads "Showing 26–50 of
|
||||
1,284"
|
||||
When the user chooses "50" in "Rows per page"
|
||||
Then GET /v1/activity is sent with page=1 and size=50 and the pager reads "Showing 1–50 of
|
||||
1,284"
|
||||
And on the last page 52 of size 25 the pager reads "Showing 1,276–1,284 of 1,284" and next is
|
||||
disabled
|
||||
|
||||
Scenario: Mobile — the list is cards and the controls collapse into a sheet
|
||||
Given the Default state has loaded below md with the filters Category "Authentication" and
|
||||
Outcome "Failed" applied and GET /v1/activity resolves with total 6
|
||||
Then the page shows the search field and "Filters" with the badge "2", and no Person,
|
||||
Category, Outcome or date control
|
||||
And the table is replaced by cards, each with its actor, time, sentence and badges
|
||||
And the pager reads "Page 1 of 1" with "1–6 of 6"
|
||||
When the user activates "Filters"
|
||||
Then a bottom sheet titled "Filters" shows Person, Category, Outcome, Dates, "Clear all" and
|
||||
"Show 6 results"
|
||||
When the user chooses "Custom"
|
||||
Then two date fields appear in the sheet
|
||||
When the user activates "Show 6 results"
|
||||
Then the sheet closes and the list stays filtered
|
||||
|
||||
Scenario: Export — the file is prepared, then downloads
|
||||
Given the Default state has loaded with the filters Category "Authentication" and 30 days
|
||||
When the user activates "Export CSV"
|
||||
Then GET /v1/activity/export is sent with category=auth and range=30d and without page or size
|
||||
And while it is in flight the button reads "Preparing file…" and is disabled
|
||||
When it resolves 200 with a text/csv body
|
||||
Then the file is downloaded as "activity.csv" and a toast appears: color success, icon
|
||||
i-lucide-circle-check, title "Export started. Your file will download shortly."
|
||||
And the button returns to "Export CSV", enabled
|
||||
|
||||
Scenario Outline: Export — a failure raises an error toast and re-enables the button
|
||||
Given the Default state has loaded
|
||||
When the user activates "Export CSV" and GET /v1/activity/export <failure>
|
||||
Then a toast appears: color error, icon i-lucide-circle-x, title "Couldn't export activity.
|
||||
Try again."
|
||||
And nothing is downloaded and the button returns to "Export CSV", enabled
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| resolves with a 403 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Details — a row opens the slide-over and focus returns on close
|
||||
Given the Default state has loaded
|
||||
When the user activates the second row (click, or Enter with the row focused)
|
||||
Then the slide-over opens with the title "Activity details", the row is marked selected and
|
||||
the footer reads "Event 2 of 1,284"
|
||||
When the user presses Escape (or activates the close button, or the scrim)
|
||||
Then the slide-over closes and focus is on that row
|
||||
|
||||
Scenario: Details — the Edit with a diff event
|
||||
Given the event "Why we reuse original floorboards" has action "article.edit" with changes
|
||||
[{"field": "title", "kind": "text", "before": "Why we reuse floorboards", "after": "Why we
|
||||
reuse original floorboards"}, {"field": "body", "kind": "body", "delta": 312}]
|
||||
When its details open
|
||||
Then the sentence reads "Edited the article Why we reuse original floorboards"
|
||||
And the Changes section shows a "Title" row with Before "Why we reuse floorboards" and After
|
||||
"Why we reuse original floorboards", then a "Body" row with "Body edited (+312 characters)"
|
||||
And no body text appears anywhere in the panel
|
||||
|
||||
Scenario: Details — long values clamp to two lines with Show more
|
||||
Given an event with a text change whose Before and After are 170-character values, and
|
||||
another text change with short values
|
||||
When its details open and the long values overflow two lines
|
||||
Then the long row's values are clamped and "Show more" appears under that row only
|
||||
When the user activates "Show more"
|
||||
Then the values are fully shown and the link reads "Show less"
|
||||
|
||||
Scenario: Details — a failed action with a very long target title shows its reason
|
||||
Given an event with action "article.publish", outcome "failed", failure_reason "The article
|
||||
has no cover image. Add one, then publish again." and a 150-character target name
|
||||
When its details open
|
||||
Then the sentence wraps with the title in semibold, the "Failed" badge is shown and an error
|
||||
alert "Why it failed" reads the reason
|
||||
And the Target section wraps the long name
|
||||
|
||||
Scenario: Details — a deleted target has no diff
|
||||
Given an event of action "media.delete" with target {"type": "image", "id": "9", "name":
|
||||
"old-banner.png", "exists": false} and no changes
|
||||
When its details open
|
||||
Then the Target section shows "old-banner.png" struck through, a "Deleted" badge and "This
|
||||
item was deleted. Its name is kept for reference."
|
||||
And there is no Changes section
|
||||
|
||||
Scenario: Details — a system-initiated event has no human actor
|
||||
Given an event with actor {"type": "system", "id": null, "name": "System"}, source "system",
|
||||
ip null and user_agent null
|
||||
When its details open
|
||||
Then Actor shows the cog avatar, "System" and a "System" badge, Source reads "System task",
|
||||
and IP address and Browser read "Not applicable"
|
||||
|
||||
Scenario: Details — a failed login shows the actor as not signed in
|
||||
Given an event with action "auth.login_failed", actor {"type": "anonymous", "id": null,
|
||||
"name": "alex@example.com"}, security true, outcome "failed", failure_reason "Wrong username
|
||||
or password.", ip "198.51.100.77" and user_agent "Chrome 129 on Windows"
|
||||
When its details open
|
||||
Then the sentence reads "Failed login attempt for alex@example.com" with a "Security" badge
|
||||
and a "Failed" badge
|
||||
And Actor reads "Not signed in", the alert "Why it failed" shows the reason, and the IP
|
||||
address and the Browser are shown
|
||||
|
||||
Scenario: Details — the times are shown in local time and in UTC
|
||||
Given an event created_at "2026-10-06T10:42:18Z"
|
||||
When its details open
|
||||
Then the When line ends with "(your time)" and the muted line below reads "6 Oct 2026,
|
||||
10:42:18 UTC"
|
||||
|
||||
Scenario Outline: Details — Source reads the event's source
|
||||
Given an event with source "<source>"
|
||||
When its details open
|
||||
Then Source reads "<label>"
|
||||
|
||||
Examples:
|
||||
| source | label |
|
||||
| web | Web app |
|
||||
| api_key | API key |
|
||||
| system | System task |
|
||||
|
||||
Scenario: Details — Previous and Next move through the filtered list across pages
|
||||
Given the list is on page 1 of size 25 with total 1284 and the first row's details are open
|
||||
Then "Previous event" is disabled and the footer reads "Event 1 of 1,284"
|
||||
When the user activates "Next event" 24 times
|
||||
Then the footer reads "Event 25 of 1,284" with no extra request
|
||||
When the user activates "Next event" once more
|
||||
Then GET /v1/activity is sent with the same filters, page=2 and size=25
|
||||
And the panel shows the first item of page 2, the footer reads "Event 26 of 1,284" and the
|
||||
pager under the table reads "Showing 26–50 of 1,284"
|
||||
When the user activates "Previous event"
|
||||
Then GET /v1/activity is sent with page=1 and the panel shows the last item of page 1 as
|
||||
"Event 25 of 1,284"
|
||||
And on the last event "Next event" is disabled
|
||||
|
||||
Scenario: Details — a failed neighbour page keeps the panel on the current event
|
||||
Given the last item of page 1 is open
|
||||
When the user activates "Next event" and GET /v1/activity resolves 500
|
||||
Then the panel still shows the same event, the button is enabled again and the list is
|
||||
unchanged
|
||||
|
||||
Scenario: Details — below md the slide-over is a bottom sheet
|
||||
Given the Default state has loaded below md
|
||||
When the user activates the second card
|
||||
Then a bottom sheet opens, 88 % high, with a handle, the title "Activity details", "2 of
|
||||
1,284" in the header, and full-width "Previous event" and "Next event" buttons
|
||||
And there is no "Event 2 of 1,284" footer text
|
||||
|
||||
Scenario: My activity — a role that is not owner or admin sees its own events
|
||||
Given the session role is "editor" (or absent) and GET /v1/activity resolves 200 with 25
|
||||
items and total 41
|
||||
When the user navigates to /activity
|
||||
Then the title reads "My activity", the subtitle "Everything you've done on this site." and
|
||||
the breadcrumb "My activity"
|
||||
And no summary strip, no Person filter and no "Export CSV" are rendered
|
||||
And the table has the columns When, Action, Target, Outcome and no Actor column
|
||||
And the cards show no actor line below md
|
||||
And GET /v1/activity is sent with range=7d, page=1, size=25 and no actor parameter, and
|
||||
GET /v1/activity/summary is never sent
|
||||
And the pager reads "Showing 1–25 of 41"
|
||||
|
||||
Scenario: My activity, empty — the user has not done anything yet
|
||||
Given the session role is "editor" and GET /v1/activity resolves 200 with items [] and total 0
|
||||
When the user navigates to /activity
|
||||
Then an empty panel with icon history shows "No activity yet" and "Your actions will appear
|
||||
here once you start working on the site."
|
||||
|
||||
Scenario: My activity, details — the slide-over on one of the user's own events
|
||||
Given the session role is "editor" and the Default list has loaded
|
||||
When the first row's details open
|
||||
Then the footer reads "Event 1 of 41", "Previous event" is disabled and the panel is the same
|
||||
as for an owner, with the Actor row showing the user's own name
|
||||
|
||||
Scenario: The French interface composes sentences and chrome in French
|
||||
Given the language is French and the Default state has loaded as owner
|
||||
Then the title reads "Activité", the strip labels, filter placeholders, badges, pager and
|
||||
"Exporter en CSV" are in French, the sentences use the French templates and day
|
||||
separators read "Aujourd'hui", "Hier" or "3 oct. 2026"
|
||||
And the Security sub-line reads "11 signalés"
|
||||
And every new English string of this screen has a French entry in messages.ts
|
||||
|
||||
Scenario: Hover shows the exact time
|
||||
Given the Default state has loaded
|
||||
Then each When cell has a title attribute holding the full local time with seconds
|
||||
@@ -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