694 lines
53 KiB
Gherkin
694 lines
53 KiB
Gherkin
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
|