feat: activity screen in the SPA, route /activity

This commit is contained in:
fsylvain committed 2026-10-07 01:48:21 +02:00
1 parent e37a46a027
commit 35db442cd3
47 files changed
+7582 -11

No files matched your search

+693
View File
@@ -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
+257
View File
@@ -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.
+1
View File
@@ -5,6 +5,7 @@
"": {
"name": "renewcms-frontend",
"dependencies": {
"@internationalized/date": "^3.12.4",
"@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3",
+1
View File
@@ -16,6 +16,7 @@
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@internationalized/date": "^3.12.4",
"@nuxt/ui": "^4.11.2",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.31.3",
+13
View File
@@ -105,6 +105,18 @@
--text-xl--line-height: 28px;
--text-2xl: 1.75rem;
--text-2xl--line-height: 36px;
--text-3xs: 0.625rem;
--text-3xs--line-height: 16px;
--text-2xs: 0.6875rem;
--text-2xs--line-height: 20px;
--text-md: 0.9375rem;
--text-md--line-height: 22px;
--text-lead: 1.125rem;
--text-lead--line-height: 26px;
--text-stat-sm: 1.375rem;
--text-stat-sm--line-height: 32px;
--text-stat: 1.5rem;
--text-stat--line-height: 32px;
--text-code: 0.75rem;
--text-code--line-height: 19px;
@@ -116,6 +128,7 @@
--shadow-popover: 0 4px 16px rgb(17 17 17 / 0.08), 0 1px 2px rgb(17 17 17 / 0.06);
--shadow-modal: 0 16px 48px rgb(17 17 17 / 0.18);
--shadow-flag: inset 2px 0 0 var(--ui-warning);
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-enter: cubic-bezier(0, 0, 0, 1);
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import { initials, type ActivityActor } from '@/lib/activity'
const props = defineProps<{
actor: ActivityActor
}>()
const displayName = computed(() =>
props.actor.type === 'anonymous' ? t('Not signed in') : props.actor.name,
)
const iconName = computed(() => {
switch (props.actor.type) {
case 'api_key':
return 'i-lucide-key-round'
case 'system':
return 'i-lucide-cog'
default:
return 'i-lucide-user-round-x'
}
})
</script>
<template>
<span class="flex min-w-0 items-center gap-2" data-testid="activity-actor">
<UAvatar
v-if="actor.type === 'user'"
aria-hidden="true"
:text="initials(actor.name)"
:ui="{
root: 'size-6 shrink-0 bg-(--ui-color-secondary-200) dark:bg-(--ui-color-secondary-300)',
fallback: 'pt-px text-3xs font-semibold text-(--ui-color-primary-900)',
}"
/>
<UAvatar
v-else
aria-hidden="true"
:icon="iconName"
:ui="{
root: 'size-6 shrink-0 border border-accented bg-elevated',
icon: 'size-3.5 text-toned',
}"
/>
<span class="min-w-0 truncate font-medium text-highlighted">{{ displayName }}</span>
<ActivityBadge v-if="actor.type === 'api_key'" tone="neutral" :label="t('API key')" />
<ActivityBadge v-else-if="actor.type === 'system'" tone="neutral" :label="t('System')" />
</span>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
export type ActivityBadgeTone = 'ok' | 'fail' | 'security' | 'deleted' | 'neutral'
const TONES: Record<ActivityBadgeTone, { class: string; icon?: string }> = {
ok: { class: 'border border-accented bg-transparent text-toned', icon: 'i-lucide-check' },
fail: { class: 'bg-(--rc-error-soft) text-error', icon: 'i-lucide-circle-x' },
security: { class: 'bg-(--rc-warning-soft) text-warning', icon: 'i-lucide-shield-alert' },
deleted: {
class: 'border border-accented bg-transparent text-toned',
icon: 'i-lucide-trash-2',
},
neutral: { class: 'border border-accented bg-transparent text-toned' },
}
const props = defineProps<{
tone: ActivityBadgeTone
label: string
}>()
const config = computed(() => TONES[props.tone])
</script>
<template>
<UBadge
color="neutral"
variant="soft"
:label="label"
:icon="config.icon"
:class="config.class"
:ui="{ base: 'h-5 shrink-0 gap-1 px-1.5 whitespace-nowrap', leadingIcon: 'size-3' }"
/>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import { ACTIVITY_SKELETON_CARDS } from '@/composables/useActivity'
import { categoryIcon, outcomeBadge, rowSurface, type ActivityItem } from '@/lib/activity'
import { fullLocalTime, groupByDay, whenLabel } from '@/lib/activityTime'
const SKELETON_SENTENCE_WIDTHS = ['w-9/10', 'w-3/4', 'w-17/20']
const props = defineProps<{
items: ActivityItem[]
loading: boolean
selectedId: number | null
showActor: boolean
}>()
const emit = defineEmits<{
open: [item: ActivityItem, opener: HTMLElement]
}>()
const groups = computed(() => groupByDay(props.items))
function open(item: ActivityItem, event: Event) {
emit('open', item, event.currentTarget as HTMLElement)
}
function cardClass(item: ActivityItem) {
return rowSurface(props.selectedId === item.id, item.security)
}
</script>
<template>
<div
:aria-busy="loading ? 'true' : undefined"
class="flex flex-col overflow-hidden border border-default bg-default"
>
<template v-if="loading">
<span role="status" class="sr-only">{{ t('Loading activity') }}</span>
<div
v-for="n in ACTIVITY_SKELETON_CARDS"
:key="n"
class="flex flex-col gap-2.5 border-t border-default px-4 py-3.5"
data-testid="activity-skeleton-card"
>
<div class="flex items-center gap-2">
<USkeleton class="size-6 shrink-0 rounded-none bg-accented" />
<USkeleton class="h-3 w-22.5 rounded-none bg-accented" />
<span class="flex-1" />
<USkeleton class="h-2.5 w-10 rounded-none bg-accented" />
</div>
<USkeleton
class="h-3.5 rounded-none bg-accented"
:class="SKELETON_SENTENCE_WIDTHS[(n - 1) % 3]"
/>
<USkeleton class="h-5 w-17.5 rounded-none bg-accented" />
</div>
</template>
<template v-else>
<template v-for="group in groups" :key="group.key">
<div
data-testid="activity-day"
class="flex h-8 shrink-0 items-center border-t border-default bg-muted px-4 text-xs font-semibold text-highlighted"
>
{{ group.label }}
</div>
<div
v-for="item in group.items"
:key="item.id"
tabindex="0"
role="button"
data-testid="activity-card"
:data-activity-id="item.id"
:aria-pressed="selectedId === item.id"
:class="cardClass(item)"
class="flex min-h-11 cursor-pointer flex-col gap-2 border-t border-default px-4 py-3 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
@click="open(item, $event)"
@keydown.enter.self.prevent="open(item, $event)"
>
<div class="flex min-w-0 items-center gap-2">
<ActivityActor v-if="showActor" :actor="item.actor" class="flex-1" />
<span v-else class="flex-1" />
<span
data-testid="activity-when"
:title="fullLocalTime(item.created_at)"
class="text-xs/5 whitespace-nowrap text-muted"
>{{ whenLabel(item.created_at) }}</span
>
</div>
<div class="flex items-start gap-2">
<UIcon
:name="categoryIcon(item.category)"
class="mt-0.5 size-4 shrink-0 text-muted"
/>
<span
class="line-clamp-3 min-w-0 flex-1 text-md wrap-anywhere text-default"
>
<ActivitySentence :item="item" />
</span>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<ActivityBadge v-bind="outcomeBadge(item.outcome)" />
<ActivityBadge
v-if="item.security"
tone="security"
:label="t('Security')"
/>
<ActivityBadge
v-if="item.target && !item.target.exists"
tone="deleted"
:label="t('Target deleted')"
/>
</div>
</div>
</template>
</template>
</div>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { ACTIVITY_CATEGORIES, categoryLabel, type ActivityCategory } from '@/lib/activity'
defineProps<{
category: ActivityCategory | ''
}>()
const emit = defineEmits<{
select: [category: ActivityCategory]
}>()
const items = computed(() =>
ACTIVITY_CATEGORIES.map((category) => ({
label: categoryLabel(category),
value: category,
})),
)
</script>
<template>
<USelectMenu
:model-value="category || undefined"
:items="items"
value-key="value"
icon="i-lucide-layout-list"
:placeholder="t('All categories')"
:search-input="false"
:aria-label="t('Category')"
color="neutral"
variant="outline"
class="w-full md:w-44"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default ps-8 pe-8 text-sm ring-0 md:h-8',
leadingIcon: 'size-3.5 text-muted',
trailingIcon: 'size-3.5 text-muted',
placeholder: 'text-muted',
content: 'min-w-48',
}"
@update:model-value="emit('select', $event as ActivityCategory)"
/>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { t } from '@/i18n'
import type { ActivityChip, ChipKey } from '@/lib/activity'
defineProps<{
chips: ActivityChip[]
}>()
const emit = defineEmits<{
remove: [key: ChipKey]
clear: []
}>()
</script>
<template>
<div
v-if="chips.length > 0"
data-testid="activity-chips"
class="flex flex-wrap items-center gap-2"
>
<UBadge
v-for="chip in chips"
:key="chip.key"
color="neutral"
variant="subtle"
data-testid="activity-chip"
:ui="{
base: 'h-8 gap-1 border border-default bg-elevated py-0 ps-2 pe-1 text-xs font-medium text-highlighted ring-0 md:h-6',
}"
>
{{ chip.label }}
<template #trailing>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-x"
:aria-label="t('Remove filter {0}', [chip.label])"
class="size-5 justify-center p-0 text-muted"
:ui="{ leadingIcon: 'size-3' }"
@click="emit('remove', chip.key)"
/>
</template>
</UBadge>
<UButton
color="neutral"
variant="link"
class="h-11 px-1.5 text-xs font-medium text-highlighted underline underline-offset-3 md:h-6"
@click="emit('clear')"
>
{{ t('Clear all') }}
</UButton>
</div>
</template>
+164
View File
@@ -0,0 +1,164 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watch } from 'vue'
import { CalendarDate, parseDate } from '@internationalized/date'
import { RANGE_KEYS, rangeLabel, type RangeKey } from '@/lib/activity'
import { SEGMENT_GROUP, segmentClass } from '@/lib/segmentStyles'
interface RangeChange {
range: RangeKey
from: string
to: string
}
interface CalendarRange {
start?: CalendarDate
end?: CalendarDate
}
const props = defineProps<{
range: RangeKey
from: string
to: string
block?: boolean
}>()
const emit = defineEmits<{
change: [value: RangeChange]
}>()
const popoverOpen = ref(false)
const customPending = ref(false)
const draftFrom = ref(props.from)
const draftTo = ref(props.to)
const presets = RANGE_KEYS.filter((key) => key !== 'custom')
const customSelected = computed(
() => props.range === 'custom' || popoverOpen.value || customPending.value,
)
const showFields = computed(() => props.block && customSelected.value)
function parse(value: string) {
try {
return parseDate(value)
} catch {
return undefined
}
}
const calendarValue = computed<CalendarRange>(() => ({
start: parse(props.from),
end: parse(props.to),
}))
watch(
() => [props.from, props.to],
([from, to]) => {
draftFrom.value = from ?? ''
draftTo.value = to ?? ''
},
)
function choose(range: RangeKey) {
customPending.value = false
emit('change', { range, from: '', to: '' })
}
function chooseCustom() {
if (props.block) customPending.value = true
}
function applyCustom(from: string, to: string) {
customPending.value = false
emit(
'change',
from <= to ? { range: 'custom', from, to } : { range: 'custom', from: to, to: from },
)
}
function onCalendar(value: CalendarRange | null) {
if (!value?.start || !value.end) return
popoverOpen.value = false
applyCustom(value.start.toString(), value.end.toString())
}
function onField(side: 'from' | 'to', value: string | number | null | undefined) {
const next = typeof value === 'string' ? value : ''
if (side === 'from') draftFrom.value = next
else draftTo.value = next
if (draftFrom.value && draftTo.value) applyCustom(draftFrom.value, draftTo.value)
}
</script>
<template>
<div class="flex flex-col gap-2" :class="block ? 'w-full' : ''">
<div role="group" :aria-label="t('Dates')" :class="[SEGMENT_GROUP, block ? 'w-full' : '']">
<UButton
v-for="key in presets"
:key="key"
color="neutral"
:variant="range === key && !customSelected ? 'soft' : 'outline'"
:aria-pressed="range === key && !customSelected"
:class="segmentClass(range === key && !customSelected, block)"
@click="choose(key)"
>
{{ rangeLabel(key) }}
</UButton>
<UButton
v-if="block"
color="neutral"
:variant="customSelected ? 'soft' : 'outline'"
icon="i-lucide-calendar"
:aria-pressed="customSelected"
:class="segmentClass(customSelected, block)"
:ui="{ leadingIcon: 'size-3.5' }"
@click="chooseCustom"
>
{{ rangeLabel('custom') }}
</UButton>
<UPopover v-else v-model:open="popoverOpen" :content="{ align: 'end' }">
<UButton
color="neutral"
:variant="customSelected ? 'soft' : 'outline'"
icon="i-lucide-calendar"
:aria-pressed="customSelected"
:class="segmentClass(customSelected)"
:ui="{ leadingIcon: 'size-3.5' }"
>
{{ rangeLabel('custom') }}
</UButton>
<template #content>
<UCalendar
range
:model-value="calendarValue"
:number-of-months="1"
class="p-2"
@update:model-value="onCalendar($event as CalendarRange | null)"
/>
</template>
</UPopover>
</div>
<div v-if="showFields" class="flex items-center gap-2">
<UInput
type="date"
icon="i-lucide-calendar"
:model-value="draftFrom"
:aria-label="t('Start date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg' }"
@update:model-value="onField('from', $event)"
/>
<UInput
type="date"
icon="i-lucide-calendar"
:model-value="draftTo"
:aria-label="t('End date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg' }"
@update:model-value="onField('to', $event)"
/>
</div>
</div>
</template>
+224
View File
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed, ref, watch } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivityDiff from '@/components/ActivityDiff.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import {
categoryIcon,
categoryLabel,
outcomeBadge,
sourceLabel,
targetLink,
targetTypeLabel,
type ActivityItem,
} from '@/lib/activity'
import { fullLocalTime, fullUtcTime } from '@/lib/activityTime'
const props = defineProps<{
item: ActivityItem | null
position: number
total: number
canPrevious: boolean
canNext: boolean
isDesktop: boolean
}>()
const emit = defineEmits<{
previous: []
next: []
close: []
}>()
const retained = ref<ActivityItem | null>(props.item)
watch(
() => props.item,
(value) => {
if (value) retained.value = value
},
)
const current = computed(() => props.item ?? retained.value)
const link = computed(() => targetLink(current.value?.target ?? null))
const side = computed(() => (props.isDesktop ? 'right' : 'bottom'))
const positionOf = computed(() =>
t('{0} of {1}', [formatNumber(props.position), formatNumber(props.total)]),
)
const contentClass = computed(() =>
props.isDesktop
? 'w-full max-w-120'
: 'h-[88%] max-h-none before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented) pt-3',
)
const closeButton = computed(() => ({
color: 'neutral' as const,
variant: 'ghost' as const,
icon: 'i-lucide-x',
class: 'text-muted',
}))
function onOpenChange(isOpen: boolean) {
if (!isOpen) emit('close')
}
</script>
<template>
<USlideover
:open="item !== null"
:side="side"
:title="t('Activity details')"
:description="t('Activity details')"
:close="closeButton"
:ui="{
content: contentClass,
header: 'h-14 min-h-14 border-b border-default px-4 py-0 sm:px-4 md:px-5',
wrapper: 'min-w-0 flex-1 pe-10 md:pe-8',
title: 'flex items-baseline justify-between gap-2 text-lg font-semibold text-highlighted',
description: 'sr-only',
close: 'top-4.5 end-2 size-11 justify-center p-0 md:top-3.5 md:end-5 md:size-7',
body: 'flex-1 overflow-y-auto p-4 sm:p-4 md:p-5',
footer: 'flex gap-2 border-t border-default px-4 py-3 sm:px-4 md:px-5',
}"
@update:open="onOpenChange"
>
<template #title>
<span>{{ t('Activity details') }}</span>
<span v-if="!isDesktop" class="text-xs font-normal text-muted">{{ positionOf }}</span>
</template>
<template #body>
<div v-if="current" data-testid="activity-details" class="flex flex-col gap-5">
<div class="flex flex-col gap-2.5">
<p
data-testid="activity-details-sentence"
class="text-lead font-semibold wrap-anywhere text-highlighted"
>
<ActivitySentence :item="current" />
</p>
<div class="flex flex-wrap items-center gap-1.5">
<ActivityBadge v-bind="outcomeBadge(current.outcome)" />
<ActivityBadge
v-if="current.security"
tone="security"
:label="t('Security')"
/>
<span class="inline-flex items-center gap-1 text-xs text-muted">
<UIcon :name="categoryIcon(current.category)" class="size-3.5" />
{{ categoryLabel(current.category) }}
</span>
</div>
</div>
<UAlert
v-if="current.outcome === 'failed' && current.failure_reason"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:title="t('Why it failed')"
:description="current.failure_reason"
/>
<dl class="grid grid-cols-[96px_minmax(0,1fr)] items-start gap-x-3 gap-y-2.5">
<dt class="text-muted">{{ t('When') }}</dt>
<dd class="m-0 flex flex-col gap-0.5">
<span class="text-highlighted">{{
t('{0} (your time)', [fullLocalTime(current.created_at)])
}}</span>
<span class="text-xs/5 text-muted">{{
t('{0} UTC', [fullUtcTime(current.created_at)])
}}</span>
</dd>
<dt class="text-muted">{{ t('Actor') }}</dt>
<dd class="m-0 min-w-0"><ActivityActor :actor="current.actor" /></dd>
<dt class="text-muted">{{ t('Source') }}</dt>
<dd class="m-0 text-highlighted">{{ sourceLabel(current.source) }}</dd>
<dt class="text-muted">{{ t('IP address') }}</dt>
<dd v-if="current.ip" class="m-0 font-mono text-xs/5 text-highlighted">
{{ current.ip }}
</dd>
<dd v-else class="m-0 text-muted">{{ t('Not applicable') }}</dd>
<dt class="text-muted">{{ t('Browser') }}</dt>
<dd v-if="current.user_agent" class="m-0 text-highlighted">
{{ current.user_agent }}
</dd>
<dd v-else class="m-0 text-muted">{{ t('Not applicable') }}</dd>
</dl>
<section
v-if="current.target"
class="flex flex-col gap-2"
data-testid="activity-target-section"
>
<h3 class="text-base font-semibold text-highlighted">{{ t('Target') }}</h3>
<div class="flex flex-col gap-1.5 border border-default px-3 py-2.5">
<div class="flex min-w-0 items-center gap-2">
<template v-if="!current.target.exists">
<span
class="min-w-0 flex-1 wrap-anywhere text-muted line-through"
>{{ current.target.name }}</span
>
<ActivityBadge tone="deleted" :label="t('Deleted')" />
</template>
<RouterLink
v-else-if="link"
:to="link"
class="flex min-w-0 flex-1 items-center gap-1 wrap-anywhere text-highlighted underline underline-offset-3"
>
{{ current.target.name }}
<UIcon
name="i-lucide-arrow-up-right"
class="size-3.5 shrink-0 text-muted"
/>
</RouterLink>
<span v-else class="min-w-0 flex-1 wrap-anywhere text-highlighted">{{
current.target.name
}}</span>
</div>
<span class="text-xs/5 text-muted">{{
`${targetTypeLabel(current.target.type)} · ${current.target.id}`
}}</span>
<span v-if="!current.target.exists" class="text-xs/5 text-toned">{{
t('This item was deleted. Its name is kept for reference.')
}}</span>
</div>
</section>
<section v-if="current.changes.length > 0" class="flex flex-col gap-2">
<h3 class="text-base font-semibold text-highlighted">{{ t('Changes') }}</h3>
<ActivityDiff :changes="current.changes" :is-desktop="isDesktop" />
</section>
</div>
</template>
<template #footer>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-up"
:disabled="!canPrevious"
class="h-11 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:flex-1 md:h-8"
@click="emit('previous')"
>
{{ t('Previous event') }}
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-down"
:disabled="!canNext"
class="h-11 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:flex-1 md:h-8"
@click="emit('next')"
>
{{ t('Next event') }}
</UButton>
<span v-if="isDesktop" class="ml-auto self-center text-xs text-muted">{{
t('Event {0} of {1}', [formatNumber(position), formatNumber(total)])
}}</span>
</template>
</USlideover>
</template>
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { bodyDeltaLabel, changeValue, fieldLabel, type ActivityChange } from '@/lib/activity'
const props = defineProps<{
changes: ActivityChange[]
isDesktop: boolean
}>()
const expanded = ref<Record<number, boolean>>({})
const overflowing = ref<Record<number, boolean>>({})
const valueBoxes = new Map<number, HTMLElement[]>()
function register(index: number, element: unknown) {
if (!(element instanceof HTMLElement)) return
const boxes = valueBoxes.get(index) ?? []
if (!boxes.includes(element)) valueBoxes.set(index, [...boxes, element])
}
function measure() {
const next: Record<number, boolean> = { ...overflowing.value }
for (const [index, boxes] of valueBoxes) {
if (expanded.value[index]) continue
next[index] = boxes.some((box) => box.scrollHeight > box.clientHeight)
}
overflowing.value = next
}
function toggle(index: number) {
expanded.value = { ...expanded.value, [index]: !expanded.value[index] }
}
watch(
() => props.changes,
() => {
expanded.value = {}
overflowing.value = {}
valueBoxes.clear()
nextTick(measure)
},
)
onMounted(() => {
nextTick(measure)
window.addEventListener('resize', measure)
})
onBeforeUnmount(() => window.removeEventListener('resize', measure))
</script>
<template>
<div data-testid="activity-diff" class="flex flex-col border border-default">
<div
v-for="(change, index) in changes"
:key="index"
data-testid="activity-change"
class="flex flex-col gap-2 border-t border-muted p-3 first:border-t-0"
>
<span class="font-medium text-highlighted">{{ fieldLabel(change.field) }}</span>
<span
v-if="change.kind === 'body'"
data-testid="activity-body-change"
class="flex items-center gap-1.5 text-toned"
>
<UIcon name="i-lucide-file-pen-line" class="size-3.5 shrink-0" />
{{ bodyDeltaLabel(change.delta) }}
</span>
<template v-else>
<div
class="grid items-start gap-1.5 md:grid-cols-[minmax(0,1fr)_16px_minmax(0,1fr)] md:gap-2"
>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-2xs text-muted">{{ t('Before') }}</span>
<span
:ref="(element) => register(index, element)"
data-testid="activity-before"
class="border border-default bg-elevated px-2.5 py-2 wrap-anywhere text-toned"
:class="expanded[index] ? '' : 'line-clamp-2'"
>{{ changeValue(change.field, change.before) }}</span
>
</div>
<UIcon
:name="isDesktop ? 'i-lucide-arrow-right' : 'i-lucide-arrow-down'"
class="size-3.5 shrink-0 justify-self-center text-muted md:mt-7.5"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="text-2xs text-muted">{{ t('After') }}</span>
<span
:ref="(element) => register(index, element)"
data-testid="activity-after"
class="border border-accented bg-default px-2.5 py-2 wrap-anywhere text-highlighted"
:class="expanded[index] ? '' : 'line-clamp-2'"
>{{ changeValue(change.field, change.after) }}</span
>
</div>
</div>
<UButton
v-if="overflowing[index]"
color="neutral"
variant="link"
class="min-h-8 self-start px-0 text-xs font-medium text-highlighted underline underline-offset-3 md:min-h-4"
@click="toggle(index)"
>
{{ expanded[index] ? t('Show less') : t('Show more') }}
</UButton>
</template>
</div>
</div>
</template>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
defineProps<{
icon: string
title: string
description: string
actionLabel?: string
}>()
const emit = defineEmits<{ action: [] }>()
</script>
<template>
<UEmpty
variant="naked"
:icon="icon"
:title="title"
:description="description"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14 sm:px-6 sm:py-14 lg:px-6 lg:py-14',
header: 'gap-3',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md/5 font-semibold',
description: 'max-w-85 text-sm/5 text-muted text-pretty',
}"
>
<template v-if="actionLabel" #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-sm text-highlighted ring-0 md:h-8"
@click="emit('action')"
>
{{ actionLabel }}
</UButton>
</template>
</UEmpty>
</template>
+174
View File
@@ -0,0 +1,174 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import ActivityDatePresets from '@/components/ActivityDatePresets.vue'
import ActivityPersonSelect from '@/components/ActivityPersonSelect.vue'
import ActivitySegmented from '@/components/ActivitySegmented.vue'
import type { PersonOptions } from '@/composables/useActivity'
import { outcomeOptions, type ActivityFilters, type OutcomeFilter } from '@/lib/activity'
const SEARCH_DEBOUNCE_MS = 300
const props = defineProps<{
filters: ActivityFilters
people: PersonOptions | null
canFilterPeople: boolean
isDesktop: boolean
chipCount: number
}>()
const emit = defineEmits<{
'update:filters': [filters: ActivityFilters]
'open-people': []
'open-sheet': []
}>()
const searchText = ref(props.filters.q)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const searchIconClass = computed(
() => `size-3.5 ${searchText.value ? 'text-highlighted' : 'text-muted'}`,
)
const outcomeChoices = computed(() => outcomeOptions())
function clearDebounce() {
if (debounceTimer !== null) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}
function applySearch() {
clearDebounce()
const trimmed = searchText.value.trim()
if (trimmed !== props.filters.q) emit('update:filters', { ...props.filters, q: trimmed })
}
watch(
() => props.filters.q,
(value) => {
if (value !== searchText.value.trim()) {
clearDebounce()
searchText.value = value
}
},
)
watch(searchText, () => {
clearDebounce()
debounceTimer = setTimeout(applySearch, SEARCH_DEBOUNCE_MS)
})
function clearSearch() {
searchText.value = ''
applySearch()
}
function onActor(actor: string, label: string) {
emit('update:filters', { ...props.filters, actor, actorLabel: label })
}
function onOutcome(outcome: OutcomeFilter) {
emit('update:filters', { ...props.filters, outcome })
}
onBeforeUnmount(clearDebounce)
</script>
<template>
<div
v-if="isDesktop"
data-testid="activity-filter-bar"
class="flex flex-wrap items-center gap-2"
>
<UInput
v-model="searchText"
type="text"
icon="i-lucide-search"
:placeholder="t('Search activity')"
:aria-label="t('Search activity')"
class="w-60"
:ui="{ base: 'h-8 ps-8 text-sm', leadingIcon: searchIconClass }"
@keydown.enter="applySearch"
>
<template v-if="searchText" #trailing>
<UButton
color="neutral"
variant="link"
size="xs"
icon="i-lucide-x"
:aria-label="t('Clear search')"
class="text-muted"
@click="clearSearch"
/>
</template>
</UInput>
<ActivityPersonSelect
v-if="canFilterPeople"
:actor="filters.actor"
:people="people"
@select="onActor"
@open="emit('open-people')"
/>
<ActivityCategorySelect
:category="filters.category"
@select="emit('update:filters', { ...filters, category: $event })"
/>
<ActivitySegmented
:options="outcomeChoices"
:model-value="filters.outcome"
:label="t('Outcome')"
@update:model-value="onOutcome"
/>
<div class="min-w-0 flex-1" />
<ActivityDatePresets
:range="filters.range"
:from="filters.from"
:to="filters.to"
@change="emit('update:filters', { ...filters, ...$event })"
/>
</div>
<div v-else data-testid="activity-filter-bar" class="flex gap-2">
<UInput
v-model="searchText"
type="text"
icon="i-lucide-search"
:placeholder="t('Search activity')"
:aria-label="t('Search activity')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 ps-8 text-lg', leadingIcon: searchIconClass }"
@keydown.enter="applySearch"
>
<template v-if="searchText" #trailing>
<UButton
color="neutral"
variant="link"
size="xs"
icon="i-lucide-x"
:aria-label="t('Clear search')"
class="text-muted"
@click="clearSearch"
/>
</template>
</UInput>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-sliders-horizontal"
class="h-11 shrink-0 border border-accented bg-default px-3 text-sm text-highlighted ring-0"
@click="emit('open-sheet')"
>
{{ t('Filters') }}
<span
v-if="chipCount > 0"
data-testid="activity-filter-count"
class="inline-flex h-4.5 min-w-4.5 items-center justify-center bg-primary px-1.25 font-mono text-2xs font-medium text-(--ui-bg)"
>{{ chipCount }}</span
>
</UButton>
</div>
</template>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import ActivityDatePresets from '@/components/ActivityDatePresets.vue'
import ActivityPersonSelect from '@/components/ActivityPersonSelect.vue'
import ActivitySegmented from '@/components/ActivitySegmented.vue'
import type { PersonOptions } from '@/composables/useActivity'
import { outcomeOptions, type ActivityFilters, type OutcomeFilter } from '@/lib/activity'
const props = defineProps<{
filters: ActivityFilters
people: PersonOptions | null
canFilterPeople: boolean
chipCount: number
total: number
}>()
const emit = defineEmits<{
'update:filters': [filters: ActivityFilters]
'open-people': []
clear: []
}>()
const open = defineModel<boolean>('open', { default: false })
const outcomeChoices = computed(() => outcomeOptions())
const showLabel = computed(() =>
props.total === 1 ? t('Show 1 result') : t('Show {0} results', [formatNumber(props.total)]),
)
function onActor(actor: string, label: string) {
emit('update:filters', { ...props.filters, actor, actorLabel: label })
}
function onOutcome(outcome: OutcomeFilter) {
emit('update:filters', { ...props.filters, outcome })
}
</script>
<template>
<USlideover
v-model:open="open"
side="bottom"
:title="t('Filters')"
:description="t('Filters')"
:ui="{
content:
'max-h-none before:absolute before:top-2 before:left-1/2 before:h-1 before:w-9 before:-translate-x-1/2 before:bg-(--ui-border-accented) pt-3',
header: 'h-14 min-h-14 border-b border-default px-4 py-0 sm:px-4',
title: 'text-lg font-semibold text-highlighted',
description: 'sr-only',
close: 'top-4.5 end-2 size-11 justify-center p-0',
body: 'p-4 sm:p-4',
footer: 'gap-2 border-t border-default px-4 pt-3 pb-4 sm:px-4',
}"
>
<template #body>
<div data-testid="activity-filters-sheet" class="flex flex-col gap-4">
<div v-if="canFilterPeople" class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Person') }}</span>
<ActivityPersonSelect
:actor="filters.actor"
:people="people"
@select="onActor"
@open="emit('open-people')"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Category') }}</span>
<ActivityCategorySelect
:category="filters.category"
@select="emit('update:filters', { ...filters, category: $event })"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Outcome') }}</span>
<ActivitySegmented
block
:options="outcomeChoices"
:model-value="filters.outcome"
:label="t('Outcome')"
@update:model-value="onOutcome"
/>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('Dates') }}</span>
<ActivityDatePresets
block
:range="filters.range"
:from="filters.from"
:to="filters.to"
@change="emit('update:filters', { ...filters, ...$event })"
/>
</div>
</div>
</template>
<template #footer>
<UButton
color="neutral"
variant="outline"
:disabled="chipCount === 0"
class="h-11 flex-1 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0"
@click="emit('clear')"
>
{{ t('Clear all') }}
</UButton>
<UButton class="h-11 flex-1 justify-center px-3 text-sm" @click="open = false">
{{ showLabel }}
</UButton>
</template>
</USlideover>
</template>
+118
View File
@@ -0,0 +1,118 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import { PAGE_SIZES } from '@/lib/activity'
const props = defineProps<{
page: number
pageCount: number
total: number
size: number
from: number
to: number
isDesktop: boolean
}>()
const emit = defineEmits<{
'update:page': [page: number]
'update:size': [size: number]
}>()
const sizeItems = PAGE_SIZES.map((value) => ({ label: String(value), value }))
const range = computed(() => ({
from: formatNumber(props.from),
to: formatNumber(props.to),
total: formatNumber(props.total),
}))
const PAGER_BUTTON =
'size-11 justify-center border border-accented bg-default p-0 text-highlighted ring-0 md:size-8'
</script>
<template>
<div v-if="isDesktop" data-testid="activity-pager" class="flex shrink-0 items-center gap-4">
<span class="text-muted">{{
t('Showing {0}–{1} of {2}', [range.from, range.to, range.total])
}}</span>
<div class="flex-1" />
<div class="flex items-center gap-2 text-muted">
<span>{{ t('Rows per page') }}</span>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
:aria-label="t('Rows per page')"
color="neutral"
variant="outline"
class="w-20"
:ui="{
base: 'h-8 rounded-none border border-accented bg-default px-2.5 text-sm ring-0',
trailingIcon: 'size-3.5 text-muted',
}"
@update:model-value="emit('update:size', $event as number)"
/>
</div>
<UPagination
:page="page"
:total="total"
:items-per-page="size"
:sibling-count="1"
show-edges
color="neutral"
variant="outline"
active-color="primary"
active-variant="solid"
:ui="{
item: 'size-8 justify-center border border-accented ring-0',
prev: 'size-8 justify-center border border-accented ring-0',
next: 'size-8 justify-center border border-accented ring-0',
}"
@update:page="emit('update:page', $event)"
/>
</div>
<div v-else data-testid="activity-pager" class="flex shrink-0 items-center gap-2">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-left"
:disabled="page <= 1"
:aria-label="t('Previous page')"
:class="PAGER_BUTTON"
@click="emit('update:page', page - 1)"
/>
<div class="flex flex-1 flex-col items-center text-xs text-muted">
<span class="font-medium text-highlighted">{{
t('Page {0} of {1}', [formatNumber(page), formatNumber(pageCount)])
}}</span>
<span>{{ t('{0}–{1} of {2}', [range.from, range.to, range.total]) }}</span>
</div>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
:aria-label="t('Rows per page')"
color="neutral"
variant="outline"
class="w-24 shrink-0"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-2.5 text-sm ring-0',
trailingIcon: 'size-3.5 text-muted',
}"
@update:model-value="emit('update:size', $event as number)"
/>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-right"
:disabled="page >= pageCount"
:aria-label="t('Next page')"
:class="PAGER_BUTTON"
@click="emit('update:page', page + 1)"
/>
</div>
</template>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { PersonOptions } from '@/composables/useActivity'
const props = defineProps<{
actor: string
people: PersonOptions | null
}>()
const emit = defineEmits<{
select: [actor: string, label: string]
open: []
}>()
const groups = computed(() => {
const users = (props.people?.users ?? []).map((user) => ({
label: user.name,
value: `user:${user.id}`,
}))
const keys = (props.people?.keys ?? []).map((key) => ({
label: key.name,
value: `key:${key.id}`,
}))
return [
[{ type: 'label' as const, label: t('Users') }, ...users],
[{ type: 'label' as const, label: t('API keys') }, ...keys],
[{ label: t('System'), value: 'system' }],
]
})
function labelFor(value: string) {
if (value === 'system') return t('System')
const options = value.startsWith('user:') ? props.people?.users : props.people?.keys
const id = Number(value.slice(value.indexOf(':') + 1))
return options?.find((option) => option.id === id)?.name ?? value
}
function onSelect(value: string | undefined) {
if (value) emit('select', value, labelFor(value))
}
</script>
<template>
<USelectMenu
:model-value="actor || undefined"
:items="groups"
value-key="value"
icon="i-lucide-user-round"
:placeholder="t('All people')"
:search-input="false"
:aria-label="t('Person')"
color="neutral"
variant="outline"
class="w-full md:w-42"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default ps-8 pe-8 text-sm ring-0 md:h-8',
leadingIcon: 'size-3.5 text-muted',
trailingIcon: 'size-3.5 text-muted',
placeholder: 'text-muted',
content: 'min-w-48',
}"
@update:open="(isOpen: boolean) => isOpen && emit('open')"
@update:model-value="onSelect($event as string | undefined)"
/>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts" generic="V extends string">
import { SEGMENT_GROUP, segmentClass } from '@/lib/segmentStyles'
defineProps<{
options: { value: V; label: string }[]
modelValue: V
label: string
block?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: V]
}>()
</script>
<template>
<div role="group" :aria-label="label" :class="[SEGMENT_GROUP, block ? 'w-full' : '']">
<UButton
v-for="option in options"
:key="option.value"
color="neutral"
:variant="option.value === modelValue ? 'soft' : 'outline'"
:aria-pressed="option.value === modelValue"
:class="segmentClass(option.value === modelValue, block)"
@click="emit('update:modelValue', option.value)"
>
{{ option.label }}
</UButton>
</div>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ActivityItem } from '@/lib/activity'
import { composeSentence } from '@/lib/activitySentence'
const props = defineProps<{
item: ActivityItem
}>()
const segments = computed(() => composeSentence(props.item))
</script>
<template>
<span data-testid="activity-sentence"
><template v-for="(segment, index) in segments" :key="index"
><span v-if="segment.strong" class="font-semibold text-highlighted">{{
segment.text
}}</span
><template v-else>{{ segment.text }}</template></template
></span
>
</template>
+141
View File
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ActivityFilters, ActivitySummary } from '@/lib/activity'
import {
barHeights,
STRIP_KEYS,
stripCards,
type StripCard,
type StripKey,
} from '@/lib/activitySummary'
const DESKTOP_BAR_HEIGHT = 20
const MOBILE_BAR_HEIGHT = 16
const props = defineProps<{
summary: ActivitySummary | null
range: ActivityFilters['range']
loading: boolean
isDesktop: boolean
outcome: ActivityFilters['outcome']
security: boolean
}>()
const emit = defineEmits<{
'apply-filter': [key: StripKey]
}>()
const cards = computed(() => (props.summary ? stripCards(props.summary, props.range) : []))
const barHeight = computed(() => (props.isDesktop ? DESKTOP_BAR_HEIGHT : MOBILE_BAR_HEIGHT))
const CARD_PADDING = 'px-3 py-2.5 md:px-3.5 md:py-3'
const BAR_TONES: Record<StripCard['tone'], string> = {
neutral: 'bg-(--ui-text-muted)',
error: 'bg-error',
warning: 'bg-warning',
calm: 'bg-(--ui-border-accented)',
}
const VALUE_TONES: Record<StripCard['tone'], string> = {
neutral: 'text-highlighted',
error: 'text-error',
warning: 'text-warning',
calm: 'text-highlighted',
}
function pressed(key: StripKey) {
if (key === 'failed') return props.outcome === 'failed'
if (key === 'security') return props.security
return false
}
function rootClass(card: StripCard) {
const fill =
card.tone === 'error'
? 'bg-(--rc-error-soft)'
: card.tone === 'warning'
? 'bg-(--rc-warning-soft)'
: 'bg-default'
const edge = pressed(card.key)
? 'border-primary ring-1 ring-primary ring-inset'
: 'border-default ring-0'
return [
'w-full cursor-pointer border text-left max-md:w-auto max-md:shrink-0 max-md:basis-39 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
fill,
edge,
]
}
</script>
<template>
<div
data-testid="activity-strip"
class="grid grid-cols-4 gap-3 max-md:-mx-4 max-md:flex max-md:gap-2 max-md:overflow-hidden max-md:px-4"
>
<template v-if="loading">
<UCard
v-for="key in STRIP_KEYS"
:key="key"
data-testid="activity-strip-skeleton"
:ui="{
root: 'border border-default bg-default ring-0 max-md:w-39 max-md:flex-none',
body: 'flex flex-col gap-2 p-3.5 sm:p-3.5 max-md:px-3 max-md:py-2.5 max-md:sm:px-3 max-md:sm:py-2.5',
}"
>
<USkeleton class="h-3 w-11/20 rounded-none bg-accented" />
<USkeleton class="h-5.5 w-2/5 rounded-none bg-accented" />
<USkeleton class="h-2.5 w-7/10 rounded-none bg-accented" />
</UCard>
</template>
<template v-else>
<UCard
v-for="card in cards"
:key="card.key"
as="button"
type="button"
data-testid="activity-strip-card"
:data-key="card.key"
:aria-pressed="
card.key === 'failed' || card.key === 'security' ? pressed(card.key) : undefined
"
:class="rootClass(card)"
:ui="{
root: 'overflow-visible rounded-none shadow-none md:min-w-0',
body: 'p-0 sm:p-0',
}"
@click="emit('apply-filter', card.key)"
>
<span class="flex flex-col gap-0.5" :class="CARD_PADDING">
<span class="flex items-center justify-between gap-2">
<span class="truncate font-medium text-toned">{{ card.label }}</span>
<span
aria-hidden="true"
data-testid="activity-bars"
class="flex shrink-0 items-end gap-0.5"
:style="{ height: `${barHeight}px` }"
>
<span
v-for="(height, index) in barHeights(card.series, barHeight)"
:key="index"
class="block w-1"
:class="BAR_TONES[card.tone]"
:style="{ height: `${height}px` }"
/>
</span>
</span>
<span
class="text-stat-sm font-semibold md:text-stat"
:class="VALUE_TONES[card.tone]"
>{{ card.value }}</span
>
<span
class="flex items-center gap-1 truncate text-xs/5"
:class="card.tone === 'calm' ? 'text-success' : 'text-muted'"
>
<UIcon v-if="card.icon" :name="card.icon" class="size-3 shrink-0" />
<span class="truncate">{{ card.subline }}</span>
</span>
</span>
</UCard>
</template>
</div>
</template>
+148
View File
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityActor from '@/components/ActivityActor.vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import ActivitySentence from '@/components/ActivitySentence.vue'
import ActivityTargetCell from '@/components/ActivityTargetCell.vue'
import { ACTIVITY_SKELETON_ROWS } from '@/composables/useActivity'
import { categoryIcon, outcomeBadge, rowSurface, type ActivityItem } from '@/lib/activity'
import { fullLocalTime, groupByDay, whenLabel } from '@/lib/activityTime'
const FULL_GRID = 'grid-cols-[96px_210px_minmax(0,1fr)_230px_120px]'
const OWN_GRID = 'grid-cols-[96px_minmax(0,1fr)_230px_120px]'
const SKELETON_ACTION_WIDTHS = ['w-7/10', 'w-11/20', 'w-41/50']
const props = defineProps<{
items: ActivityItem[]
loading: boolean
selectedId: number | null
showActor: boolean
}>()
const emit = defineEmits<{
open: [item: ActivityItem, opener: HTMLElement]
}>()
const grid = computed(() => `grid ${props.showActor ? FULL_GRID : OWN_GRID}`)
const groups = computed(() => groupByDay(props.items))
function open(item: ActivityItem, event: Event) {
emit('open', item, event.currentTarget as HTMLElement)
}
function rowClass(item: ActivityItem) {
return [grid.value, ...rowSurface(props.selectedId === item.id, item.security)]
}
</script>
<template>
<div
role="table"
:aria-label="t('Activity')"
:aria-busy="loading ? 'true' : undefined"
class="flex flex-col overflow-hidden border border-default bg-default"
>
<div
role="row"
:class="grid"
class="h-9 shrink-0 items-center bg-elevated pr-4 font-medium text-muted"
>
<span role="columnheader" class="flex items-center gap-1 pl-4 text-highlighted">
{{ t('When') }}
<UIcon name="i-lucide-arrow-down" class="size-3 shrink-0" />
</span>
<span v-if="showActor" role="columnheader">{{ t('Actor') }}</span>
<span role="columnheader">{{ t('Action') }}</span>
<span role="columnheader">{{ t('Target') }}</span>
<span role="columnheader">{{ t('Outcome') }}</span>
</div>
<template v-if="loading">
<span role="status" class="sr-only">{{ t('Loading activity') }}</span>
<div
v-for="n in ACTIVITY_SKELETON_ROWS"
:key="n"
role="row"
:class="grid"
class="h-13 items-center border-t border-default pr-4"
data-testid="activity-skeleton-row"
>
<span class="pl-4"><USkeleton class="h-3 w-14 rounded-none bg-accented" /></span>
<span v-if="showActor" class="flex items-center gap-2">
<USkeleton class="size-6 shrink-0 rounded-none bg-accented" />
<USkeleton class="h-3 w-22.5 rounded-none bg-accented" />
</span>
<span>
<USkeleton
class="h-3 rounded-none bg-accented"
:class="SKELETON_ACTION_WIDTHS[(n - 1) % 3]"
/>
</span>
<span><USkeleton class="h-3 w-3/5 rounded-none bg-accented" /></span>
<span><USkeleton class="h-5 w-18 rounded-none bg-accented" /></span>
</div>
</template>
<template v-else>
<template v-for="group in groups" :key="group.key">
<div role="row" data-testid="activity-day">
<div
role="cell"
class="flex h-8 items-center border-t border-default bg-muted px-4 text-xs font-semibold text-highlighted"
>
{{ group.label }}
</div>
</div>
<div
v-for="item in group.items"
:key="item.id"
role="row"
tabindex="0"
data-testid="activity-row"
:data-activity-id="item.id"
:aria-selected="selectedId === item.id"
:class="rowClass(item)"
class="min-h-13 cursor-pointer items-center border-t border-default pr-4 hover:bg-elevated focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
@click="open(item, $event)"
@keydown.enter.self.prevent="open(item, $event)"
>
<span
role="cell"
data-testid="activity-when"
:title="fullLocalTime(item.created_at)"
class="pl-4 whitespace-nowrap text-toned"
>
{{ whenLabel(item.created_at) }}
</span>
<span v-if="showActor" role="cell" class="min-w-0 pr-3">
<ActivityActor :actor="item.actor" />
</span>
<span role="cell" class="flex min-w-0 items-center gap-2.5 pr-4">
<UIcon
:name="categoryIcon(item.category)"
class="size-4 shrink-0 text-muted"
/>
<span class="truncate text-default">
<ActivitySentence :item="item" />
</span>
<UIcon
v-if="item.security"
name="i-lucide-shield-alert"
role="img"
:aria-label="t('Security')"
class="size-3.5 shrink-0 text-warning"
/>
</span>
<span role="cell" class="min-w-0">
<ActivityTargetCell :target="item.target" />
</span>
<span role="cell" class="flex">
<ActivityBadge v-bind="outcomeBadge(item.outcome)" />
</span>
</div>
</template>
</template>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import ActivityBadge from '@/components/ActivityBadge.vue'
import { targetLink, targetTypeLabel, type ActivityTarget } from '@/lib/activity'
const props = defineProps<{
target: ActivityTarget | null
}>()
const link = computed(() => targetLink(props.target))
</script>
<template>
<span v-if="!target" class="text-muted">—</span>
<span v-else class="flex min-w-0 flex-col pr-3" data-testid="activity-target">
<span v-if="!target.exists" class="flex min-w-0 items-center gap-1.5">
<span class="min-w-0 flex-1 truncate text-muted line-through">{{ target.name }}</span>
<ActivityBadge tone="deleted" :label="t('Deleted')" />
</span>
<RouterLink
v-else-if="link"
:to="link"
class="flex min-w-0 items-center gap-1 text-highlighted underline underline-offset-3"
@click.stop
>
<span class="truncate">{{ target.name }}</span>
<UIcon name="i-lucide-arrow-up-right" class="size-3 shrink-0 text-muted" />
</RouterLink>
<span v-else class="truncate text-highlighted">{{ target.name }}</span>
<span class="text-xs/5 text-muted">{{ targetTypeLabel(target.type) }}</span>
</span>
</template>
+7
View File
@@ -92,6 +92,13 @@ const navItems = computed(() =>
to: '/users',
icon: 'i-lucide-users',
},
{
get label() {
return t('Activity')
},
to: '/activity',
icon: 'i-lucide-history',
},
{
get label() {
return t('Integration')
@@ -58,8 +58,8 @@ afterEach(async () => {
document.body.innerHTML = ''
})
describe('DashboardShell renders the six navigation destinations', () => {
it('lists Home, Articles, Media, Users, Integration and Settings in order, with only Articles carrying aria-current', async () => {
describe('DashboardShell renders the seven navigation destinations', () => {
it('lists Home, Articles, Media, Users, Activity, Integration and Settings in order, with only Articles carrying aria-current', async () => {
const wrapper = await mountShell('/article')
const hrefs = wrapper
@@ -70,6 +70,7 @@ describe('DashboardShell renders the six navigation destinations', () => {
'/article',
'/media',
'/users',
'/activity',
'/integration',
'/settings',
])
@@ -85,7 +86,7 @@ describe('DashboardShell renders the six navigation destinations', () => {
expect(integration.attributes('aria-current')).toBeUndefined()
})
it('lists the same six destinations in the mobile drawer and Settings current', async () => {
it('lists the same seven destinations in the mobile drawer and Settings current', async () => {
await mountShell('/settings', { width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
@@ -100,11 +101,12 @@ describe('DashboardShell renders the six navigation destinations', () => {
'/article',
'/media',
'/users',
'/activity',
'/integration',
'/settings',
])
expect(links[5].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[5].getAttribute('aria-current')).toBe('page')
expect(links[6].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[6].getAttribute('aria-current')).toBe('page')
})
it('marks Media with aria-current on /media, in the sidebar and in the mobile drawer', async () => {
@@ -263,11 +265,11 @@ describe('The account chip shows the display name and no item is current on /pro
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
})
it('marks none of the six navigation items current on /profile', async () => {
it('marks none of the seven navigation items current on /profile', async () => {
const wrapper = await mountShell('/profile')
const links = wrapper.findAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(6)
expect(links).toHaveLength(7)
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
})
})
+69
View File
@@ -0,0 +1,69 @@
import { useApi } from '@/composables/useApi'
import {
exportQuery,
listQuery,
summaryQuery,
type ActivityFilters,
type ActivityPage,
type ActivitySummary,
} from '@/lib/activity'
import type { UserListItem } from '@/composables/useUsers'
import type { ApiKey } from '@/composables/useApiKeys'
export interface PersonOptions {
users: { id: number; name: string }[]
keys: { id: number; name: string }[]
}
export const ACTIVITY_SKELETON_ROWS = 9
export const ACTIVITY_SKELETON_CARDS = 5
export const EXPORT_FILE_NAME = 'activity.csv'
export function useActivity() {
const { request, requestBlob } = useApi()
function listActivity(
filters: ActivityFilters,
page: number,
size: number,
withActor = true,
): Promise<ActivityPage> {
return request<ActivityPage>(`/v1/activity?${listQuery(filters, page, size, withActor)}`)
}
function getSummary(filters: ActivityFilters): Promise<ActivitySummary> {
return request<ActivitySummary>(`/v1/activity/summary?${summaryQuery(filters)}`)
}
function exportActivity(filters: ActivityFilters): Promise<Blob> {
return requestBlob(`/v1/activity/export?${exportQuery(filters)}`)
}
async function listPeople(): Promise<PersonOptions> {
const [users, keys] = await Promise.all([
request<UserListItem[]>('/v1/user/'),
request<ApiKey[]>('/v1/api-key/'),
])
return {
users: users.flatMap((item) =>
item.kind === 'user'
? [{ id: item.id, name: item.display_name || item.username }]
: [],
),
keys: keys.map((key) => ({ id: key.id, name: key.name })),
}
}
return { listActivity, getSummary, exportActivity, listPeople }
}
export function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = fileName
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}
+316
View File
@@ -0,0 +1,316 @@
import { computed, onBeforeUnmount, ref } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError } from '@/composables/useApi'
import {
downloadBlob,
EXPORT_FILE_NAME,
useActivity,
type PersonOptions,
} from '@/composables/useActivity'
import { useSessionStore } from '@/stores/session'
import {
activeChips,
clearChip,
defaultFilters,
DEFAULT_PAGE_SIZE,
summaryRangeChanged,
type ActivityFilters,
type ActivityItem,
type ActivitySummary,
type ChipKey,
} from '@/lib/activity'
export type ListState = 'idle' | 'loading' | 'ready' | 'error' | 'forbidden'
export type SummaryState = 'idle' | 'loading' | 'ready' | 'failed'
const SKELETON_DELAY_MS = 150
const HANDLED_STATUSES = [401]
function delayedFlag() {
const flag = ref(false)
let timer: ReturnType<typeof setTimeout> | null = null
function stop() {
if (timer !== null) {
clearTimeout(timer)
timer = null
}
flag.value = false
}
function arm() {
stop()
timer = setTimeout(() => {
timer = null
flag.value = true
}, SKELETON_DELAY_MS)
}
return { flag, arm, stop }
}
export function useActivityPage() {
const session = useSessionStore()
const toast = useLocalizedToast()
const { listActivity, getSummary, exportActivity, listPeople } = useActivity()
const filters = ref<ActivityFilters>(defaultFilters())
const page = ref(1)
const size = ref(DEFAULT_PAGE_SIZE)
const items = ref<ActivityItem[]>([])
const total = ref(0)
const listState = ref<ListState>('idle')
const summary = ref<ActivitySummary | null>(null)
const summaryState = ref<SummaryState>('idle')
const people = ref<PersonOptions | null>(null)
const exporting = ref(false)
const selectedId = ref<number | null>(null)
const navigating = ref(false)
const listSkeleton = delayedFlag()
const summarySkeleton = delayedFlag()
let listSequence = 0
let summarySequence = 0
let peopleRequested = false
let requestedPage = 1
const role = computed(() => session.session?.role as string | undefined)
const isFullView = computed(() => role.value === 'owner' || role.value === 'admin')
const chips = computed(() => activeChips(filters.value))
const showListSkeleton = computed(
() => listState.value === 'loading' && listSkeleton.flag.value,
)
const showSummarySkeleton = computed(
() => summaryState.value === 'loading' && summarySkeleton.flag.value,
)
const isEmpty = computed(() => listState.value === 'ready' && total.value === 0)
const exportDisabled = computed(
() =>
exporting.value ||
listState.value === 'error' ||
(isEmpty.value && chips.value.length === 0),
)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size.value)))
const rangeStart = computed(() => (total.value === 0 ? 0 : (page.value - 1) * size.value + 1))
const rangeEnd = computed(() => Math.min(total.value, page.value * size.value))
const selectedIndex = computed(() =>
items.value.findIndex((item) => item.id === selectedId.value),
)
const selectedItem = computed(() => items.value[selectedIndex.value] ?? null)
const position = computed(() =>
selectedIndex.value < 0 ? 0 : (page.value - 1) * size.value + selectedIndex.value + 1,
)
const canPrevious = computed(() => position.value > 1 && !navigating.value)
const canNext = computed(
() => position.value > 0 && position.value < total.value && !navigating.value,
)
function failureState(error: unknown): ListState {
if (error instanceof ApiError && HANDLED_STATUSES.includes(error.status)) return 'idle'
if (error instanceof ApiError && error.status === 403) {
return session.session?.verified === false ? 'idle' : 'forbidden'
}
return 'error'
}
async function fetchPage(
nextPage: number,
options: { silent?: boolean; select?: 'first' | 'last' } = {},
) {
const sequence = ++listSequence
if (!options.silent) {
requestedPage = nextPage
listState.value = 'loading'
listSkeleton.arm()
}
try {
const result = await listActivity(filters.value, nextPage, size.value, isFullView.value)
if (sequence !== listSequence) return false
items.value = result.items
total.value = result.total
page.value = nextPage
listState.value = 'ready'
if (options.select && result.items.length > 0) {
const target = options.select === 'first' ? 0 : result.items.length - 1
selectedId.value = result.items[target]!.id
}
return true
} catch (error) {
if (sequence !== listSequence) return false
if (!options.silent) listState.value = failureState(error)
return false
} finally {
if (sequence === listSequence) listSkeleton.stop()
}
}
function loadList() {
return fetchPage(1)
}
async function loadSummary() {
if (!isFullView.value) return
const sequence = ++summarySequence
summaryState.value = 'loading'
summarySkeleton.arm()
try {
const result = await getSummary(filters.value)
if (sequence !== summarySequence) return
summary.value = result
summaryState.value = 'ready'
} catch {
if (sequence !== summarySequence) return
summary.value = null
summaryState.value = 'failed'
} finally {
if (sequence === summarySequence) summarySkeleton.stop()
}
}
function start() {
loadList()
loadSummary()
}
function applyFilters(next: ActivityFilters, force = false) {
const current = filters.value
if (!force && JSON.stringify(next) === JSON.stringify(current)) return
const rangeChanged = summaryRangeChanged(current, next)
filters.value = next
loadList()
if (rangeChanged) loadSummary()
}
function removeChip(key: ChipKey) {
applyFilters(clearChip(filters.value, key))
}
function clearAll() {
applyFilters(defaultFilters(), true)
}
function goToPage(next: number) {
if (next === page.value || next < 1 || next > pageCount.value) return
fetchPage(next)
}
function setSize(next: number) {
if (next === size.value) return
size.value = next
fetchPage(1)
}
function retry() {
fetchPage(requestedPage)
}
async function ensurePeople() {
if (peopleRequested) return
peopleRequested = true
try {
people.value = await listPeople()
} catch {
peopleRequested = false
}
}
async function runExport() {
if (exportDisabled.value) return
exporting.value = true
try {
const blob = await exportActivity(filters.value)
downloadBlob(blob, EXPORT_FILE_NAME)
toast.add(() => ({
color: 'success',
icon: 'i-lucide-circle-check',
get title() {
return t('Export started. Your file will download shortly.')
},
}))
} catch (error) {
if (!(error instanceof ApiError && error.status === 401)) {
toast.add(() => ({
color: 'error',
icon: 'i-lucide-circle-x',
get title() {
return t("Couldn't export activity. Try again.")
},
}))
}
} finally {
exporting.value = false
}
}
function openDetails(id: number) {
selectedId.value = id
}
function closeDetails() {
selectedId.value = null
}
async function step(direction: -1 | 1) {
const index = selectedIndex.value
if (index < 0 || navigating.value || listState.value === 'loading') return
const target = index + direction
const neighbour = items.value[target]
if (neighbour) {
selectedId.value = neighbour.id
return
}
if (direction === 1 ? position.value >= total.value : page.value <= 1) return
navigating.value = true
await fetchPage(page.value + direction, {
silent: true,
select: direction === 1 ? 'first' : 'last',
})
navigating.value = false
}
onBeforeUnmount(() => {
listSkeleton.stop()
summarySkeleton.stop()
})
return {
filters,
page,
size,
items,
total,
listState,
summary,
summaryState,
people,
exporting,
chips,
isFullView,
isEmpty,
exportDisabled,
showListSkeleton,
showSummarySkeleton,
pageCount,
rangeStart,
rangeEnd,
selectedId,
selectedItem,
position,
canPrevious,
canNext,
start,
applyFilters,
removeChip,
clearAll,
goToPage,
setSize,
retry,
ensurePeople,
runExport,
openDetails,
closeDetails,
previous: () => step(-1),
next: () => step(1),
}
}
+13 -2
View File
@@ -101,7 +101,7 @@ export function redirectUnverified(onForbidden: OnForbidden = 'redirect') {
}
export function useApi() {
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
async function send(path: string, options: RequestOptions = {}): Promise<Response> {
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
let response: Response
@@ -132,6 +132,12 @@ export function useApi() {
throw new ApiError(response.status, undefined, await readErrorBody(response))
}
return response
}
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const response = await send(path, options)
if (response.status === 204) {
return undefined as T
}
@@ -139,6 +145,11 @@ export function useApi() {
return response.json()
}
async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
const response = await send(path, options)
return response.blob()
}
async function getMe(): Promise<MeResult> {
const body = await request<{
id?: number
@@ -158,5 +169,5 @@ export function useApi() {
}
}
return { getMe, request }
return { getMe, request, requestBlob }
}
+179
View File
@@ -1004,6 +1004,185 @@ export const messages = {
'La médiathèque a changé, rien n’a donc été purgé. Vérifiez le nouveau récapitulatif et réessayez.',
'A purge is already running.': 'Une purge est déjà en cours.',
'Duplicates purged': 'Doublons purgés',
Activity: 'Activité',
'My activity': 'Mon activité',
'Who did what, and when, across your site.': 'Qui a fait quoi, et quand, sur votre site.',
"Everything you've done on this site.": 'Tout ce que vous avez fait sur ce site.',
'Export CSV': 'Exporter en CSV',
'Preparing file…': 'Préparation du fichier…',
'Export started. Your file will download shortly.':
'Export lancé. Votre fichier va se télécharger sous peu.',
"Couldn't export activity. Try again.": "Impossible d'exporter l'activité. Réessayez.",
'Total actions': 'Actions au total',
'Active people': 'Personnes actives',
'Failed actions': 'Actions en échec',
'Security events': 'Événements de sécurité',
'{0}% vs previous 7 days': '{0} % par rapport aux 7 jours précédents',
'{0}% vs previous day': '{0} % par rapport au jour précédent',
'{0}% vs previous 30 days': '{0} % par rapport aux 30 jours précédents',
'{0}% vs previous period': '{0} % par rapport à la période précédente',
'No earlier data': 'Aucune donnée antérieure',
'No change': 'Aucun changement',
'No data yet': 'Aucune donnée pour le moment',
'{0} people': '{0} personnes',
'1 person': '1 personne',
'{0} API keys': "{0} clés d'API",
'1 API key': "1 clé d'API",
'{0}% of all actions': '{0} % de toutes les actions',
'{0} flagged': '{0} signalés',
'1 flagged': '1 signalé',
'Nothing failed': 'Aucun échec',
'Nothing to flag': 'Rien à signaler',
'Search activity': "Rechercher dans l'activité",
'All people': 'Toutes les personnes',
'All categories': 'Toutes les catégories',
Succeeded: 'Réussie',
Failed: 'Échec',
'7 days': '7 jours',
'30 days': '30 jours',
Custom: 'Personnalisée',
Person: 'Personne',
Category: 'Catégorie',
Outcome: 'Résultat',
Dates: 'Dates',
Filters: 'Filtres',
'Clear all': 'Tout effacer',
'Search: {0}': 'Recherche : {0}',
'Person: {0}': 'Personne : {0}',
'Category: {0}': 'Catégorie : {0}',
'Outcome: {0}': 'Résultat : {0}',
'Dates: {0}': 'Dates : {0}',
'Remove filter {0}': 'Retirer le filtre {0}',
'Show {0} results': 'Afficher {0} résultats',
'Show 1 result': 'Afficher 1 résultat',
'Showing {0}–{1} of {2}': 'Affichage de {0} à {1} sur {2}',
'Page {0} of {1}': 'Page {0} sur {1}',
'{0}–{1} of {2}': '{0} à {1} sur {2}',
'Rows per page': 'Lignes par page',
'Previous page': 'Page précédente',
'Next page': 'Page suivante',
'Start date': 'Date de début',
'End date': 'Date de fin',
When: 'Quand',
Actor: 'Acteur',
Action: 'Action',
Target: 'Cible',
'Not signed in': 'Non connecté',
Deleted: 'Supprimé',
'Target deleted': 'Cible supprimée',
Security: 'Sécurité',
Article: 'Article',
User: 'Utilisateur',
Invite: 'Invitation',
Release: 'Version',
'{0} min ago': 'Il y a {0} min',
'{0} h ago': 'Il y a {0} h',
'No activity yet': 'Aucune activité pour le moment',
'Actions on articles, media, users and API keys will appear here.':
"Les actions sur les articles, les médias, les utilisateurs et les clés d'API apparaîtront ici.",
'Your actions will appear here once you start working on the site.':
'Vos actions apparaîtront ici dès que vous commencerez à travailler sur le site.',
'No activity matches these filters': 'Aucune activité ne correspond à ces filtres',
'Try a different search or a wider date range.':
'Essayez une autre recherche ou une période plus large.',
"Couldn't load activity": "Impossible de charger l'activité",
"You can't view this activity": 'Vous ne pouvez pas consulter cette activité',
"Your role doesn't include this page. Ask an owner or admin if you need access.":
"Votre rôle n'inclut pas cette page. Demandez à un propriétaire ou à un administrateur si vous avez besoin d'y accéder.",
'Back to home': "Retour à l'accueil",
'Loading activity': "Chargement de l'activité",
'Activity details': "Détails de l'activité",
'Why it failed': "Raison de l'échec",
Source: 'Source',
'IP address': 'Adresse IP',
Browser: 'Navigateur',
'Web app': 'Application web',
'System task': 'Tâche système',
'Not applicable': 'Sans objet',
Changes: 'Modifications',
Before: 'Avant',
After: 'Après',
'Show more': 'Voir plus',
'Show less': 'Voir moins',
'Previous event': 'Événement précédent',
'Next event': 'Événement suivant',
'Event {0} of {1}': 'Événement {0} sur {1}',
'{0} of {1}': '{0} sur {1}',
'{0} (your time)': '{0} (votre heure)',
'{0} UTC': '{0} UTC',
'This item was deleted. Its name is kept for reference.':
'Cet élément a été supprimé. Son nom est conservé pour référence.',
'Body edited ({0} characters)': 'Corps modifié ({0} caractères)',
'Body edited ({0} character)': 'Corps modifié ({0} caractère)',
'Publish state': 'État de publication',
Body: 'Corps',
'File name': 'Nom du fichier',
Published: 'Publié',
'Created the article {0}': 'Article créé : {0}',
'Edited the article {0}': 'Article modifié : {0}',
'Published the article {0}': 'Article publié : {0}',
'Unpublished the article {0}': 'Article dépublié : {0}',
'Deleted the article {0}': 'Article supprimé : {0}',
'Uploaded the image {0}': 'Image téléversée : {0}',
'Renamed the image {0}': 'Image renommée : {0}',
'Deleted the image {0}': 'Image supprimée : {0}',
'Merged duplicates into the image {0}': "Doublons fusionnés dans l'image {0}",
'Purged duplicate images: {0}': 'Doublons purgés : {0}',
'Ran the scheduled duplicate purge: {0}': 'Purge automatique des doublons : {0}',
'{0} articles updated': '{0} articles mis à jour',
'1 article updated': '1 article mis à jour',
'{0} groups failed': '{0} groupes en échec',
'1 group failed': '1 groupe en échec',
'Registered the account {0}': 'Compte créé : {0}',
'Verified the account {0}': 'Compte vérifié : {0}',
'Updated the profile of {0}': 'Profil mis à jour : {0}',
'Invited {0}': 'Invitation envoyée à {0}',
'Cancelled the invite for {0}': 'Invitation annulée : {0}',
'Renewed the invite for {0}': 'Invitation renouvelée : {0}',
'Accepted the invite for {0}': 'Invitation acceptée : {0}',
'Created API key {0}': "Clé d'API créée : {0}",
'Revoked API key {0}': "Clé d'API révoquée : {0}",
'Logged in': 'Connexion',
'Logged out': 'Déconnexion',
'Requested a password reset': 'Réinitialisation du mot de passe demandée',
'Reset the password': 'Mot de passe réinitialisé',
'Checked for updates': 'Recherche de mises à jour',
'Downloaded the update {0}': 'Mise à jour téléchargée : {0}',
'Requested a restart': 'Redémarrage demandé',
'Restarted to apply the update {0}': 'Redémarrage pour appliquer la mise à jour {0}',
'Changed the activity retention from {0} to {1}':
"Durée de conservation de l'activité modifiée : {0} → {1}",
'Removed {0} old events from the activity log':
"{0} anciens événements supprimés du journal d'activité",
'Removed 1 old event from the activity log':
"1 ancien événement supprimé du journal d'activité",
'{0} days': '{0} jours',
Forever: 'Toujours',
'Failed to create the article {0}': "Échec de la création de l'article {0}",
'Failed to edit the article {0}': "Échec de la modification de l'article {0}",
'Failed to publish the article {0}': "Échec de la publication de l'article {0}",
'Failed to unpublish the article {0}': "Échec de la dépublication de l'article {0}",
'Failed to delete the article {0}': "Échec de la suppression de l'article {0}",
'Failed to upload the image {0}': "Échec du téléversement de l'image {0}",
'Failed to rename the image {0}': "Échec du renommage de l'image {0}",
'Failed to delete the image {0}': "Échec de la suppression de l'image {0}",
'Failed to merge duplicates into the image {0}':
"Échec de la fusion des doublons dans l'image {0}",
'Failed to purge duplicate images': 'Échec de la purge des doublons',
'Failed to run the scheduled duplicate purge': 'Échec de la purge automatique des doublons',
'Failed to register the account {0}': 'Échec de la création du compte {0}',
'Failed to verify the account {0}': 'Échec de la vérification du compte {0}',
'Failed to update the profile of {0}': 'Échec de la mise à jour du profil de {0}',
'Failed to invite {0}': "Échec de l'invitation de {0}",
'Failed to cancel the invite for {0}': "Échec de l'annulation de l'invitation de {0}",
'Failed to renew the invite for {0}': "Échec du renouvellement de l'invitation de {0}",
'Failed to accept the invite for {0}': "Échec de l'acceptation de l'invitation de {0}",
'Failed to create API key {0}': "Échec de la création de la clé d'API {0}",
'Failed to revoke API key {0}': "Échec de la révocation de la clé d'API {0}",
'Failed login attempt for {0}': 'Échec de connexion pour {0}',
'Failed to check for updates': 'Échec de la recherche de mises à jour',
'Failed to download the update {0}': 'Échec du téléchargement de la mise à jour {0}',
'Failed to restart': 'Échec du redémarrage',
} as const
export type MessageKey = keyof typeof messages
+305
View File
@@ -0,0 +1,305 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale } from '@/i18n'
import {
activeChips,
bodyDeltaLabel,
changeValue,
clearChip,
defaultFilters,
exportQuery,
fieldLabel,
initials,
listQuery,
summaryQuery,
targetLink,
type ActivityFilters,
} from '../activity'
import { dayLabel, formatDateRange, groupByDay, whenLabel } from '../activityTime'
import { barHeights, stripCards } from '../activitySummary'
import { summaryDefault, summaryNone, summaryZero } from '@/views/__tests__/activityFixtures'
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(() => {
localStorage.clear()
initializeLocale()
})
function filters(overrides: Partial<ActivityFilters> = {}): ActivityFilters {
return { ...defaultFilters(), ...overrides }
}
function entries(query: string) {
return Object.fromEntries(new URLSearchParams(query))
}
describe('Queries', () => {
it('always sends range, page and size and nothing else by default', () => {
expect(entries(listQuery(filters(), 1, 25))).toEqual({ range: '7d', page: '1', size: '25' })
})
it('adds q, actor, category, outcome and security only when set', () => {
const query = listQuery(
filters({
q: 'spring',
actor: 'user:3',
category: 'auth',
outcome: 'failed',
security: true,
}),
2,
50,
)
expect(entries(query)).toEqual({
q: 'spring',
actor: 'user:3',
category: 'auth',
outcome: 'failed',
security: 'true',
range: '7d',
page: '2',
size: '50',
})
})
it('sends from and to only with the custom range', () => {
expect(
entries(
listQuery(filters({ range: '30d', from: '2026-09-01', to: '2026-09-30' }), 1, 25),
),
).toEqual({ range: '30d', page: '1', size: '25' })
expect(
entries(
listQuery(
filters({ range: 'custom', from: '2026-09-01', to: '2026-09-30' }),
1,
25,
),
),
).toEqual({ range: 'custom', from: '2026-09-01', to: '2026-09-30', page: '1', size: '25' })
})
it('never sends an actor for a regular user', () => {
expect(entries(listQuery(filters({ actor: 'system' }), 1, 25, false)).actor).toBeUndefined()
})
it('keeps the summary to the date range and the export to the list filters', () => {
const all = filters({
q: 'x',
category: 'media',
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
expect(entries(summaryQuery(all))).toEqual({
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
expect(entries(exportQuery(all))).toEqual({
q: 'x',
category: 'media',
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
})
})
describe('Chips', () => {
it('lists the active filters in order and never the default range', () => {
expect(activeChips(filters())).toEqual([])
expect(
activeChips(
filters({
q: 'spring',
actor: 'user:3',
actorLabel: 'Marta Alves',
category: 'auth',
outcome: 'failed',
security: true,
range: '30d',
}),
).map((chip) => chip.label),
).toEqual([
'Search: spring',
'Person: Marta Alves',
'Category: Authentication',
'Outcome: Failed',
'Security events',
'Dates: 30 days',
])
})
it('words custom ranges', () => {
const range = (from: string, to: string) =>
activeChips(filters({ range: 'custom', from, to }))[0]!.label
expect(range('2026-09-01', '2026-09-30')).toBe('Dates: 1 – 30 Sep 2026')
expect(range('2026-09-28', '2026-10-03')).toBe('Dates: 28 Sep – 3 Oct 2026')
expect(range('2025-12-28', '2026-01-03')).toBe('Dates: 28 Dec 2025 – 3 Jan 2026')
expect(range('2026-09-05', '2026-09-05')).toBe('Dates: 5 Sep 2026')
})
it('words custom ranges in French', () => {
setLocale('fr')
expect(formatDateRange('2026-10-01', '2026-10-30')).toBe('1 – 30 oct. 2026')
})
it('resets one filter at a time', () => {
const all = filters({ q: 'a', actor: 'system', actorLabel: 'System', range: '30d' })
expect(clearChip(all, 'q').q).toBe('')
expect(clearChip(all, 'actor')).toMatchObject({ actor: '', actorLabel: '' })
expect(clearChip(all, 'range').range).toBe('7d')
})
})
describe('Times', () => {
const now = new Date(2026, 9, 6, 15, 0, 0)
it('labels days as Today, Yesterday or the date', () => {
expect(dayLabel(new Date(2026, 9, 6, 9, 0).toISOString(), now)).toBe('Today')
expect(dayLabel(new Date(2026, 9, 5, 23, 59).toISOString(), now)).toBe('Yesterday')
expect(dayLabel(new Date(2026, 9, 3, 14, 20).toISOString(), now)).toBe('3 Oct 2026')
setLocale('fr')
expect(dayLabel(new Date(2026, 9, 6, 9, 0).toISOString(), now)).toBe('Aujourd’hui')
expect(dayLabel(new Date(2026, 9, 3, 14, 20).toISOString(), now)).toBe('3 oct. 2026')
})
it('reads relative within today and as a clock time before', () => {
expect(whenLabel(new Date(2026, 9, 6, 14, 58).toISOString(), now)).toBe('2 min ago')
expect(whenLabel(new Date(2026, 9, 6, 14, 31).toISOString(), now)).toBe('29 min ago')
expect(whenLabel(new Date(2026, 9, 6, 14, 0).toISOString(), now)).toBe('1 h ago')
expect(whenLabel(new Date(2026, 9, 5, 18, 20).toISOString(), now)).toBe('18:20')
expect(whenLabel(new Date(2026, 9, 6, 14, 59, 50).toISOString(), now)).toBe('1 min ago')
})
it('groups consecutive items of the same local day in API order', () => {
const groups = groupByDay(
[
{ created_at: new Date(2026, 9, 6, 12).toISOString(), id: 1 },
{ created_at: new Date(2026, 9, 6, 9).toISOString(), id: 2 },
{ created_at: new Date(2026, 9, 5, 20).toISOString(), id: 3 },
],
now,
)
expect(groups.map((group) => [group.label, group.items.map((entry) => entry.id)])).toEqual([
['Today', [1, 2]],
['Yesterday', [3]],
])
})
})
describe('Labels', () => {
it('takes the first letters of the first two words', () => {
expect(initials('Marta Alves')).toBe('MA')
expect(initials('inès duarte jr')).toBe('ID')
expect(initials('Madonna')).toBe('M')
})
it('links each target type, and nothing for a deleted or unknown one', () => {
const link = (type: string, exists = true) =>
targetLink({ type, id: '8', name: 'x', exists })
expect(link('article')).toBe('/article/8/edit')
expect(link('image')).toBe('/media')
expect(link('user')).toBe('/users')
expect(link('invite')).toBe('/users')
expect(link('api_key')).toBe('/integration/api-keys')
expect(link('release')).toBeNull()
expect(link('article', false)).toBeNull()
expect(targetLink(null)).toBeNull()
})
it('labels fields and values, keeping unknown codes', () => {
expect(fieldLabel('title')).toBe('Title')
expect(fieldLabel('online')).toBe('Publish state')
expect(fieldLabel('mystery')).toBe('mystery')
expect(changeValue('online', 'false')).toBe('Draft')
expect(changeValue('online', 'true')).toBe('Published')
expect(changeValue('title', 'true')).toBe('true')
setLocale('fr')
expect(fieldLabel('filename')).toBe('Nom du fichier')
expect(changeValue('online', 'true')).toBe('Publié')
})
it('words body deltas with sign and singular', () => {
expect(bodyDeltaLabel(312)).toBe('Body edited (+312 characters)')
expect(bodyDeltaLabel(-48)).toBe('Body edited (-48 characters)')
expect(bodyDeltaLabel(1)).toBe('Body edited (+1 character)')
})
})
describe('Summary strip', () => {
it('derives the four cards from the summary', () => {
const cards = stripCards(summaryDefault, '7d')
expect(cards.map((card) => [card.label, card.value, card.subline])).toEqual([
['Total actions', '1,284', '12% vs previous 7 days'],
['Active people', '9', '7 people, 2 API keys'],
['Failed actions', '6', '0.5% of all actions'],
['Security events', '11', '11 flagged'],
])
expect(cards.map((card) => card.tone)).toEqual(['neutral', 'neutral', 'error', 'warning'])
expect(cards[0]!.icon).toBe('i-lucide-trending-up')
})
it('words the comparison for each range', () => {
const subline = (range: 'today' | '7d' | '30d' | 'custom') =>
stripCards(summaryDefault, range)[0]!.subline
expect(subline('today')).toBe('12% vs previous day')
expect(subline('30d')).toBe('12% vs previous 30 days')
expect(subline('custom')).toBe('12% vs previous period')
})
it('goes calm on zero and reads No data yet on an empty site', () => {
const quiet = stripCards(summaryZero, '7d')
expect(quiet[0]!.icon).toBe('i-lucide-trending-down')
expect(quiet.slice(2).map((card) => [card.tone, card.subline, card.icon])).toEqual([
['calm', 'Nothing failed', 'i-lucide-circle-check'],
['calm', 'Nothing to flag', 'i-lucide-circle-check'],
])
const none = stripCards(summaryNone, '7d')
expect(none.map((card) => card.subline)).toEqual([
'No data yet',
'No data yet',
'Nothing failed',
'Nothing to flag',
])
})
it('words singulars, the French forms and the flag count', () => {
const single = stripCards(
{ ...summaryDefault, active_people: { users: 1, api_keys: 1 }, security: 1 },
'7d',
)
expect(single[1]!.subline).toBe('1 person, 1 API key')
expect(single[3]!.subline).toBe('1 flagged')
setLocale('fr')
expect(stripCards(summaryDefault, '7d')[3]!.subline).toBe('11 signalés')
expect(stripCards({ ...summaryDefault, security: 1 }, '7d')[3]!.subline).toBe('1 signalé')
expect(
stripCards({ ...summaryDefault, total: 40, previous_total: 0 }, '7d')[0]!.subline,
).toBe('Aucune donnée antérieure')
expect(
stripCards({ ...summaryDefault, total: 40, previous_total: 40 }, '7d')[0]!.subline,
).toBe('Aucun changement')
})
it('sizes bars from the peak with a 2 px minimum', () => {
expect(barHeights([4, 6, 5, 8, 7, 9, 6], 20)).toEqual([9, 13, 11, 18, 16, 20, 13])
expect(barHeights([0, 0, 0, 0, 0, 0, 0], 20)).toEqual([2, 2, 2, 2, 2, 2, 2])
expect(barHeights([0, 1, 0, 0, 0, 0, 100], 16)).toEqual([2, 2, 2, 2, 2, 2, 16])
})
})
@@ -0,0 +1,240 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale, t } from '@/i18n'
import { messages, type MessageKey } from '@/i18n/messages'
import type { ActivityItem } from '@/lib/activity'
import {
SENTENCE_ACTIONS,
composeSentence,
sentenceTemplates,
sentenceText,
} from '../activitySentence'
function item(overrides: Partial<ActivityItem>): ActivityItem {
return {
id: 1,
created_at: '2026-10-03T12:00:00Z',
action: 'article.publish',
category: 'articles',
actor: { type: 'user', id: 3, name: 'Marta Alves' },
target: { type: 'article', id: '8', name: 'Spring release', exists: true },
outcome: 'success',
failure_reason: null,
security: false,
source: 'web',
ip: null,
user_agent: null,
params: {},
changes: [],
...overrides,
}
}
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(() => {
localStorage.clear()
initializeLocale()
})
describe('Sentences: English and French come from the same code and params', () => {
const cases: [string, Partial<ActivityItem>, string, string][] = [
[
'article.publish',
{},
'Published the article Spring release',
'Article publié : Spring release',
],
[
'article.publish failed',
{ outcome: 'failed' },
'Failed to publish the article Spring release',
"Échec de la publication de l'article Spring release",
],
[
'media.purge',
{ action: 'media.purge', target: null, params: { merged: 3, articles_updated: 5 } },
'Purged duplicate images: 3 merged, 5 articles updated',
'Doublons purgés : 3 fusionnées, 5 articles mis à jour',
],
[
'media.purge with failed groups',
{
action: 'media.purge',
target: null,
params: { merged: 3, articles_updated: 1, failed_groups: 1 },
},
'Purged duplicate images: 3 merged, 1 article updated, 1 group failed',
'Doublons purgés : 3 fusionnées, 1 article mis à jour, 1 groupe en échec',
],
[
'system.media_purge with several failed groups',
{
action: 'system.media_purge',
target: null,
params: { merged: 10, articles_updated: 12, failed_groups: 3 },
},
'Ran the scheduled duplicate purge: 10 merged, 12 articles updated, 3 groups failed',
'Purge automatique des doublons : 10 fusionnées, 12 articles mis à jour, 3 groupes en échec',
],
[
'system.activity_cleanup singular',
{ action: 'system.activity_cleanup', target: null, params: { removed: 1 } },
'Removed 1 old event from the activity log',
"1 ancien événement supprimé du journal d'activité",
],
[
'system.activity_cleanup plural',
{ action: 'system.activity_cleanup', target: null, params: { removed: 1284 } },
'Removed 1,284 old events from the activity log',
"1 284 anciens événements supprimés du journal d'activité",
],
[
'system.activity_retention_changed',
{
action: 'system.activity_retention_changed',
target: null,
params: { from: 90, to: 0 },
},
'Changed the activity retention from 90 days to Forever',
"Durée de conservation de l'activité modifiée : 90 jours → Toujours",
],
[
'system.restart_requested update',
{
action: 'system.restart_requested',
target: { type: 'release', id: 'v1.4.0', name: 'v1.4.0', exists: true },
params: { kind: 'update', tag: 'v1.4.0' },
},
'Restarted to apply the update v1.4.0',
'Redémarrage pour appliquer la mise à jour v1.4.0',
],
[
'system.restart_requested restart',
{ action: 'system.restart_requested', target: null, params: { kind: 'restart' } },
'Requested a restart',
'Redémarrage demandé',
],
[
'media.upload failed without a target',
{ action: 'media.upload', target: null, outcome: 'failed' },
'Failed to upload the image',
"Échec du téléversement de l'image",
],
[
'article.publish without a target',
{ target: null },
'Published the article',
'Article publié',
],
[
'auth.login_failed uses the actor name',
{
action: 'auth.login_failed',
target: null,
outcome: 'failed',
actor: { type: 'anonymous', id: null, name: 'alex@example.com' },
},
'Failed login attempt for alex@example.com',
'Échec de connexion pour alex@example.com',
],
[
'auth.login failed renders its success sentence',
{ action: 'auth.login', target: null, outcome: 'failed' },
'Logged in',
'Connexion',
],
[
'unknown action code',
{ action: 'article.archive' },
'article.archive',
'article.archive',
],
]
it.each(cases)('%s', (_, overrides, english, french) => {
const subject = item(overrides)
expect(sentenceText(subject)).toBe(english)
setLocale('fr')
expect(sentenceText(subject)).toBe(french)
})
it('inserts the target name untranslated as the semibold last segment', () => {
const segments = composeSentence(item({}))
expect(segments).toEqual([
{ text: 'Published the article ', strong: false },
{ text: 'Spring release', strong: true },
])
setLocale('fr')
expect(composeSentence(item({}))).toEqual([
{ text: 'Article publié : ', strong: false },
{ text: 'Spring release', strong: true },
])
})
it('keeps a target name that looks like a template slot', () => {
const subject = item({
target: { type: 'article', id: '1', name: '{0} {1}', exists: true },
})
expect(sentenceText(subject)).toBe('Published the article {0} {1}')
})
})
describe('The catalogue is complete in both languages', () => {
const templateKeys = SENTENCE_ACTIONS.flatMap((action) => {
const template = sentenceTemplates(action)!
return [template.success, ...(template.failed ? [template.failed] : [])]
})
it.each(Array.from(new Set(templateKeys)))('has a French entry for %s', (key) => {
expect(messages).toHaveProperty([key as MessageKey])
expect(messages[key as MessageKey]).not.toBe('')
expect(messages[key as MessageKey]).not.toBe(key)
})
it('covers every action the producers record', () => {
expect(SENTENCE_ACTIONS.sort()).toEqual(
[
'article.create',
'article.edit',
'article.publish',
'article.unpublish',
'article.delete',
'media.upload',
'media.rename',
'media.delete',
'media.merge',
'media.purge',
'system.media_purge',
'user.register',
'user.verify',
'user.update_profile',
'invite.create',
'invite.cancel',
'invite.renew',
'invite.accept',
'api_key.create',
'api_key.revoke',
'auth.login',
'auth.login_failed',
'auth.logout',
'auth.password_reset_requested',
'auth.password_reset_completed',
'system.update_checked',
'system.update_downloaded',
'system.restart_requested',
'system.activity_retention_changed',
'system.activity_cleanup',
].sort(),
)
})
it('formats a template with placeholders through t', () => {
expect(t('Revoked API key {0}', ['Mobile app'])).toBe('Revoked API key Mobile app')
})
})
+350
View File
@@ -0,0 +1,350 @@
import { formatNumber, t } from '@/i18n'
import { formatDateRange } from '@/lib/activityTime'
export const ACTIVITY_CATEGORIES = [
'articles',
'media',
'users',
'api_keys',
'auth',
'system',
] as const
export type ActivityCategory = (typeof ACTIVITY_CATEGORIES)[number]
export type ActivityOutcome = 'success' | 'failed'
export type OutcomeFilter = 'all' | ActivityOutcome
export type ActorType = 'user' | 'api_key' | 'system' | 'anonymous'
export type ActivitySource = 'web' | 'api_key' | 'system'
export type RangeKey = 'today' | '7d' | '30d' | 'custom'
export const PAGE_SIZES = [25, 50, 100] as const
export const DEFAULT_PAGE_SIZE = 25
export const DEFAULT_RANGE: RangeKey = '7d'
export const RANGE_KEYS: RangeKey[] = ['today', '7d', '30d', 'custom']
export const OUTCOME_FILTERS: OutcomeFilter[] = ['all', 'success', 'failed']
export interface ActivityActor {
type: ActorType
id: number | null
name: string
}
export interface ActivityTarget {
type: string
id: string
name: string
exists: boolean
}
export type ActivityChange =
| { field: string; kind: 'text'; before: string; after: string }
| { field: string; kind: 'body'; delta: number }
export interface ActivityItem {
id: number
created_at: string
action: string
category: ActivityCategory
actor: ActivityActor
target: ActivityTarget | null
outcome: ActivityOutcome
failure_reason: string | null
security: boolean
source: ActivitySource
ip: string | null
user_agent: string | null
params: Record<string, unknown>
changes: ActivityChange[]
}
export interface ActivityPage {
items: ActivityItem[]
total: number
page: number
size: number
}
export interface ActivitySummary {
total: number
previous_total: number
active_people: { users: number; api_keys: number }
failed: number
security: number
series: { total: number[]; active: number[]; failed: number[]; security: number[] }
}
export interface ActivityFilters {
q: string
actor: string
actorLabel: string
category: ActivityCategory | ''
outcome: OutcomeFilter
security: boolean
range: RangeKey
from: string
to: string
}
export type ChipKey = 'q' | 'actor' | 'category' | 'outcome' | 'security' | 'range'
export interface ActivityChip {
key: ChipKey
label: string
}
export function defaultFilters(): ActivityFilters {
return {
q: '',
actor: '',
actorLabel: '',
category: '',
outcome: 'all',
security: false,
range: DEFAULT_RANGE,
from: '',
to: '',
}
}
export function summaryRangeChanged(a: ActivityFilters, b: ActivityFilters) {
return a.range !== b.range || a.from !== b.from || a.to !== b.to
}
function filterParams(filters: ActivityFilters) {
const params = new URLSearchParams()
if (filters.q) params.set('q', filters.q)
if (filters.actor) params.set('actor', filters.actor)
if (filters.category) params.set('category', filters.category)
if (filters.outcome !== 'all') params.set('outcome', filters.outcome)
if (filters.security) params.set('security', 'true')
params.set('range', filters.range)
if (filters.range === 'custom') {
params.set('from', filters.from)
params.set('to', filters.to)
}
return params
}
export function listQuery(filters: ActivityFilters, page: number, size: number, withActor = true) {
const params = filterParams(withActor ? filters : { ...filters, actor: '' })
params.set('page', String(page))
params.set('size', String(size))
return params.toString()
}
export function exportQuery(filters: ActivityFilters) {
return filterParams(filters).toString()
}
export function summaryQuery(filters: ActivityFilters) {
const params = new URLSearchParams({ range: filters.range })
if (filters.range === 'custom') {
params.set('from', filters.from)
params.set('to', filters.to)
}
return params.toString()
}
export function categoryLabel(category: ActivityCategory) {
switch (category) {
case 'articles':
return t('Articles')
case 'media':
return t('Media')
case 'users':
return t('Users and invites')
case 'api_keys':
return t('API keys')
case 'auth':
return t('Authentication')
case 'system':
return t('System')
}
}
export function categoryIcon(category: ActivityCategory) {
switch (category) {
case 'articles':
return 'i-lucide-file-text'
case 'media':
return 'i-lucide-image'
case 'users':
return 'i-lucide-users'
case 'api_keys':
return 'i-lucide-key-round'
case 'auth':
return 'i-lucide-shield-check'
case 'system':
return 'i-lucide-server-cog'
}
}
export function outcomeFilterLabel(outcome: OutcomeFilter) {
switch (outcome) {
case 'all':
return t('All')
case 'success':
return t('Succeeded')
case 'failed':
return t('Failed')
}
}
export function outcomeOptions() {
return OUTCOME_FILTERS.map((value) => ({ value, label: outcomeFilterLabel(value) }))
}
export function outcomeBadge(outcome: ActivityOutcome) {
return outcome === 'success'
? { tone: 'ok' as const, label: t('Succeeded') }
: { tone: 'fail' as const, label: t('Failed') }
}
export function rowSurface(selected: boolean, security: boolean) {
return [selected ? 'bg-elevated' : 'bg-default', security ? 'shadow-flag' : '']
}
export function rangeLabel(range: RangeKey) {
switch (range) {
case 'today':
return t('Today')
case '7d':
return t('7 days')
case '30d':
return t('30 days')
case 'custom':
return t('Custom')
}
}
export function activeChips(filters: ActivityFilters): ActivityChip[] {
const chips: ActivityChip[] = []
if (filters.q) chips.push({ key: 'q', label: t('Search: {0}', [filters.q]) })
if (filters.actor) chips.push({ key: 'actor', label: t('Person: {0}', [filters.actorLabel]) })
if (filters.category)
chips.push({
key: 'category',
label: t('Category: {0}', [categoryLabel(filters.category)]),
})
if (filters.outcome !== 'all')
chips.push({
key: 'outcome',
label: t('Outcome: {0}', [outcomeFilterLabel(filters.outcome)]),
})
if (filters.security) chips.push({ key: 'security', label: t('Security events') })
if (filters.range !== DEFAULT_RANGE) {
const value =
filters.range === 'custom'
? formatDateRange(filters.from, filters.to)
: rangeLabel(filters.range)
chips.push({ key: 'range', label: t('Dates: {0}', [value]) })
}
return chips
}
export function clearChip(filters: ActivityFilters, key: ChipKey): ActivityFilters {
const defaults = defaultFilters()
switch (key) {
case 'q':
return { ...filters, q: '' }
case 'actor':
return { ...filters, actor: '', actorLabel: '' }
case 'category':
return { ...filters, category: '' }
case 'outcome':
return { ...filters, outcome: 'all' }
case 'security':
return { ...filters, security: false }
case 'range':
return { ...filters, range: defaults.range, from: '', to: '' }
}
}
export function initials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => Array.from(word)[0])
.join('')
.toUpperCase()
}
export function sourceLabel(source: ActivitySource) {
switch (source) {
case 'web':
return t('Web app')
case 'api_key':
return t('API key')
case 'system':
return t('System task')
}
}
export function targetTypeLabel(type: string) {
switch (type) {
case 'article':
return t('Article')
case 'image':
return t('Image')
case 'user':
return t('User')
case 'invite':
return t('Invite')
case 'api_key':
return t('API key')
case 'release':
return t('Release')
default:
return type
}
}
export function targetLink(target: ActivityTarget | null) {
if (!target?.exists) return null
switch (target.type) {
case 'article':
return `/article/${target.id}/edit`
case 'image':
return '/media'
case 'user':
case 'invite':
return '/users'
case 'api_key':
return '/integration/api-keys'
default:
return null
}
}
export function fieldLabel(field: string) {
switch (field) {
case 'title':
return t('Title')
case 'online':
return t('Publish state')
case 'body':
return t('Body')
case 'filename':
return t('File name')
case 'display_name':
return t('Display name')
default:
return field
}
}
export function changeValue(field: string, value: string) {
if (field !== 'online') return value
if (value === 'false') return t('Draft')
if (value === 'true') return t('Published')
return value
}
export function bodyDeltaLabel(delta: number) {
const sign = delta < 0 ? '-' : '+'
const amount = `${sign}${formatNumber(Math.abs(delta))}`
return Math.abs(delta) === 1
? t('Body edited ({0} character)', [amount])
: t('Body edited ({0} characters)', [amount])
}
+209
View File
@@ -0,0 +1,209 @@
import { formatNumber, t } from '@/i18n'
import type { MessageKey } from '@/i18n/messages'
import type { ActivityItem } from '@/lib/activity'
export interface SentenceSegment {
text: string
strong: boolean
}
interface Template {
success: MessageKey
failed?: MessageKey
}
const TARGET_SLOT = '\u0000'
const DROPPED_SLOT = new RegExp(`[\\s\\u00a0]*:?[\\s\\u00a0]*${TARGET_SLOT}`)
const PURGE_ACTIONS = ['media.purge', 'system.media_purge']
const CATALOGUE: Record<string, Template> = {
'article.create': {
success: 'Created the article {0}',
failed: 'Failed to create the article {0}',
},
'article.edit': { success: 'Edited the article {0}', failed: 'Failed to edit the article {0}' },
'article.publish': {
success: 'Published the article {0}',
failed: 'Failed to publish the article {0}',
},
'article.unpublish': {
success: 'Unpublished the article {0}',
failed: 'Failed to unpublish the article {0}',
},
'article.delete': {
success: 'Deleted the article {0}',
failed: 'Failed to delete the article {0}',
},
'media.upload': {
success: 'Uploaded the image {0}',
failed: 'Failed to upload the image {0}',
},
'media.rename': {
success: 'Renamed the image {0}',
failed: 'Failed to rename the image {0}',
},
'media.delete': { success: 'Deleted the image {0}', failed: 'Failed to delete the image {0}' },
'media.merge': {
success: 'Merged duplicates into the image {0}',
failed: 'Failed to merge duplicates into the image {0}',
},
'media.purge': {
success: 'Purged duplicate images: {0}',
failed: 'Failed to purge duplicate images',
},
'system.media_purge': {
success: 'Ran the scheduled duplicate purge: {0}',
failed: 'Failed to run the scheduled duplicate purge',
},
'user.register': {
success: 'Registered the account {0}',
failed: 'Failed to register the account {0}',
},
'user.verify': {
success: 'Verified the account {0}',
failed: 'Failed to verify the account {0}',
},
'user.update_profile': {
success: 'Updated the profile of {0}',
failed: 'Failed to update the profile of {0}',
},
'invite.create': { success: 'Invited {0}', failed: 'Failed to invite {0}' },
'invite.cancel': {
success: 'Cancelled the invite for {0}',
failed: 'Failed to cancel the invite for {0}',
},
'invite.renew': {
success: 'Renewed the invite for {0}',
failed: 'Failed to renew the invite for {0}',
},
'invite.accept': {
success: 'Accepted the invite for {0}',
failed: 'Failed to accept the invite for {0}',
},
'api_key.create': {
success: 'Created API key {0}',
failed: 'Failed to create API key {0}',
},
'api_key.revoke': {
success: 'Revoked API key {0}',
failed: 'Failed to revoke API key {0}',
},
'auth.login': { success: 'Logged in' },
'auth.login_failed': {
success: 'Failed login attempt for {0}',
failed: 'Failed login attempt for {0}',
},
'auth.logout': { success: 'Logged out' },
'auth.password_reset_requested': { success: 'Requested a password reset' },
'auth.password_reset_completed': { success: 'Reset the password' },
'system.update_checked': {
success: 'Checked for updates',
failed: 'Failed to check for updates',
},
'system.update_downloaded': {
success: 'Downloaded the update {0}',
failed: 'Failed to download the update {0}',
},
'system.restart_requested': { success: 'Requested a restart', failed: 'Failed to restart' },
'system.activity_retention_changed': {
success: 'Changed the activity retention from {0} to {1}',
},
'system.activity_cleanup': { success: 'Removed {0} old events from the activity log' },
}
function count(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
function purgeSummary(params: Record<string, unknown>) {
const articles = count(params.articles_updated)
const failedGroups = count(params.failed_groups)
const parts = [
t('{0} merged', [formatNumber(count(params.merged))]),
articles === 1
? t('1 article updated')
: t('{0} articles updated', [formatNumber(articles)]),
]
if (failedGroups > 0) {
parts.push(
failedGroups === 1
? t('1 group failed')
: t('{0} groups failed', [formatNumber(failedGroups)]),
)
}
return parts.join(', ')
}
function retentionDays(value: unknown) {
const days = count(value)
return days === 0 ? t('Forever') : t('{0} days', [formatNumber(days)])
}
function resolveTemplate(item: ActivityItem): MessageKey | null {
const template = CATALOGUE[item.action]
if (!template) return null
if (item.action === 'system.restart_requested' && item.outcome === 'success') {
return item.params.kind === 'update'
? 'Restarted to apply the update {0}'
: 'Requested a restart'
}
if (item.outcome === 'failed') return template.failed ?? template.success
return template.success
}
function slotValues(item: ActivityItem, template: MessageKey) {
if (PURGE_ACTIONS.includes(item.action) && item.outcome === 'success') {
return [purgeSummary(item.params)]
}
if (item.action === 'system.activity_retention_changed') {
return [retentionDays(item.params.from), retentionDays(item.params.to)]
}
if (item.action === 'system.activity_cleanup') {
return [formatNumber(count(item.params.removed))]
}
return template.includes('{0}') ? [TARGET_SLOT] : []
}
function targetName(item: ActivityItem) {
if (item.action === 'auth.login_failed') return item.actor.name
return item.target?.name ?? ''
}
function usesTargetSlot(values: string[]) {
return values.length === 1 && values[0] === TARGET_SLOT
}
export function composeSentence(item: ActivityItem): SentenceSegment[] {
const template = resolveTemplate(item)
if (template === null) return [{ text: item.action, strong: false }]
if (item.action === 'system.activity_cleanup' && count(item.params.removed) === 1) {
return [{ text: t('Removed 1 old event from the activity log'), strong: false }]
}
const values = slotValues(item, template)
const name = targetName(item)
const rendered = t(template, values)
if (!usesTargetSlot(values)) return [{ text: rendered, strong: false }]
if (!name) return [{ text: rendered.replace(DROPPED_SLOT, ''), strong: false }]
const [before, after] = rendered.split(TARGET_SLOT)
const segments: SentenceSegment[] = [
{ text: before ?? '', strong: false },
{ text: name, strong: true },
]
if (after) segments.push({ text: after, strong: false })
return segments.filter((segment) => segment.text !== '')
}
export function sentenceText(item: ActivityItem) {
return composeSentence(item)
.map((segment) => segment.text)
.join('')
}
export const SENTENCE_ACTIONS = Object.keys(CATALOGUE)
export function sentenceTemplates(action: string) {
return CATALOGUE[action]
}
+109
View File
@@ -0,0 +1,109 @@
import { formatNumber, t } from '@/i18n'
import type { ActivitySummary, RangeKey } from '@/lib/activity'
export type StripKey = 'total' | 'active' | 'failed' | 'security'
export type StripTone = 'neutral' | 'error' | 'warning' | 'calm'
export interface StripCard {
key: StripKey
label: string
value: string
subline: string
icon: string | null
tone: StripTone
series: number[]
}
export const STRIP_KEYS: StripKey[] = ['total', 'active', 'failed', 'security']
export const BAR_COUNT = 7
export const MIN_BAR_HEIGHT = 2
function percent(part: number, whole: number) {
return Math.round((Math.abs(part - whole) / whole) * 100)
}
function totalSubline(summary: ActivitySummary, range: RangeKey) {
if (summary.total === 0) return { text: t('No data yet'), icon: null }
if (summary.previous_total === 0) return { text: t('No earlier data'), icon: null }
if (summary.total === summary.previous_total) return { text: t('No change'), icon: null }
const value = formatNumber(percent(summary.total, summary.previous_total))
const text =
range === 'today'
? t('{0}% vs previous day', [value])
: range === '30d'
? t('{0}% vs previous 30 days', [value])
: range === 'custom'
? t('{0}% vs previous period', [value])
: t('{0}% vs previous 7 days', [value])
const icon =
summary.total >= summary.previous_total ? 'i-lucide-trending-up' : 'i-lucide-trending-down'
return { text, icon }
}
function peopleSubline(summary: ActivitySummary) {
if (summary.total === 0) return t('No data yet')
const { users, api_keys: keys } = summary.active_people
const people = users === 1 ? t('1 person') : t('{0} people', [formatNumber(users)])
const apiKeys = keys === 1 ? t('1 API key') : t('{0} API keys', [formatNumber(keys)])
return `${people}, ${apiKeys}`
}
function failedSubline(summary: ActivitySummary) {
const ratio = (summary.failed / summary.total) * 100
return t('{0}% of all actions', [
formatNumber(ratio, { minimumFractionDigits: 1, maximumFractionDigits: 1 }),
])
}
function flaggedSubline(count: number) {
return count === 1 ? t('1 flagged') : t('{0} flagged', [formatNumber(count)])
}
export function stripCards(summary: ActivitySummary, range: RangeKey): StripCard[] {
const total = totalSubline(summary, range)
const failedCalm = summary.failed === 0
const securityCalm = summary.security === 0
return [
{
key: 'total',
label: t('Total actions'),
value: formatNumber(summary.total),
subline: total.text,
icon: total.icon,
tone: 'neutral',
series: summary.series.total,
},
{
key: 'active',
label: t('Active people'),
value: formatNumber(summary.active_people.users + summary.active_people.api_keys),
subline: peopleSubline(summary),
icon: null,
tone: 'neutral',
series: summary.series.active,
},
{
key: 'failed',
label: t('Failed actions'),
value: formatNumber(summary.failed),
subline: failedCalm ? t('Nothing failed') : failedSubline(summary),
icon: failedCalm ? 'i-lucide-circle-check' : 'i-lucide-circle-x',
tone: failedCalm ? 'calm' : 'error',
series: summary.series.failed,
},
{
key: 'security',
label: t('Security events'),
value: formatNumber(summary.security),
subline: securityCalm ? t('Nothing to flag') : flaggedSubline(summary.security),
icon: securityCalm ? 'i-lucide-circle-check' : 'i-lucide-shield-alert',
tone: securityCalm ? 'calm' : 'warning',
series: summary.series.security,
},
]
}
export function barHeights(series: number[], height: number) {
const peak = Math.max(...series, 1)
return series.map((value) => Math.max(MIN_BAR_HEIGHT, Math.round((value / peak) * height)))
}
+110
View File
@@ -0,0 +1,110 @@
import { locale, t } from '@/i18n'
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
const MINUTE_MS = 60 * 1000
const HOUR_MS = 60 * MINUTE_MS
const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})$/
export interface DayGroup<T> {
key: string
label: string
items: T[]
}
function pad(value: number) {
return String(value).padStart(2, '0')
}
function monthShort(date: Date, timeZone?: 'UTC') {
if (locale.value === 'fr') {
return new Intl.DateTimeFormat('fr', { month: 'short', timeZone }).format(date)
}
return MONTHS[timeZone ? date.getUTCMonth() : date.getMonth()]
}
export function formatAbsoluteDay(date: Date, timeZone?: 'UTC') {
const day = timeZone ? date.getUTCDate() : date.getDate()
const year = timeZone ? date.getUTCFullYear() : date.getFullYear()
return `${day} ${monthShort(date, timeZone)} ${year}`
}
function clock(date: Date, withSeconds: boolean, utc: boolean) {
const hours = utc ? date.getUTCHours() : date.getHours()
const minutes = utc ? date.getUTCMinutes() : date.getMinutes()
const seconds = utc ? date.getUTCSeconds() : date.getSeconds()
const base = `${pad(hours)}:${pad(minutes)}`
return withSeconds ? `${base}:${pad(seconds)}` : base
}
export function localDayKey(date: Date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
export function dayLabel(iso: string, now: Date = new Date()) {
const date = new Date(iso)
const today = startOfDay(now)
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1)
const key = localDayKey(date)
if (key === localDayKey(today)) return t('Today')
if (key === localDayKey(yesterday)) return t('Yesterday')
return formatAbsoluteDay(date)
}
export function whenLabel(iso: string, now: Date = new Date()) {
const date = new Date(iso)
if (localDayKey(date) !== localDayKey(now)) return clock(date, false, false)
const elapsed = Math.max(0, now.getTime() - date.getTime())
if (elapsed < HOUR_MS) {
return t('{0} min ago', [Math.max(1, Math.floor(elapsed / MINUTE_MS))])
}
return t('{0} h ago', [Math.floor(elapsed / HOUR_MS)])
}
export function fullLocalTime(iso: string) {
const date = new Date(iso)
return `${formatAbsoluteDay(date)}, ${clock(date, true, false)}`
}
export function fullUtcTime(iso: string) {
const date = new Date(iso)
return `${formatAbsoluteDay(date, 'UTC')}, ${clock(date, true, true)}`
}
export function groupByDay<T extends { created_at: string }>(
items: T[],
now: Date = new Date(),
): DayGroup<T>[] {
const groups: DayGroup<T>[] = []
for (const item of items) {
const key = localDayKey(new Date(item.created_at))
const last = groups[groups.length - 1]
if (last && last.key === key) {
last.items.push(item)
continue
}
groups.push({ key, label: dayLabel(item.created_at, now), items: [item] })
}
return groups
}
function parseIsoDay(value: string) {
const match = ISO_DAY.exec(value)
if (!match) return null
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 12)
}
export function formatDateRange(from: string, to: string) {
const start = parseIsoDay(from)
const end = parseIsoDay(to)
if (!start || !end) return ''
const sameYear = start.getFullYear() === end.getFullYear()
const sameMonth = sameYear && start.getMonth() === end.getMonth()
if (sameMonth && start.getDate() === end.getDate()) return formatAbsoluteDay(end)
if (sameMonth) return `${start.getDate()} – ${formatAbsoluteDay(end)}`
if (sameYear) return `${start.getDate()} ${monthShort(start)} – ${formatAbsoluteDay(end)}`
return `${formatAbsoluteDay(start)} – ${formatAbsoluteDay(end)}`
}
+15
View File
@@ -0,0 +1,15 @@
const SEGMENT_BUTTON =
'h-10.5 gap-1.5 px-3 text-sm ring-0 md:h-7.5 md:px-2.5 rounded-none whitespace-nowrap'
const SEGMENT_SELECTED =
'bg-secondary-200 font-medium text-primary-900 hover:bg-secondary-200 dark:bg-secondary-300 dark:hover:bg-secondary-300'
const SEGMENT_IDLE = 'bg-default font-normal text-toned'
export function segmentClass(selected: boolean, block = false) {
return [
SEGMENT_BUTTON,
selected ? SEGMENT_SELECTED : SEGMENT_IDLE,
block ? 'flex-1 justify-center' : '',
]
}
export const SEGMENT_GROUP = 'flex shrink-0 border border-accented'
+5
View File
@@ -81,6 +81,11 @@ const router = createRouter({
component: () => import('@/views/UsersView.vue'),
meta: { auth: 'verified', title: 'Users' },
},
{
path: '/activity',
component: () => import('@/views/ActivityView.vue'),
meta: { auth: 'verified', title: 'Activity' },
},
{
path: '/profile',
component: () => import('@/views/ProfileView.vue'),
+296
View File
@@ -0,0 +1,296 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import ActivityCardList from '@/components/ActivityCardList.vue'
import ActivityChips from '@/components/ActivityChips.vue'
import ActivityDetailsPanel from '@/components/ActivityDetailsPanel.vue'
import ActivityEmptyPanel from '@/components/ActivityEmptyPanel.vue'
import ActivityFilterBar from '@/components/ActivityFilterBar.vue'
import ActivityFiltersSheet from '@/components/ActivityFiltersSheet.vue'
import ActivityPager from '@/components/ActivityPager.vue'
import ActivitySummaryStrip from '@/components/ActivitySummaryStrip.vue'
import ActivityTable from '@/components/ActivityTable.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import { useActivityPage } from '@/composables/useActivityPage'
import { useSessionStore } from '@/stores/session'
import { defaultFilters, type ActivityFilters } from '@/lib/activity'
import type { StripKey } from '@/lib/activitySummary'
const DESKTOP_QUERY = '(min-width: 48rem)'
const router = useRouter()
const session = useSessionStore()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const activity = useActivityPage()
const {
filters,
items,
total,
page,
size,
listState,
summary,
summaryState,
people,
chips,
isFullView,
isEmpty,
exporting,
exportDisabled,
showListSkeleton,
showSummarySkeleton,
selectedId,
selectedItem,
position,
canPrevious,
canNext,
} = activity
const sheetOpen = ref(false)
let started = false
let opener: HTMLElement | null = null
const sessionReady = computed(() => session.status === 'ready' && !!session.session?.verified)
const title = computed(() =>
isFullView.value || session.status !== 'ready' ? t('Activity') : t('My activity'),
)
const subtitle = computed(() =>
isFullView.value
? t('Who did what, and when, across your site.')
: t("Everything you've done on this site."),
)
const forbidden = computed(() => listState.value === 'forbidden')
const showStrip = computed(
() =>
isFullView.value &&
!forbidden.value &&
((summaryState.value !== 'failed' && summary.value !== null) || showSummarySkeleton.value),
)
const showRows = computed(
() =>
listState.value === 'ready' ||
(listState.value === 'loading' && (showListSkeleton.value || items.value.length > 0)),
)
const showPager = computed(
() => showRows.value && !showListSkeleton.value && !isEmpty.value && items.value.length > 0,
)
const emptyIsFiltered = computed(() => chips.value.length > 0)
watch(
sessionReady,
(ready) => {
if (ready && !started) {
started = true
activity.start()
}
},
{ immediate: true },
)
function onFilters(next: ActivityFilters) {
activity.applyFilters(next)
}
function onStripCard(key: StripKey) {
if (key === 'total') activity.applyFilters(defaultFilters(), true)
else if (key === 'failed') activity.applyFilters({ ...filters.value, outcome: 'failed' })
else if (key === 'security') activity.applyFilters({ ...filters.value, security: true })
}
function onOpen(item: { id: number }, element: HTMLElement) {
opener = element
activity.openDetails(item.id)
}
async function onClose() {
const id = selectedId.value
activity.closeDetails()
await nextTick()
const row = document.querySelector<HTMLElement>(`[data-activity-id="${id}"]`)
;(row ?? opener)?.focus()
}
function goHome() {
router.push('/home')
}
</script>
<template>
<DashboardShell compact-padding :mobile-title="title">
<template #breadcrumb>
<UBreadcrumb
:items="[{ label: title }]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-45 flex-1 flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">{{ title }}</h1>
<p class="text-muted">{{ subtitle }}</p>
</div>
<UButton
v-if="isFullView && !forbidden"
color="neutral"
variant="outline"
icon="i-lucide-download"
:disabled="exportDisabled"
class="h-11 border border-accented bg-default px-3 text-sm text-highlighted ring-0 md:h-8"
@click="activity.runExport"
>
{{ exporting ? t('Preparing file…') : t('Export CSV') }}
</UButton>
</div>
<ActivityEmptyPanel
v-if="forbidden"
icon="i-lucide-lock"
:title="t('You can\'t view this activity')"
:description="
t(
'Your role doesn\'t include this page. Ask an owner or admin if you need access.',
)
"
:action-label="t('Back to home')"
@action="goHome"
/>
<template v-else>
<ActivitySummaryStrip
v-if="showStrip"
:summary="summary"
:range="filters.range"
:loading="showSummarySkeleton"
:is-desktop="isDesktop"
:outcome="filters.outcome"
:security="filters.security"
@apply-filter="onStripCard"
/>
<ActivityFilterBar
:filters="filters"
:people="people"
:can-filter-people="isFullView"
:is-desktop="isDesktop"
:chip-count="chips.length"
@update:filters="onFilters"
@open-people="activity.ensurePeople"
@open-sheet="sheetOpen = true"
/>
<ActivityChips
:chips="chips"
@remove="activity.removeChip"
@clear="activity.clearAll"
/>
<UAlert
v-if="listState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load activity')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-3 text-xs text-highlighted ring-0 md:h-7"
@click="activity.retry"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<ActivityEmptyPanel
v-else-if="isEmpty && !emptyIsFiltered"
icon="i-lucide-history"
:title="t('No activity yet')"
:description="
isFullView
? t('Actions on articles, media, users and API keys will appear here.')
: t('Your actions will appear here once you start working on the site.')
"
/>
<ActivityEmptyPanel
v-else-if="isEmpty"
icon="i-lucide-search-x"
:title="t('No activity matches these filters')"
:description="t('Try a different search or a wider date range.')"
:action-label="t('Clear filters')"
@action="activity.clearAll"
/>
<template v-else-if="showRows">
<ActivityTable
v-if="isDesktop"
:items="items"
:loading="showListSkeleton"
:selected-id="selectedId"
:show-actor="isFullView"
@open="onOpen"
/>
<ActivityCardList
v-else
:items="items"
:loading="showListSkeleton"
:selected-id="selectedId"
:show-actor="isFullView"
@open="onOpen"
/>
</template>
<ActivityPager
v-if="showPager"
:page="page"
:page-count="activity.pageCount.value"
:total="total"
:size="size"
:from="activity.rangeStart.value"
:to="activity.rangeEnd.value"
:is-desktop="isDesktop"
@update:page="activity.goToPage"
@update:size="activity.setSize"
/>
</template>
</div>
<ActivityDetailsPanel
:item="selectedItem"
:position="position"
:total="total"
:can-previous="canPrevious"
:can-next="canNext"
:is-desktop="isDesktop"
@previous="activity.previous"
@next="activity.next"
@close="onClose"
/>
<ActivityFiltersSheet
v-if="!isDesktop && !forbidden"
v-model:open="sheetOpen"
:filters="filters"
:people="people"
:can-filter-people="isFullView"
:chip-count="chips.length"
:total="total"
@update:filters="onFilters"
@open-people="activity.ensurePeople"
@clear="activity.clearAll"
/>
</DashboardShell>
</template>
@@ -0,0 +1,488 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, useApi } from '@/composables/useApi'
import { initializeLocale } from '@/i18n'
import ActivityPager from '@/components/ActivityPager.vue'
import type { ActivityChange, ActivityItem } from '@/lib/activity'
import {
LIST_PATH,
callsTo,
click,
expectIcon,
findButton,
harness,
mountActivity,
queryObject,
rows,
textOf,
unmountActivity,
} from './activityHarness'
import {
activityItems,
anonymous,
makeItem,
pageOf,
system,
target,
LONG_VALUE,
} from './activityFixtures'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn() }) }
})
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(async () => {
vi.useRealTimers()
unmountActivity()
vi.mocked(useApi).mockReset()
await router.push('/login')
})
function panel() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')
}
function details() {
return document.body.querySelector<HTMLElement>('[data-testid="activity-details"]')
}
async function openRow(index: number) {
const row = rows()[index]!
row.focus()
row.click()
await flushPromises()
await vi.waitFor(() => expect(details()).toBeTruthy())
return row
}
async function openWith(item: ActivityItem, desktop = true) {
await mountActivity({ desktop, list: () => pageOf([item]) })
await openRow(0)
}
function pressEscape() {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
}
describe('Details: a row opens the slide-over and focus returns on close', () => {
it('opens from a click, marks the row selected and closes with Escape', async () => {
await mountActivity()
const row = await openRow(1)
expect(textOf(panel()?.querySelector('[data-slot="title"], h2'))).toContain(
'Activity details',
)
expect(row.getAttribute('aria-selected')).toBe('true')
expect(row.className).toContain('bg-elevated')
expect(textOf(panel())).toContain('Event 2 of 1,284')
pressEscape()
await flushPromises()
await vi.waitFor(() => expect(details()).toBeNull())
expect(document.activeElement).toBe(row)
expect(row.getAttribute('aria-selected')).toBe('false')
})
it('opens from Enter on a focused row and closes with the close button', async () => {
await mountActivity()
const row = rows()[0]!
row.focus()
row.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await flushPromises()
await vi.waitFor(() => expect(details()).toBeTruthy())
await click(panel()!.querySelector('button[aria-label="Close"]'))
await vi.waitFor(() => expect(details()).toBeNull())
expect(document.activeElement).toBe(row)
})
it('closes when the scrim is pressed', async () => {
await mountActivity()
const row = await openRow(0)
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
await vi.waitFor(() => expect(details()).toBeNull())
expect(document.activeElement).toBe(row)
})
})
describe('Details: content', () => {
it('shows the edit with a diff and never the body text', async () => {
await mountActivity()
await openRow(1)
expect(textOf(details()!.querySelector('[data-testid="activity-details-sentence"]'))).toBe(
'Edited the article Why we reuse original floorboards',
)
const changes = Array.from(details()!.querySelectorAll('[data-testid="activity-change"]'))
expect(changes).toHaveLength(2)
expect(textOf(changes[0])).toContain('Title')
expect(textOf(changes[0]!.querySelector('[data-testid="activity-before"]'))).toBe(
'Why we reuse floorboards',
)
expect(textOf(changes[0]!.querySelector('[data-testid="activity-after"]'))).toBe(
'Why we reuse original floorboards',
)
expect(textOf(changes[1])).toBe('Body Body edited (+312 characters)')
expect(changes[1]!.querySelector('[data-testid="activity-before"]')).toBeNull()
})
it.each([
[1, 'Body edited (+1 character)'],
[-48, 'Body edited (-48 characters)'],
])('words a body delta of %i', async (delta, text) => {
await openWith(makeItem({ id: 1, changes: [{ field: 'body', kind: 'body', delta }] }))
expect(textOf(details()!.querySelector('[data-testid="activity-body-change"]'))).toBe(text)
})
it('labels the fields, reads online as Draft and Published, and keeps unknown codes', async () => {
const changes: ActivityChange[] = [
{ field: 'online', kind: 'text', before: 'false', after: 'true' },
{ field: 'filename', kind: 'text', before: 'a.png', after: 'b.png' },
{ field: 'display_name', kind: 'text', before: 'Sam', after: 'Samuel' },
{ field: 'tagline', kind: 'text', before: 'x', after: 'y' },
]
await openWith(makeItem({ id: 1, changes }))
const rowsText = Array.from(
details()!.querySelectorAll('[data-testid="activity-change"]'),
).map(textOf)
expect(rowsText).toEqual([
'Publish stateBeforeDraftAfterPublished',
'File nameBeforea.pngAfterb.png',
'Display nameBeforeSamAfterSamuel',
'taglineBeforexAftery',
])
})
it('clamps long values and toggles Show more on that row only', async () => {
const scrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight')
const clientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', {
configurable: true,
get() {
return (this.textContent ?? '').length > 100 ? 80 : 20
},
})
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
get() {
return 40
},
})
try {
await openWith(
makeItem({
id: 1,
changes: [
{
field: 'title',
kind: 'text',
before: LONG_VALUE,
after: `${LONG_VALUE}more`,
},
{ field: 'filename', kind: 'text', before: 'a.png', after: 'b.png' },
],
}),
)
await vi.waitFor(() => expect(findButton('Show more')).toBeTruthy())
const changes = Array.from(
details()!.querySelectorAll('[data-testid="activity-change"]'),
)
expect(changes[0]!.querySelector('.line-clamp-2')).toBeTruthy()
expect(changes[1]!.querySelector('button')).toBeNull()
expect(
Array.from(document.body.querySelectorAll('button')).filter(
(button) => textOf(button) === 'Show more',
),
).toHaveLength(1)
await click(findButton('Show more'))
expect(changes[0]!.querySelector('.line-clamp-2')).toBeNull()
expect(findButton('Show less')).toBeTruthy()
await click(findButton('Show less'))
expect(changes[0]!.querySelector('.line-clamp-2')).toBeTruthy()
} finally {
if (scrollHeight)
Object.defineProperty(HTMLElement.prototype, 'scrollHeight', scrollHeight)
if (clientHeight)
Object.defineProperty(HTMLElement.prototype, 'clientHeight', clientHeight)
}
})
it('shows the failure reason and wraps a very long target title', async () => {
await openWith(
makeItem({
id: 1,
outcome: 'failed',
failure_reason: 'The article has no cover image. Add one, then publish again.',
target: target('article', '6', 'T'.repeat(150)),
}),
)
const sentence = details()!.querySelector('[data-testid="activity-details-sentence"]')!
expect(sentence.className).toContain('wrap-anywhere')
expect(textOf(sentence.querySelector('.font-semibold'))).toBe('T'.repeat(150))
expect(textOf(details())).toContain('Failed')
const alert = details()!.querySelector('[role="alert"]')!
expect(textOf(alert)).toContain('Why it failed')
expect(textOf(alert)).toContain(
'The article has no cover image. Add one, then publish again.',
)
expect(
details()!.querySelector('[data-testid="activity-target-section"] .wrap-anywhere'),
).toBeTruthy()
})
it('shows no Why it failed alert for a failed event without a reason', async () => {
await openWith(makeItem({ id: 1, outcome: 'failed', failure_reason: null }))
expect(details()!.querySelector('[role="alert"]')).toBeNull()
})
it('shows a deleted target struck through with its explanation and no Changes', async () => {
await openWith(
makeItem({
id: 1,
action: 'media.delete',
category: 'media',
target: target('image', '9', 'old-banner.png', false),
}),
)
const section = details()!.querySelector('[data-testid="activity-target-section"]')!
expect(section.querySelector('.line-through')?.textContent).toBe('old-banner.png')
expect(textOf(section)).toContain('Deleted')
expect(textOf(section)).toContain('Image · 9')
expect(textOf(section)).toContain('This item was deleted. Its name is kept for reference.')
expect(textOf(details())).not.toContain('Changes')
})
it('shows a living target as a link with its type and id', async () => {
await openWith(makeItem({ id: 1 }))
const section = details()!.querySelector('[data-testid="activity-target-section"]')!
expect(section.querySelector('a')?.getAttribute('href')).toBe('/article/8/edit')
expect(textOf(section)).toContain('Article · 8')
await expectIcon(section, 'arrow-up-right')
})
it('omits the Target section when there is no target', async () => {
await openWith(makeItem({ id: 1, action: 'auth.login', target: null }))
expect(details()!.querySelector('[data-testid="activity-target-section"]')).toBeNull()
})
it('shows a system event without a human actor', async () => {
await openWith(
makeItem({
id: 1,
action: 'system.media_purge',
category: 'system',
actor: system,
target: null,
source: 'system',
ip: null,
user_agent: null,
params: { merged: 3, articles_updated: 5 },
}),
)
const text = textOf(details())
expect(textOf(details()!.querySelector('[data-testid="activity-actor"]'))).toBe(
'SystemSystem',
)
await expectIcon(details()!.querySelector('[data-testid="activity-actor"]'), 'cog')
expect(text).toContain('System task')
expect(text.match(/Not applicable/g)).toHaveLength(2)
})
it('shows a failed login as not signed in with the reason, IP and browser', async () => {
await openWith(
makeItem({
id: 1,
action: 'auth.login_failed',
category: 'auth',
actor: anonymous('alex@example.com'),
target: null,
outcome: 'failed',
security: true,
failure_reason: 'Wrong username or password.',
ip: '198.51.100.77',
user_agent: 'Chrome 129 on Windows',
}),
)
expect(textOf(details()!.querySelector('[data-testid="activity-details-sentence"]'))).toBe(
'Failed login attempt for alex@example.com',
)
const text = textOf(details())
expect(text).toContain('Security')
expect(text).toContain('Failed')
expect(textOf(details()!.querySelector('[data-testid="activity-actor"]'))).toBe(
'Not signed in',
)
expect(textOf(details()!.querySelector('[role="alert"]'))).toContain(
'Wrong username or password.',
)
expect(text).toContain('198.51.100.77')
expect(text).toContain('Chrome 129 on Windows')
})
it('shows the time in your time zone and in UTC', async () => {
await openWith(makeItem({ id: 1, created_at: '2026-10-06T10:42:18Z' }))
const when = Array.from(details()!.querySelectorAll('dt')).find(
(term) => textOf(term) === 'When',
)!.nextElementSibling!
const lines = Array.from(when.querySelectorAll('span')).map(textOf)
expect(lines[0]).toMatch(/^6 Oct 2026, \d{2}:\d{2}:\d{2} \(your time\)$/)
expect(lines[1]).toBe('6 Oct 2026, 10:42:18 UTC')
})
it.each([
['web', 'Web app'],
['api_key', 'API key'],
['system', 'System task'],
] as const)('reads source %s as %s', async (source, label) => {
await openWith(makeItem({ id: 1, source }))
const term = Array.from(details()!.querySelectorAll('dt')).find(
(candidate) => textOf(candidate) === 'Source',
)!
expect(textOf(term.nextElementSibling)).toBe(label)
})
})
describe('Details: Previous and Next across pages', () => {
function numbered(page: number) {
return Array.from({ length: 25 }, (_, index) =>
makeItem({ id: (page - 1) * 25 + index + 1, created_at: '2026-10-03T12:30:00Z' }),
)
}
function pagedList(size = 25) {
return (query: URLSearchParams) => {
const page = Number(query.get('page'))
return pageOf(numbered(page), { page, size })
}
}
const footer = () => textOf(panel())
const previous = () => findButton('Previous event')!
const next = () => findButton('Next event')!
it('moves through the list, fetches the neighbour pages and stops at the ends', async () => {
await mountActivity({ list: pagedList() })
await openRow(0)
expect(previous().disabled).toBe(true)
expect(footer()).toContain('Event 1 of 1,284')
for (let step = 0; step < 24; step++) await click(next())
expect(footer()).toContain('Event 25 of 1,284')
expect(callsTo(LIST_PATH)).toHaveLength(1)
await click(next())
expect(queryObject(callsTo(LIST_PATH).at(-1))).toEqual({
range: '7d',
page: '2',
size: '25',
})
expect(footer()).toContain('Event 26 of 1,284')
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 26–50 of 1,284',
)
expect(
textOf(details()!.querySelector('[data-testid="activity-details-sentence"]')),
).toContain('Spring release')
await click(previous())
expect(queryObject(callsTo(LIST_PATH).at(-1))).toMatchObject({ page: '1' })
expect(footer()).toContain('Event 25 of 1,284')
expect(
rows()
.map((row) => row.getAttribute('data-activity-id'))
.at(-1),
).toBe('25')
})
it('disables Next on the very last event', async () => {
await mountActivity({
list: () => pageOf([makeItem({ id: 1 }), makeItem({ id: 2 })], { total: 2 }),
})
await openRow(1)
expect(next().disabled).toBe(true)
expect(footer()).toContain('Event 2 of 2')
})
it('keeps the panel on the current event when the neighbour page fails', async () => {
await mountActivity({
list: (query) => {
if (query.get('page') === '2') throw new ApiError(500)
return pageOf(numbered(1))
},
})
await openRow(24)
const ids = rows().map((row) => row.getAttribute('data-activity-id'))
await click(next())
expect(footer()).toContain('Event 25 of 1,284')
expect(next().disabled).toBe(false)
expect(rows().map((row) => row.getAttribute('data-activity-id'))).toEqual(ids)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
it('numbers events from the page size', async () => {
await mountActivity({ list: pagedList(50) })
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 50)
await flushPromises()
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:page', 2)
await flushPromises()
await openRow(0)
expect(footer()).toContain('Event 51 of 1,284')
})
})
describe('Details below md: a bottom sheet', () => {
it('shows the position in the header and full-width buttons without footer text', async () => {
await mountActivity({ desktop: false })
await openRow(1)
const dialog = panel()!
expect(textOf(dialog)).toContain('Activity details')
expect(textOf(dialog)).toContain('2 of 1,284')
expect(textOf(dialog)).not.toContain('Event 2 of 1,284')
expect(findButton('Previous event')!.className).toContain('max-md:flex-1')
expect(findButton('Next event')!.className).toContain('max-md:flex-1')
expect(dialog.getAttribute('data-side') ?? dialog.className).toContain('bottom')
expect(dialog.className).toContain('h-[88%]')
})
})
describe('Details: counts', () => {
it('counts positions against the filtered total', async () => {
await mountActivity({ list: () => pageOf(activityItems, { total: 41 }) })
await openRow(0)
expect(textOf(panel())).toContain('Event 1 of 41')
})
})
@@ -0,0 +1,509 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { flushPromises } from '@vue/test-utils'
import { CalendarDate } from '@internationalized/date'
import UCalendar from '@nuxt/ui/components/Calendar.vue'
import USelectMenu from '@nuxt/ui/components/SelectMenu.vue'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import { initializeLocale } from '@/i18n'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import ActivityPager from '@/components/ActivityPager.vue'
import ActivityPersonSelect from '@/components/ActivityPersonSelect.vue'
import {
KEYS_PATH,
LIST_PATH,
SUMMARY_PATH,
USERS_PATH,
callsTo,
chipTexts,
click,
clickButton,
findButton,
harness,
lastListQuery,
mountActivity,
queryObject,
rows,
stripCards,
textOf,
unmountActivity,
} from './activityHarness'
import { activityItems, makeItem, pageOf, summaryDefault } from './activityFixtures'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn() }) }
})
const DEFAULT_QUERY = { range: '7d', page: '1', size: '25' }
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(async () => {
vi.useRealTimers()
unmountActivity()
vi.mocked(useApi).mockReset()
await router.push('/login')
})
function searchInput() {
return document.body.querySelector<HTMLInputElement>('input[type="text"]')!
}
async function typeSearch(value: string) {
const input = searchInput()
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
return input
}
async function goToPage(page: number) {
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:page', page)
await flushPromises()
}
function group(label: string) {
return document.body.querySelector(`[role="group"][aria-label="${label}"]`)!
}
async function clickIn(label: string, text: string) {
const button = Array.from(group(label).querySelectorAll('button')).find(
(candidate) => textOf(candidate) === text,
)
await click(button)
}
describe('Filter bar: each control sends its query parameter and resets the page', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
})
async function onPage3() {
await mountActivity({
list: (query) => pageOf(activityItems, { page: Number(query.get('page')) }),
})
await goToPage(3)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '3' })
}
it('types "spring" and waits 300 ms', async () => {
await onPage3()
const before = callsTo(LIST_PATH).length
await typeSearch('spring')
vi.advanceTimersByTime(299)
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(before)
vi.advanceTimersByTime(1)
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, q: 'spring' })
})
it('types "spring" and presses Enter at once', async () => {
await onPage3()
const before = callsTo(LIST_PATH).length
const input = await typeSearch('spring')
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, q: 'spring' })
vi.advanceTimersByTime(500)
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(before + 1)
})
it.each([
['Marta Alves (user 3)', 'user:3', 'Marta Alves', { actor: 'user:3' }],
['Website sync (API key 7)', 'key:7', 'Website sync', { actor: 'key:7' }],
['System', 'system', 'System', { actor: 'system' }],
])('chooses Person %s', async (_, value, label, expected) => {
await onPage3()
harness.wrapper!.findComponent(ActivityPersonSelect).vm.$emit('select', value, label)
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, ...expected })
expect(chipTexts()).toEqual([`Person: ${label}`])
})
it('chooses Category "Users and invites"', async () => {
await onPage3()
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'users')
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, category: 'users' })
})
it('chooses Outcome "Succeeded", then "All"', async () => {
await onPage3()
await clickIn('Outcome', 'Succeeded')
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, outcome: 'success' })
expect(chipTexts()).toEqual(['Outcome: Succeeded'])
await clickIn('Outcome', 'All')
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
})
it.each([
['Today', { range: 'today', page: '1', size: '25' }],
['30 days', { range: '30d', page: '1', size: '25' }],
])('chooses "%s"', async (label, expected) => {
await onPage3()
await clickIn('Dates', label)
expect(queryObject(lastListQuery())).toEqual(expected)
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: expected.range })
})
})
describe('Filter bar: custom dates', () => {
it('applies on the second date and shows the range chip', async () => {
await mountActivity()
await clickIn('Dates', 'Custom')
await vi.waitFor(() =>
expect(harness.wrapper!.findComponent(UCalendar).exists()).toBe(true),
)
const calendar = harness.wrapper!.findComponent(UCalendar)
calendar.vm.$emit('update:modelValue', {
start: new CalendarDate(2026, 9, 1),
end: undefined,
})
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(1)
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
calendar.vm.$emit('update:modelValue', {
start: new CalendarDate(2026, 9, 1),
end: new CalendarDate(2026, 9, 30),
})
await flushPromises()
const expected = { range: 'custom', from: '2026-09-01', to: '2026-09-30' }
expect(queryObject(lastListQuery())).toEqual({ ...expected, page: '1', size: '25' })
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual(expected)
expect(chipTexts()).toEqual(['Dates: 1 – 30 Sep 2026'])
})
it('words a range across months', async () => {
await mountActivity()
await clickIn('Dates', 'Custom')
await vi.waitFor(() =>
expect(harness.wrapper!.findComponent(UCalendar).exists()).toBe(true),
)
harness.wrapper!.findComponent(UCalendar).vm.$emit('update:modelValue', {
start: new CalendarDate(2026, 9, 28),
end: new CalendarDate(2026, 10, 3),
})
await flushPromises()
expect(chipTexts()).toEqual(['Dates: 28 Sep – 3 Oct 2026'])
})
})
describe('Chips', () => {
it('removes one filter at a time and clears all', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountActivity()
await typeSearch('spring')
vi.advanceTimersByTime(300)
await flushPromises()
await clickIn('Outcome', 'Failed')
await clickIn('Dates', '30 days')
expect(chipTexts()).toEqual(['Search: spring', 'Outcome: Failed', 'Dates: 30 days'])
expect(callsTo(SUMMARY_PATH)).toHaveLength(2)
await click(findButton('Remove filter Outcome: Failed'))
expect(queryObject(lastListQuery())).toEqual({
range: '30d',
page: '1',
size: '25',
q: 'spring',
})
expect(chipTexts()).toEqual(['Search: spring', 'Dates: 30 days'])
expect(callsTo(SUMMARY_PATH)).toHaveLength(2)
await clickButton('Clear all')
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: '7d' })
expect(chipTexts()).toEqual([])
expect(findButton('Clear all')).toBeUndefined()
expect(searchInput().value).toBe('')
})
})
describe('Strip cards apply their filter', () => {
it('clears every filter from the Total card', async () => {
await mountActivity()
await clickIn('Outcome', 'Failed')
expect(chipTexts()).toEqual(['Outcome: Failed'])
await click(stripCards()[0])
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
expect(chipTexts()).toEqual([])
stripCards().forEach((card) => expect(card.getAttribute('aria-pressed')).not.toBe('true'))
})
it('does nothing from the Active people card', async () => {
await mountActivity()
const before = harness.calls.length
await click(stripCards()[1])
expect(harness.calls).toHaveLength(before)
expect(stripCards()[1]!.hasAttribute('aria-pressed')).toBe(false)
})
it('applies outcome=failed from the Failed card and marks it pressed', async () => {
await mountActivity()
await click(stripCards()[2])
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, outcome: 'failed' })
expect(chipTexts()).toEqual(['Outcome: Failed'])
expect(findButton('Clear all')?.disabled).toBe(false)
expect(stripCards()[2]!.getAttribute('aria-pressed')).toBe('true')
expect(stripCards()[2]!.className).toContain('border-primary')
})
it('applies security=true from the Security card and marks it pressed', async () => {
await mountActivity()
await click(stripCards()[3])
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, security: 'true' })
expect(chipTexts()).toEqual(['Security events'])
expect(stripCards()[3]!.getAttribute('aria-pressed')).toBe('true')
})
it('stacks the Failed card on other filters without asking for the summary again', async () => {
await mountActivity({
list: (query) =>
pageOf([makeItem({ id: 3 })], { total: query.get('outcome') ? 6 : 1284 }),
})
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
await flushPromises()
await clickIn('Dates', '30 days')
await click(stripCards()[2])
expect(chipTexts()).toEqual([
'Category: Authentication',
'Outcome: Failed',
'Dates: 30 days',
])
expect(callsTo(SUMMARY_PATH)).toHaveLength(2)
expect(queryObject(callsTo(SUMMARY_PATH).at(-1))).toEqual({ range: '30d' })
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 1–6 of 6',
)
})
})
describe('The Person menu', () => {
it('loads its options once, on first open, and lists users, keys then System', async () => {
await mountActivity({
users: () => [
{
kind: 'user',
id: 3,
username: 'marta',
display_name: 'Marta Alves',
role: 'owner',
},
{ kind: 'user', id: 4, username: 'jonas', display_name: '', role: 'admin' },
{ kind: 'invite', id: 9, email: 'x@example.com', status: 'pending' },
],
keys: () => [{ id: 7, name: 'Website sync' }],
})
expect(callsTo(USERS_PATH)).toHaveLength(0)
expect(callsTo(KEYS_PATH)).toHaveLength(0)
const person = harness.wrapper!.findComponent(ActivityPersonSelect)
person.findComponent(USelectMenu).vm.$emit('update:open', true)
await flushPromises()
expect(callsTo(USERS_PATH)).toHaveLength(1)
expect(callsTo(KEYS_PATH)).toHaveLength(1)
const groups = person.findComponent(USelectMenu).props('items') as Record<
string,
string
>[][]
expect(groups.map((items) => items.map((item) => item.label))).toEqual([
['Users', 'Marta Alves', 'jonas'],
['API keys', 'Website sync'],
['System'],
])
expect(
groups
.flat()
.map((item) => item.value)
.filter(Boolean),
).toEqual(['user:3', 'user:4', 'key:7', 'system'])
person.findComponent(USelectMenu).vm.$emit('update:open', true)
await flushPromises()
expect(callsTo(USERS_PATH)).toHaveLength(1)
expect(callsTo(KEYS_PATH)).toHaveLength(1)
})
})
describe('Pagination', () => {
it('moves to page 2, changes the page size and reaches the last page', async () => {
await mountActivity({
list: (query) =>
pageOf(activityItems, {
page: Number(query.get('page')),
size: Number(query.get('size')),
}),
})
const pager = () => textOf(document.body.querySelector('[data-testid="activity-pager"]'))
await goToPage(2)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '2' })
expect(pager()).toContain('Showing 26–50 of 1,284')
const activePage = document.body.querySelector(
'[data-testid="activity-pager"] [aria-current="page"]',
)
expect(textOf(activePage)).toBe('2')
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 50)
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, page: '1', size: '50' })
expect(pager()).toContain('Showing 1–50 of 1,284')
harness.wrapper!.findComponent(ActivityPager).vm.$emit('update:size', 25)
await flushPromises()
await goToPage(52)
expect(pager()).toContain('Showing 1,276–1,284 of 1,284')
expect(harness.wrapper!.findComponent(ActivityPager).props('page')).toBe(52)
expect(rows()).toHaveLength(activityItems.length)
})
})
describe('Mobile: the list is cards and the controls collapse into a sheet', () => {
function mobileList() {
return mountActivity({
desktop: false,
list: () => pageOf(activityItems, { total: 6 }),
})
}
async function applyAuthFailed() {
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
await flushPromises()
}
it('shows search and Filters with a badge, cards, and the short pager', async () => {
await mobileList()
const sheet = () => document.body.querySelector('[data-testid="activity-filters-sheet"]')
expect(document.body.querySelector('[role="group"][aria-label="Outcome"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-filter-count"]')).toBeNull()
expect(textOf(findButton('Filters'))).toBe('Filters')
expect(sheet()).toBeNull()
await click(findButton('Filters'))
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
await flushPromises()
await clickIn('Outcome', 'Failed')
expect(textOf(document.body.querySelector('[data-testid="activity-filter-count"]'))).toBe(
'2',
)
expect(rows().every((row) => row.getAttribute('data-testid') === 'activity-card')).toBe(
true,
)
expect(document.body.querySelector('[role="table"]')).toBeNull()
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Page 1 of 1',
)
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'1–6 of 6',
)
})
it('draws the sheet with its footer and closes it from "Show 6 results"', async () => {
await mobileList()
await click(findButton('Filters'))
await applyAuthFailed()
const sheet = document.body.querySelector('[data-testid="activity-filters-sheet"]')!
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain('Filters')
expect(textOf(sheet)).toContain('Person')
expect(textOf(sheet)).toContain('Category')
expect(textOf(sheet)).toContain('Outcome')
expect(textOf(sheet)).toContain('Dates')
expect(findButton('Clear all')?.disabled).toBe(false)
expect(sheet.querySelectorAll('input[type="date"]')).toHaveLength(0)
await clickIn('Dates', 'Custom')
expect(sheet.querySelectorAll('input[type="date"]')).toHaveLength(2)
await clickButton('Show 6 results')
await vi.waitFor(() =>
expect(
document.body.querySelector('[data-testid="activity-filters-sheet"]'),
).toBeNull(),
)
expect(chipTexts()).toEqual(['Category: Authentication'])
})
it('applies the two custom dates from the sheet', async () => {
await mobileList()
await click(findButton('Filters'))
await clickIn('Dates', 'Custom')
const [from, to] = Array.from(
document.body.querySelectorAll<HTMLInputElement>('input[type="date"]'),
)
from!.value = '2026-09-01'
from!.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
expect(callsTo(LIST_PATH)).toHaveLength(1)
to!.value = '2026-09-30'
to!.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
expect(queryObject(lastListQuery())).toEqual({
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
page: '1',
size: '25',
})
})
it('words the footer for a single result and disables Clear all at 0 filters', async () => {
await mountActivity({
desktop: false,
list: () => pageOf([activityItems[0]!], { total: 1 }),
})
await click(findButton('Filters'))
expect(textOf(findButton('Show 1 result'))).toBe('Show 1 result')
expect(findButton('Clear all')?.disabled).toBe(true)
})
})
describe('Summary follows only the date range', () => {
it('does not request the summary again for the other filters', async () => {
await mountActivity({ summary: () => summaryDefault })
await clickIn('Outcome', 'Failed')
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'media')
await flushPromises()
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
})
})
@@ -0,0 +1,238 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { initializeLocale, setLocale } from '@/i18n'
import {
EXPORT_PATH,
LIST_PATH,
SUMMARY_PATH,
callsTo,
click,
days,
findButton,
harness,
mountActivity,
queryObject,
rows,
stripCards,
textOf,
unmountActivity,
} from './activityHarness'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import { activityItems, makeItem, pageOf, summaryDefault } from './activityFixtures'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
})
const clicked: HTMLAnchorElement[] = []
beforeEach(() => {
toastAdd = vi.fn()
clicked.length = 0
localStorage.clear()
initializeLocale()
URL.createObjectURL = vi.fn(() => 'blob:activity')
URL.revokeObjectURL = vi.fn()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
this: HTMLAnchorElement,
) {
clicked.push(this)
})
})
afterEach(async () => {
vi.restoreAllMocks()
unmountActivity()
vi.mocked(useApi).mockReset()
localStorage.clear()
initializeLocale()
await router.push('/login')
})
function toastCall(index = 0) {
const options = toastAdd.mock.calls[index]![0] as {
color: string
icon: string
title: string
}
return { color: options.color, icon: options.icon, title: options.title }
}
describe('Export', () => {
it('prepares the file with the list filters, then downloads it and toasts', async () => {
let release: (blob: Blob) => void = () => undefined
const pending = new Promise<Blob>((resolve) => (release = resolve))
await mountActivity({ exportFile: () => pending })
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
await flushPromises()
await click(
Array.from(
document.body.querySelectorAll<HTMLButtonElement>(
'[role="group"][aria-label="Dates"] button',
),
).find((button) => textOf(button) === '30 days'),
)
await click(findButton('Export CSV'))
expect(callsTo(EXPORT_PATH)).toHaveLength(1)
expect(queryObject(callsTo(EXPORT_PATH)[0])).toEqual({ category: 'auth', range: '30d' })
const busy = findButton('Preparing file…')!
expect(busy.disabled).toBe(true)
expect(clicked).toHaveLength(0)
release(new Blob(['when,actor'], { type: 'text/csv' }))
await flushPromises()
expect(clicked).toHaveLength(1)
expect(clicked[0]!.download).toBe('activity.csv')
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
expect(toastCall()).toEqual({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Export started. Your file will download shortly.',
})
expect(findButton('Export CSV')?.disabled).toBe(false)
})
it.each([
['a 500 response', () => new ApiError(500)],
['a 403 response', () => new ApiError(403)],
['a network failure', () => new NetworkError()],
])('raises an error toast after %s and re-enables the button', async (_, failure) => {
await mountActivity({
exportFile: () => {
throw failure()
},
})
await click(findButton('Export CSV'))
expect(toastCall()).toEqual({
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't export activity. Try again.",
})
expect(clicked).toHaveLength(0)
expect(findButton('Export CSV')?.disabled).toBe(false)
})
})
describe('My activity: a role that is not owner or admin sees its own events', () => {
it.each([['editor'], [undefined]])('role %s', async (role) => {
await mountActivity({
role,
list: () => pageOf(activityItems, { total: 41 }),
})
expect(textOf(document.body.querySelector('h1'))).toBe('My activity')
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe(
'My activity',
)
expect(textOf(document.body)).toContain("Everything you've done on this site.")
expect(stripCards()).toHaveLength(0)
expect(textOf(document.body)).not.toContain('All people')
expect(findButton('Export CSV')).toBeUndefined()
const headers = Array.from(document.body.querySelectorAll('[role="columnheader"]')).map(
textOf,
)
expect(headers).toEqual(['When', 'Action', 'Target', 'Outcome'])
expect(document.body.querySelector('[data-testid="activity-actor"]')).toBeNull()
expect(callsTo(SUMMARY_PATH)).toHaveLength(0)
expect(callsTo(EXPORT_PATH)).toHaveLength(0)
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({ range: '7d', page: '1', size: '25' })
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 1–25 of 41',
)
})
it('shows no actor line on the cards below md', async () => {
await mountActivity({ role: 'editor', desktop: false })
expect(rows()).toHaveLength(activityItems.length)
expect(document.body.querySelector('[data-testid="activity-actor"]')).toBeNull()
})
it('shows its own empty copy', async () => {
await mountActivity({ role: 'editor', list: () => pageOf([], { total: 0 }) })
expect(textOf(document.body)).toContain('No activity yet')
expect(textOf(document.body)).toContain(
'Your actions will appear here once you start working on the site.',
)
})
it('shows the same details panel with the user position', async () => {
await mountActivity({ role: 'editor', list: () => pageOf(activityItems, { total: 41 }) })
rows()[0]!.click()
await flushPromises()
await vi.waitFor(() => expect(document.body.querySelector('[role="dialog"]')).toBeTruthy())
expect(textOf(document.body.querySelector('[role="dialog"]'))).toContain('Event 1 of 41')
expect((findButton('Previous event') as HTMLButtonElement).disabled).toBe(true)
expect(textOf(document.body.querySelector('[data-testid="activity-actor"]'))).toBe(
'MAMarta Alves',
)
})
})
describe('The French interface', () => {
it('composes the chrome, the strip, the sentences and the day separators in French', async () => {
setLocale('fr')
await mountActivity({
summary: () => summaryDefault,
list: () =>
pageOf([
makeItem({ id: 1, created_at: '2026-10-03T12:30:00Z' }),
makeItem({
id: 2,
created_at: '2026-10-03T12:20:00Z',
outcome: 'failed',
target: { type: 'article', id: '8', name: 'Spring release', exists: true },
}),
]),
})
expect(textOf(document.body.querySelector('h1'))).toBe('Activité')
expect(textOf(findButton('Exporter en CSV'))).toBe('Exporter en CSV')
const strip = stripCards().map(textOf)
expect(strip[0]).toContain('Actions au total')
expect(strip[1]).toContain('Personnes actives')
expect(strip[1]).toContain('7 personnes')
expect(strip[2]).toContain('Actions en échec')
expect(strip[3]).toContain('Événements de sécurité')
expect(strip[3]).toContain('11 signalés')
expect(days()).toEqual(['3 oct. 2026'])
const sentences = Array.from(
document.body.querySelectorAll('[data-testid="activity-sentence"]'),
).map(textOf)
expect(sentences).toEqual([
'Article publié : Spring release',
"Échec de la publication de l'article Spring release",
])
const search = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
expect(search.placeholder).toBe("Rechercher dans l'activité")
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Lignes par page',
)
expect(textOf(rows()[0]!)).toContain('Réussie')
expect(textOf(rows()[1]!)).toContain('Échec')
})
it('shows the French My activity title', async () => {
setLocale('fr')
await mountActivity({ role: 'editor' })
expect(textOf(document.body.querySelector('h1'))).toBe('Mon activité')
})
})
@@ -0,0 +1,666 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { initializeLocale } from '@/i18n'
import type { ActivityItem } from '@/lib/activity'
import {
LIST_PATH,
SUMMARY_PATH,
callsTo,
chipTexts,
clickButton,
days,
findButton,
expectIcon,
mountActivity,
queryObject,
rows,
stripCards,
textOf,
unmountActivity,
} from './activityHarness'
import {
activityItems,
anonymous,
apiKey,
makeItem,
pageOf,
summaryDefault,
summaryNone,
summaryZero,
system,
target,
user,
} from './activityFixtures'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: toastAdd }) }
})
beforeEach(() => {
toastAdd = vi.fn()
localStorage.clear()
initializeLocale()
})
afterEach(async () => {
vi.useRealTimers()
unmountActivity()
vi.mocked(useApi).mockReset()
await router.push('/login')
})
function pageText() {
return textOf(document.body)
}
function stripTexts() {
return stripCards().map(textOf)
}
describe('Navigation', () => {
it('lists Activity between Users and Integration with the history icon and the current page', async () => {
await mountActivity()
const links = Array.from(
document.body.querySelectorAll<HTMLAnchorElement>('nav[aria-label="Main"] a'),
)
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/home',
'/article',
'/media',
'/users',
'/activity',
'/integration',
'/settings',
])
const activity = links[4]!
expect(activity.getAttribute('aria-current')).toBe('page')
await expectIcon(activity, 'history')
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe('Activity')
expect(router.currentRoute.value.meta).toEqual({ auth: 'verified', title: 'Activity' })
})
})
describe('Default: an owner sees the strip, the filter bar and a day-grouped table', () => {
it('draws the header, the strip, the filter bar, the rows and the pager', async () => {
await mountActivity()
expect(textOf(document.body.querySelector('h1'))).toBe('Activity')
expect(pageText()).toContain('Who did what, and when, across your site.')
expect(findButton('Export CSV')?.disabled).toBe(false)
expect(stripTexts()).toEqual([
expect.stringContaining('Total actions'),
expect.stringContaining('Active people'),
expect.stringContaining('Failed actions'),
expect.stringContaining('Security events'),
])
expect(stripTexts()[0]).toContain('1,284')
expect(stripTexts()[0]).toContain('12% vs previous 7 days')
expect(stripTexts()[1]).toContain('9')
expect(stripTexts()[1]).toContain('7 people, 2 API keys')
expect(stripTexts()[2]).toContain('0.5% of all actions')
expect(stripTexts()[3]).toContain('11 flagged')
await expectIcon(stripCards()[0], 'trending-up')
await expectIcon(stripCards()[2], 'circle-x')
await expectIcon(stripCards()[3], 'shield-alert')
const search = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
expect(search.placeholder).toBe('Search activity')
expect(pageText()).toContain('All people')
expect(pageText()).toContain('All categories')
const outcome = document.body.querySelector('[role="group"][aria-label="Outcome"]')!
expect(outcome.querySelector('[aria-pressed="true"]')?.textContent?.trim()).toBe('All')
const dates = document.body.querySelector('[role="group"][aria-label="Dates"]')!
expect(dates.querySelector('[aria-pressed="true"]')?.textContent?.trim()).toBe('7 days')
expect(chipTexts()).toEqual([])
expect(rows()).toHaveLength(activityItems.length)
expect(days()).toEqual(['3 Oct 2026', '2 Oct 2026'])
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 1–25 of 1,284',
)
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Rows per page',
)
})
it('requests the list and the summary once each with the default query', async () => {
await mountActivity()
expect(callsTo(LIST_PATH)).toHaveLength(1)
expect(queryObject(callsTo(LIST_PATH)[0])).toEqual({ range: '7d', page: '1', size: '25' })
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
expect(queryObject(callsTo(SUMMARY_PATH)[0])).toEqual({ range: '7d' })
})
it('gives every When cell the full local time with seconds as its title', async () => {
await mountActivity()
const cells = Array.from(
document.body.querySelectorAll<HTMLElement>('[data-testid="activity-when"]'),
)
expect(cells).toHaveLength(activityItems.length)
for (const cell of cells) {
expect(cell.textContent?.trim()).toMatch(/^\d{2}:\d{2}$/)
expect(cell.title).toMatch(/^\d{1,2} \w{3} 2026, \d{2}:\d{2}:\d{2}$/)
}
})
})
describe('Rows: the Actor cell and the sentence follow the event', () => {
const cases: {
name: string
item: ActivityItem
actor: string
sentence: string
badge: string
shield: boolean
}[] = [
{
name: 'user, success',
item: makeItem({ id: 1 }),
actor: 'Marta Alves',
sentence: 'Published the article Spring release',
badge: 'Succeeded',
shield: false,
},
{
name: 'api key',
item: makeItem({
id: 2,
action: 'media.upload',
category: 'media',
actor: apiKey(7, 'Website sync'),
target: target('image', '4', 'north-wall.jpg'),
}),
actor: 'Website sync API key',
sentence: 'Uploaded the image north-wall.jpg',
badge: 'Succeeded',
shield: false,
},
{
name: 'system',
item: makeItem({
id: 3,
action: 'system.media_purge',
category: 'system',
actor: system,
target: null,
params: { merged: 3, articles_updated: 5 },
}),
actor: 'System System',
sentence: 'Ran the scheduled duplicate purge: 3 merged, 5 articles updated',
badge: 'Succeeded',
shield: false,
},
{
name: 'anonymous failed login',
item: makeItem({
id: 4,
action: 'auth.login_failed',
category: 'auth',
actor: anonymous('alex@example.com'),
target: null,
outcome: 'failed',
security: true,
}),
actor: 'Not signed in',
sentence: 'Failed login attempt for alex@example.com',
badge: 'Failed',
shield: true,
},
{
name: 'failed publish',
item: makeItem({ id: 5, actor: user(4, 'Inès Duarte'), outcome: 'failed' }),
actor: 'Inès Duarte',
sentence: 'Failed to publish the article Spring release',
badge: 'Failed',
shield: false,
},
{
name: 'security revoke',
item: makeItem({
id: 6,
action: 'api_key.revoke',
category: 'api_keys',
actor: user(2, 'Jonas Weber'),
target: target('api_key', '71', 'Mobile app'),
security: true,
}),
actor: 'Jonas Weber',
sentence: 'Revoked API key Mobile app',
badge: 'Succeeded',
shield: true,
},
]
it.each(cases)('$name', async ({ item, actor, sentence, badge, shield }) => {
await mountActivity({ list: () => pageOf([item]) })
const row = rows()[0]!
const cells = row.querySelectorAll('[role="cell"]')
expect(textOf(cells[1])).toContain(actor.split(' ')[0])
if (actor === 'Not signed in') {
expect(textOf(cells[1])).toBe('Not signed in')
}
expect(textOf(row.querySelector('[data-testid="activity-sentence"]'))).toBe(sentence)
const strong = Array.from(
row.querySelectorAll('[data-testid="activity-sentence"] .font-semibold'),
).map(textOf)
const name = item.action === 'auth.login_failed' ? item.actor.name : item.target?.name
expect(strong).toEqual(name ? [name] : [])
expect(textOf(cells[cells.length - 1])).toBe(badge)
expect(!!row.querySelector('[aria-label="Security"]')).toBe(shield)
expect(row.className.includes('shadow-flag')).toBe(shield)
})
it('shows the actor initials, the key and the system badge', async () => {
await mountActivity({ list: () => pageOf(activityItems) })
const actorCells = Array.from(
document.body.querySelectorAll('[data-testid="activity-actor"]'),
)
expect(textOf(actorCells[0])).toBe('MAMarta Alves')
const keyCell = actorCells.find((cell) => textOf(cell).includes('Website sync'))!
expect(textOf(keyCell)).toContain('API key')
await expectIcon(keyCell, 'key-round')
const systemCell = actorCells.find((cell) => textOf(cell).startsWith('System'))!
expect(textOf(systemCell)).toBe('SystemSystem')
await expectIcon(systemCell, 'cog')
const anonymousCell = actorCells.find((cell) => textOf(cell) === 'Not signed in')!
await expectIcon(anonymousCell, 'user-round-x')
})
})
describe('Rows: targets', () => {
it('strikes a deleted target through with a Deleted badge and links a living one', async () => {
await mountActivity({
list: () =>
pageOf([
makeItem({
id: 1,
action: 'media.delete',
category: 'media',
target: target('image', '9', 'old-banner.png', false),
}),
makeItem({ id: 2 }),
]),
})
const targets = Array.from(
document.body.querySelectorAll('[data-testid="activity-target"]'),
)
expect(targets[0]!.querySelector('.line-through')?.textContent).toBe('old-banner.png')
expect(textOf(targets[0])).toContain('Deleted')
expect(textOf(targets[0])).toContain('Image')
const link = targets[1]!.querySelector('a')!
expect(link.getAttribute('href')).toBe('/article/8/edit')
expect(textOf(targets[1])).toContain('Article')
})
it('navigates from the target link without opening the details', async () => {
await mountActivity()
const link = document.body.querySelector<HTMLAnchorElement>(
'[data-testid="activity-row"] a[href="/article/8/edit"]',
)!
link.click()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article/8/edit'))
expect(document.body.querySelector('[data-testid="activity-details"]')).toBeNull()
})
it.each([
['article', '8', '/article/8/edit'],
['image', '4', '/media'],
['user', '3', '/users'],
['invite', '2', '/users'],
['api_key', '71', '/integration/api-keys'],
])('links a %s target to %s', async (type, id, href) => {
await mountActivity({
list: () => pageOf([makeItem({ id: 1, target: target(type, id, 'Some name') })]),
})
expect(
document.body.querySelector('[data-testid="activity-target"] a')?.getAttribute('href'),
).toBe(href)
})
it('shows a target type with no page as plain text', async () => {
await mountActivity({
list: () =>
pageOf([
makeItem({
id: 1,
action: 'system.update_downloaded',
category: 'system',
actor: system,
target: target('release', 'v1.4.0', 'v1.4.0'),
}),
]),
})
const cell = document.body.querySelector('[data-testid="activity-target"]')!
expect(cell.querySelector('a')).toBeNull()
expect(textOf(cell)).toBe('v1.4.0Release')
})
it('shows a muted dash when there is no target', async () => {
await mountActivity({
list: () => pageOf([makeItem({ id: 1, target: null, action: 'auth.login' })]),
})
const cell = document.body.querySelector(
'[data-testid="activity-row"] [role="cell"]:nth-child(4)',
)!
expect(textOf(cell)).toBe('—')
expect(cell.querySelector('.text-muted')).toBeTruthy()
})
it('renders an unknown action code as it is and still opens the details', async () => {
await mountActivity({
list: () => pageOf([makeItem({ id: 1, action: 'article.archive', target: null })]),
})
const row = rows()[0]!
expect(textOf(row.querySelector('[data-testid="activity-sentence"]'))).toBe(
'article.archive',
)
row.click()
await flushPromises()
expect(document.body.querySelector('[data-testid="activity-details"]')).toBeTruthy()
})
})
describe('Day grouping', () => {
it('groups by local day in API order under separators', async () => {
await mountActivity({
list: () =>
pageOf([
makeItem({ id: 1, created_at: '2026-10-03T12:30:00Z' }),
makeItem({ id: 2, created_at: '2026-10-03T12:10:00Z' }),
makeItem({ id: 3, created_at: '2026-10-02T12:40:00Z' }),
]),
})
expect(days()).toEqual(['3 Oct 2026', '2 Oct 2026'])
const order = Array.from(
document.body.querySelectorAll(
'[data-testid="activity-day"], [data-testid="activity-row"]',
),
).map((element) => element.getAttribute('data-activity-id') ?? textOf(element))
expect(order).toEqual(['3 Oct 2026', '1', '2', '2 Oct 2026', '3'])
})
})
describe('Loading: skeletons after 150 ms', () => {
it('draws nothing in place for 150 ms, then 4 skeleton cards and 9 skeleton rows', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const never = new Promise(() => undefined)
await mountActivity({ list: () => never, summary: () => never })
expect(document.body.querySelector('h1')?.textContent).toBe('Activity')
expect(findButton('Export CSV')?.disabled).toBe(false)
expect(
document.body
.querySelector('[data-testid="activity-filter-bar"] input')
?.getAttribute('aria-label'),
).toBe('Search activity')
expect(document.body.querySelector('[data-testid="activity-strip-skeleton"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-skeleton-row"]')).toBeNull()
vi.advanceTimersByTime(149)
await flushPromises()
expect(document.body.querySelector('[data-testid="activity-skeleton-row"]')).toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="activity-strip-skeleton"]'),
).toHaveLength(4)
expect(
document.body.querySelectorAll('[data-testid="activity-skeleton-row"]'),
).toHaveLength(9)
expect(document.body.querySelector('[role="table"]')?.getAttribute('aria-busy')).toBe(
'true',
)
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
})
it('draws 5 skeleton cards below md and replaces them with the data on resolve', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
let resolveList: (value: unknown) => void = () => undefined
const pending = new Promise((resolve) => (resolveList = resolve))
await mountActivity({ desktop: false, list: () => pending })
vi.advanceTimersByTime(150)
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="activity-skeleton-card"]'),
).toHaveLength(5)
resolveList(pageOf(activityItems))
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="activity-skeleton-card"]'),
).toHaveLength(0)
expect(rows()).toHaveLength(activityItems.length)
})
})
describe('Strip', () => {
it('shows a calm 0 for a quiet period', async () => {
await mountActivity({ summary: () => summaryZero })
const texts = stripTexts()
expect(texts[0]).toContain('214')
expect(texts[0]).toContain('4% vs previous 7 days')
await expectIcon(stripCards()[0], 'trending-down')
expect(texts[1]).toContain('5')
expect(texts[1]).toContain('4 people, 1 API key')
expect(texts[2]).toContain('Nothing failed')
expect(texts[3]).toContain('Nothing to flag')
await expectIcon(stripCards()[2], 'circle-check')
await expectIcon(stripCards()[3], 'circle-check')
expect(stripCards()[3]!.className).not.toContain('rc-warning-soft')
expect(stripCards()[2]!.className).not.toContain('rc-error-soft')
const bars = stripCards()[2]!.querySelectorAll<HTMLElement>(
'[data-testid="activity-bars"] span',
)
expect(Array.from(bars).map((bar) => bar.style.height)).toEqual(Array(7).fill('2px'))
})
it.each([
[40, 0, 'No earlier data'],
[40, 40, 'No change'],
])('reads the Total sub-line for %i against %i as %s', async (total, previous, subline) => {
await mountActivity({
summary: () => ({ ...summaryDefault, total, previous_total: previous }),
})
expect(stripTexts()[0]).toContain(subline)
await expectIcon(stripCards()[0], 'trending-up', false)
await expectIcon(stripCards()[0], 'trending-down', false)
})
it('sizes the bars from the series on desktop and mobile', async () => {
await mountActivity()
const heights = Array.from(
stripCards()[0]!.querySelectorAll<HTMLElement>('[data-testid="activity-bars"] span'),
).map((bar) => bar.style.height)
expect(heights).toEqual(['9px', '13px', '11px', '18px', '16px', '20px', '13px'])
unmountActivity()
await mountActivity({ desktop: false })
const mobile = Array.from(
stripCards()[0]!.querySelectorAll<HTMLElement>('[data-testid="activity-bars"] span'),
).map((bar) => bar.style.height)
expect(mobile).toEqual(['7px', '11px', '9px', '14px', '12px', '16px', '11px'])
})
it('is not drawn when the summary fails, and the list still works', async () => {
for (const failure of [new ApiError(500), new ApiError(403), new NetworkError()]) {
await mountActivity({
summary: () => {
throw failure
},
})
expect(stripCards()).toHaveLength(0)
expect(document.body.querySelector('[data-testid="activity-strip"]')).toBeNull()
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(rows()).toHaveLength(activityItems.length)
expect(textOf(document.body.querySelector('[data-testid="activity-pager"]'))).toContain(
'Showing 1–25 of 1,284',
)
unmountActivity()
}
})
})
describe('Empty states', () => {
it('shows No activity yet with Export disabled and no table or pager', async () => {
await mountActivity({ list: () => pageOf([], { total: 0 }), summary: () => summaryNone })
expect(pageText()).toContain('No activity yet')
expect(pageText()).toContain(
'Actions on articles, media, users and API keys will appear here.',
)
await expectIcon(document.body.querySelector('[data-slot="root"]'), 'history')
expect(findButton('Export CSV')?.disabled).toBe(true)
expect(document.body.querySelector('[role="table"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
const texts = stripTexts()
expect(texts[0]).toContain('No data yet')
expect(texts[1]).toContain('No data yet')
expect(texts[2]).toContain('Nothing failed')
expect(texts[3]).toContain('Nothing to flag')
})
it('offers Clear filters when filters match nothing and resets them', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
let empty = false
await mountActivity({
list: () => (empty ? pageOf([], { total: 0 }) : pageOf(activityItems)),
})
empty = true
const search = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
search.value = 'sitemap'
search.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
vi.advanceTimersByTime(300)
await flushPromises()
expect(chipTexts()).toEqual(['Search: sitemap'])
expect(pageText()).toContain('No activity matches these filters')
expect(pageText()).toContain('Try a different search or a wider date range.')
expect(findButton('Export CSV')?.disabled).toBe(false)
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
empty = false
await clickButton('Clear filters')
expect(queryObject(callsTo(LIST_PATH).at(-1))).toEqual({
range: '7d',
page: '1',
size: '25',
})
expect(rows()).toHaveLength(activityItems.length)
expect(chipTexts()).toEqual([])
})
})
describe('Error with retry', () => {
it.each([
['a 500 response', () => new ApiError(500)],
['a 400 response', () => new ApiError(400)],
['a network failure', () => new NetworkError()],
])('replaces the table with an alert after %s and retries', async (_, failure) => {
let failing = true
await mountActivity({
list: () => {
if (failing) throw failure()
return pageOf(activityItems)
},
})
expect(stripCards()).toHaveLength(4)
expect(
document.body
.querySelector('[data-testid="activity-filter-bar"] input')
?.getAttribute('aria-label'),
).toBe('Search activity')
const alert = document.body.querySelector('[role="alert"]')!
expect(textOf(alert)).toContain("Couldn't load activity")
expect(textOf(alert)).toContain('Check your connection and try again.')
expect(findButton('Export CSV')?.disabled).toBe(true)
expect(document.body.querySelector('[role="table"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
failing = false
await clickButton('Try again')
expect(callsTo(LIST_PATH)).toHaveLength(2)
expect(queryObject(callsTo(LIST_PATH)[1])).toEqual(queryObject(callsTo(LIST_PATH)[0]))
expect(rows()).toHaveLength(activityItems.length)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
})
describe('Forbidden (403)', () => {
it('shows only the header and the lock panel, and goes home', async () => {
await mountActivity({
list: () => {
throw new ApiError(403)
},
})
expect(textOf(document.body.querySelector('h1'))).toBe('Activity')
expect(findButton('Export CSV')).toBeUndefined()
expect(stripCards()).toHaveLength(0)
expect(document.body.querySelector('[data-testid="activity-filter-bar"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-chips"]')).toBeNull()
expect(document.body.querySelector('[role="table"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
await expectIcon(document.body.querySelector('main, #dashboard-content'), 'lock')
expect(pageText()).toContain("You can't view this activity")
expect(pageText()).toContain(
"Your role doesn't include this page. Ask an owner or admin if you need access.",
)
await clickButton('Back to home')
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
})
it('shows no panel for a 401, which the API client handles globally', async () => {
await mountActivity({
list: () => {
throw new ApiError(401)
},
})
expect(pageText()).not.toContain("You can't view this activity")
expect(pageText()).not.toContain("Couldn't load activity")
expect(document.body.querySelector('[role="table"]')).toBeNull()
})
it('shows no panel for a 403 on an unverified session', async () => {
await mountActivity({
verified: false,
list: () => {
throw new ApiError(403)
},
})
expect(callsTo(LIST_PATH)).toHaveLength(0)
expect(pageText()).not.toContain("You can't view this activity")
})
})
@@ -1503,6 +1503,7 @@ describe('Sidebar', () => {
'/article',
'/media',
'/users',
'/activity',
'/integration',
'/settings',
])
+2 -2
View File
@@ -279,11 +279,11 @@ describe('Default: the usual first visit with no display name', () => {
expect(request).not.toHaveBeenCalled()
})
it('marks none of the six navigation items as the current page', async () => {
it('marks none of the seven navigation items as the current page', async () => {
await mountApp()
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(6)
expect(links).toHaveLength(7)
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
+205
View File
@@ -0,0 +1,205 @@
import type {
ActivityActor,
ActivityChange,
ActivityItem,
ActivityPage,
ActivitySummary,
ActivityTarget,
} from '@/lib/activity'
const LONG_TITLE =
'The complete guide to restoring a nineteenth-century timber-framed farmhouse: structural surveys, lime mortar, breathable insulation and what it all costs'
const WEB_IP = '203.0.113.24'
const WEB_AGENT = 'Firefox 131 on macOS'
export const user = (id: number, name: string): ActivityActor => ({ type: 'user', id, name })
export const apiKey = (id: number, name: string): ActivityActor => ({
type: 'api_key',
id,
name,
})
export const system: ActivityActor = { type: 'system', id: null, name: 'System' }
export const anonymous = (name: string): ActivityActor => ({ type: 'anonymous', id: null, name })
export const target = (type: string, id: string, name: string, exists = true): ActivityTarget => ({
type,
id,
name,
exists,
})
export function makeItem(overrides: Partial<ActivityItem> & { id: number }): ActivityItem {
return {
created_at: '2026-10-03T12:30:00Z',
action: 'article.publish',
category: 'articles',
actor: user(3, 'Marta Alves'),
target: target('article', '8', 'Spring release'),
outcome: 'success',
failure_reason: null,
security: false,
source: 'web',
ip: WEB_IP,
user_agent: WEB_AGENT,
params: {},
changes: [],
...overrides,
}
}
const titleChange: ActivityChange = {
field: 'title',
kind: 'text',
before: 'Why we reuse floorboards',
after: 'Why we reuse original floorboards',
}
const bodyChange: ActivityChange = { field: 'body', kind: 'body', delta: 312 }
export const activityItems: ActivityItem[] = [
makeItem({
id: 1,
created_at: '2026-10-03T12:58:00Z',
changes: [{ field: 'online', kind: 'text', before: 'false', after: 'true' }],
}),
makeItem({
id: 2,
created_at: '2026-10-03T12:45:00Z',
action: 'article.edit',
target: target('article', '5', 'Why we reuse original floorboards'),
changes: [titleChange, bodyChange],
}),
makeItem({
id: 3,
created_at: '2026-10-03T12:40:00Z',
action: 'auth.login_failed',
category: 'auth',
actor: anonymous('alex@example.com'),
target: null,
outcome: 'failed',
failure_reason: 'The password did not match. 3 attempts in 10 minutes.',
security: true,
ip: '198.51.100.77',
user_agent: 'Chrome 129 on Windows',
}),
makeItem({
id: 4,
created_at: '2026-10-03T12:35:00Z',
action: 'api_key.revoke',
category: 'api_keys',
actor: user(2, 'Jonas Weber'),
target: target('api_key', '71', 'Mobile app'),
security: true,
}),
makeItem({
id: 5,
created_at: '2026-10-03T12:20:00Z',
action: 'system.media_purge',
category: 'system',
actor: system,
target: null,
source: 'system',
ip: null,
user_agent: null,
params: { merged: 3, articles_updated: 5 },
}),
makeItem({
id: 6,
created_at: '2026-10-03T12:10:00Z',
action: 'media.upload',
category: 'media',
actor: apiKey(7, 'Website sync'),
target: target('image', '4', 'north-wall.jpg'),
source: 'api_key',
}),
makeItem({
id: 7,
created_at: '2026-10-03T12:05:00Z',
action: 'media.delete',
category: 'media',
actor: user(3, 'Marta Alves'),
target: target('image', '9', 'old-banner.png', false),
}),
makeItem({
id: 8,
created_at: '2026-10-03T12:00:00Z',
outcome: 'failed',
failure_reason: 'The article has no cover image. Add one, then publish again.',
actor: user(4, 'Inès Duarte'),
target: target('article', '6', LONG_TITLE),
}),
makeItem({
id: 9,
created_at: '2026-10-02T12:50:00Z',
action: 'system.activity_cleanup',
category: 'system',
actor: system,
target: null,
source: 'system',
ip: null,
user_agent: null,
params: { removed: 3 },
}),
makeItem({
id: 10,
created_at: '2026-10-02T12:40:00Z',
action: 'user.update_profile',
category: 'users',
actor: user(4, 'Inès Duarte'),
target: target('user', '8', 'Sam Rivera'),
changes: [
{ field: 'display_name', kind: 'text', before: 'Sam Rivera', after: 'Samuel Rivera' },
],
}),
]
export const LONG_VALUE = 'Renovation notes and practical guides from the Renew studio. '.repeat(3)
export const TOTAL = 1284
export const SIZE = 25
export function pageOf(
items: ActivityItem[],
{ total = TOTAL, page = 1, size = SIZE }: { total?: number; page?: number; size?: number } = {},
): ActivityPage {
return { items, total, page, size }
}
export const summaryDefault: ActivitySummary = {
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],
},
}
export const summaryZero: ActivitySummary = {
total: 214,
previous_total: 223,
active_people: { users: 4, api_keys: 1 },
failed: 0,
security: 0,
series: {
total: [3, 5, 2, 6, 4, 1, 2],
active: [2, 3, 2, 3, 2, 1, 2],
failed: [0, 0, 0, 0, 0, 0, 0],
security: [0, 0, 0, 0, 0, 0, 0],
},
}
const ZEROS = [0, 0, 0, 0, 0, 0, 0]
export const summaryNone: ActivitySummary = {
total: 0,
previous_total: 0,
active_people: { users: 0, api_keys: 0 },
failed: 0,
security: 0,
series: { total: ZEROS, active: ZEROS, failed: ZEROS, security: ZEROS },
}
+206
View File
@@ -0,0 +1,206 @@
import { expect, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { addCollection } from '@iconify/vue'
import { getIconData } from '@iconify/utils'
import lucide from 'virtual:lucide-subset'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ActivityView from '../ActivityView.vue'
import { activityItems, pageOf, summaryDefault } from './activityFixtures'
addCollection(lucide)
export type Responder = (query: URLSearchParams, call: number) => unknown
export interface ActivityApiMock {
list?: Responder
summary?: Responder
exportFile?: Responder
users?: Responder
keys?: Responder
}
export interface MountOptions extends ActivityApiMock {
role?: string
desktop?: boolean
verified?: boolean
}
export interface RecordedCall {
path: string
query: URLSearchParams
}
export const LIST_PATH = '/v1/activity'
export const SUMMARY_PATH = '/v1/activity/summary'
export const EXPORT_PATH = '/v1/activity/export'
export const USERS_PATH = '/v1/user/'
export const KEYS_PATH = '/v1/api-key/'
export const harness = {
calls: [] as RecordedCall[],
wrapper: null as VueWrapper | null,
}
const counters = new Map<string, number>()
function respond(responder: Responder | undefined, query: URLSearchParams, key: string) {
if (!responder) throw new Error(`Unexpected request: ${key}`)
const call = (counters.get(key) ?? 0) + 1
counters.set(key, call)
return responder(query, call)
}
export function callsTo(path: string) {
return harness.calls.filter((call) => call.path === path).map((call) => call.query)
}
export async function mountActivity(options: MountOptions = {}) {
const { desktop = true, verified = true } = options
const role = 'role' in options ? options.role : 'owner'
const api: ActivityApiMock = {
list: () => pageOf(activityItems),
summary: () => summaryDefault,
exportFile: () => new Blob(['when,actor'], { type: 'text/csv' }),
users: () => [],
keys: () => [],
...options,
}
harness.calls = []
counters.clear()
setViewportWidth(desktop ? DESKTOP_WIDTH : MOBILE_WIDTH)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = {
id: 2,
verified,
username: 'ines',
displayName: 'Inès Duarte',
role: role as 'owner' | undefined,
}
session.status = 'ready'
const record = (path: string) => {
const url = new URL(path, 'http://localhost')
harness.calls.push({ path: url.pathname, query: url.searchParams })
return url
}
const request = vi.fn(async (path: string) => {
const url = record(path)
const key = url.pathname
switch (key) {
case LIST_PATH:
return respond(api.list, url.searchParams, key)
case SUMMARY_PATH:
return respond(api.summary, url.searchParams, key)
case USERS_PATH:
return respond(api.users, url.searchParams, key)
case KEYS_PATH:
return respond(api.keys, url.searchParams, key)
default:
throw new Error(`Unexpected request: ${path}`)
}
})
const requestBlob = vi.fn(async (path: string) => {
const url = record(path)
return respond(api.exportFile, url.searchParams, url.pathname) as Blob
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified, role }),
request,
requestBlob,
} as unknown as ReturnType<typeof useApi>)
await router.push('/activity')
harness.wrapper = mount(ActivityView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return harness.wrapper
}
export function unmountActivity() {
harness.wrapper?.unmount()
harness.wrapper = null
document.body.innerHTML = ''
}
export function textOf(element: Element | null | undefined) {
return (element?.textContent ?? '').replace(/\s+/g, ' ').trim()
}
export function rows() {
return Array.from(
document.body.querySelectorAll<HTMLElement>(
'[data-testid="activity-row"], [data-testid="activity-card"]',
),
)
}
export function days() {
return Array.from(document.body.querySelectorAll('[data-testid="activity-day"]')).map(textOf)
}
export function findButton(text: string) {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('button')).find(
(button) => textOf(button) === text || button.getAttribute('aria-label') === text,
)
}
export async function click(element: Element | null | undefined) {
;(element as HTMLElement | undefined)?.click()
await flushPromises()
}
export async function clickButton(text: string) {
await click(findButton(text))
}
export function chipTexts() {
return Array.from(document.body.querySelectorAll('[data-testid="activity-chip"]')).map(textOf)
}
export function stripCards() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('[data-testid="activity-strip-card"]'),
)
}
export async function setSearch(value: string) {
const input = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
return input
}
export function lastListQuery() {
const queries = callsTo(LIST_PATH)
return queries[queries.length - 1]
}
export function queryObject(query: URLSearchParams | undefined) {
return Object.fromEntries(query?.entries() ?? [])
}
export { DESKTOP_WIDTH, MOBILE_WIDTH }
export function hasIcon(root: Element | null | undefined, name: string) {
const body = getIconData(lucide, name)?.body?.replace(/<(\w+)([^>]*?)\/>/g, '<$1$2></$1>')
if (!root || !body) return false
return Array.from(root.querySelectorAll('svg')).some((svg) => svg.innerHTML === body)
}
export async function expectIcon(root: Element | null | undefined, name: string, present = true) {
if (present) {
await vi.waitFor(() => expect(hasIcon(root, name)).toBe(true))
return
}
await new Promise((resolve) => setTimeout(resolve, 50))
expect(hasIcon(root, name)).toBe(false)
}
+377
View File
@@ -2576,7 +2576,384 @@ const mediaPurgeStates: Record<string, VisualState> = {
'Offline: button disabled': mediaState(mediaDuplicatesApi(), goOffline, mediaDuplicatesPath),
}
const activityOwnerSession: ApiMock = {
status: 200,
body: {
id: 2,
is_verified: true,
username: 'ines',
email: 'ines.duarte@renew.example',
display_name: 'Inès Duarte',
role: 'owner',
},
}
const activityEditorSession: ApiMock = {
status: 200,
body: {
id: 3,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
role: 'editor',
},
}
const activitySummaryDefault = {
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],
},
}
const activitySummaryNone = {
total: 0,
previous_total: 0,
active_people: { users: 0, api_keys: 0 },
failed: 0,
security: 0,
series: {
total: [0, 0, 0, 0, 0, 0, 0],
active: [0, 0, 0, 0, 0, 0, 0],
failed: [0, 0, 0, 0, 0, 0, 0],
security: [0, 0, 0, 0, 0, 0, 0],
},
}
const activityLongTitle =
'The complete guide to restoring a nineteenth-century timber-framed farmhouse: structural surveys, lime mortar, breathable insulation and what it all costs'
function activityEvent(
id: number,
createdAt: string,
action: string,
category: string,
actor: { type: string; id: number | null; name: string },
extra: Record<string, unknown> = {},
) {
return {
id,
created_at: createdAt,
action,
category,
actor,
target: null,
outcome: 'success',
failure_reason: null,
security: false,
source: 'web',
ip: '203.0.113.24',
user_agent: 'Firefox 131 on macOS',
params: {},
changes: [],
...extra,
}
}
const activityTarget = (type: string, id: string, name: string, exists = true) => ({
type,
id,
name,
exists,
})
const activityBoardEvents = [
activityEvent(
1,
'2026-10-06T09:42:18Z',
'article.publish',
'articles',
{ type: 'user', id: 3, name: 'Marta Alves' },
{
target: activityTarget('article', '8', 'Spring release'),
changes: [{ field: 'online', kind: 'text', before: 'false', after: 'true' }],
},
),
activityEvent(
2,
'2026-10-06T09:15:04Z',
'article.edit',
'articles',
{ type: 'user', id: 3, name: 'Marta Alves' },
{
target: activityTarget('article', '5', 'Why we reuse original floorboards'),
changes: [
{
field: 'title',
kind: 'text',
before: 'Why we reuse floorboards',
after: 'Why we reuse original floorboards',
},
{ field: 'body', kind: 'body', delta: 312 },
],
},
),
activityEvent(
3,
'2026-10-06T08:31:47Z',
'auth.login_failed',
'auth',
{ type: 'anonymous', id: null, name: 'alex@example.com' },
{
outcome: 'failed',
security: true,
failure_reason: 'The password did not match. 3 attempts in 10 minutes.',
ip: '198.51.100.77',
user_agent: 'Chrome 129 on Windows',
},
),
activityEvent(
4,
'2026-10-06T07:50:09Z',
'api_key.revoke',
'api_keys',
{ type: 'user', id: 2, name: 'Jonas Weber' },
{
target: activityTarget('api_key', '71', 'Mobile app'),
security: true,
ip: '203.0.113.90',
user_agent: 'Safari 18 on iOS',
},
),
activityEvent(
5,
'2026-10-06T02:00:02Z',
'system.media_purge',
'system',
{ type: 'system', id: null, name: 'System' },
{
source: 'system',
ip: null,
user_agent: null,
params: { merged: 3, articles_updated: 5 },
},
),
activityEvent(
6,
'2026-10-05T17:20:36Z',
'invite.accept',
'users',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{ target: activityTarget('invite', '2', 'jonas@renew.example'), security: true },
),
activityEvent(
7,
'2026-10-05T15:05:51Z',
'media.upload',
'media',
{ type: 'api_key', id: 7, name: 'Website sync' },
{
target: activityTarget('image', '4', 'north-wall.jpg'),
source: 'api_key',
ip: '192.0.2.18',
user_agent: 'node-fetch 3.3',
},
),
activityEvent(
8,
'2026-10-05T13:40:13Z',
'media.delete',
'media',
{ type: 'user', id: 3, name: 'Marta Alves' },
{ target: activityTarget('image', '9', 'old-banner.png', false) },
),
activityEvent(
9,
'2026-10-05T10:12:27Z',
'article.publish',
'articles',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{
target: activityTarget('article', '6', activityLongTitle),
outcome: 'failed',
failure_reason: 'The article has no cover image. Add one, then publish again.',
},
),
activityEvent(
10,
'2026-10-05T02:00:01Z',
'system.activity_cleanup',
'system',
{ type: 'system', id: null, name: 'System' },
{ source: 'system', ip: null, user_agent: null, params: { removed: 142 } },
),
activityEvent(
11,
'2026-10-03T15:48:22Z',
'article.edit',
'articles',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{
target: activityTarget('article', '2', 'Choosing lime plaster for old stone walls'),
changes: [
{
field: 'title',
kind: 'text',
before: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes.',
after: 'Renovation notes, project write-ups and practical guides from the Renew studio team in Lisbon, covering timber, lime plaster and stone restoration for older homes, plus a seasonal maintenance checklist.',
},
],
},
),
activityEvent(
12,
'2026-10-03T14:02:40Z',
'api_key.create',
'api_keys',
{ type: 'user', id: 2, name: 'Jonas Weber' },
{
target: activityTarget('api_key', '71', 'Mobile app', false),
security: true,
ip: '203.0.113.90',
},
),
activityEvent(
13,
'2026-10-03T13:20:05Z',
'user.update_profile',
'users',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{
target: activityTarget('user', '8', 'Sam Rivera'),
changes: [
{
field: 'display_name',
kind: 'text',
before: 'Sam Rivera',
after: 'Samuel Rivera',
},
],
},
),
activityEvent(
14,
'2026-10-03T10:36:58Z',
'media.upload',
'media',
{ type: 'user', id: 3, name: 'Marta Alves' },
{ target: activityTarget('image', '5', 'floorboards-copy.jpg') },
),
activityEvent(
15,
'2026-10-03T08:05:12Z',
'auth.password_reset_completed',
'auth',
{ type: 'user', id: 4, name: 'Inès Duarte' },
{ target: activityTarget('user', '8', 'Sam Rivera'), security: true },
),
]
function activityPage(items: unknown[], total: number) {
return { items, total, page: 1, size: 25 }
}
function activityApi(
overrides: Record<string, ApiMock> = {},
session: ApiMock = activityOwnerSession,
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': session,
'GET /v1/activity': { status: 200, body: activityPage(activityBoardEvents, 1284) },
'GET /v1/activity/summary': { status: 200, body: activitySummaryDefault },
...overrides,
}
}
const activityClockSensitive: VisualState = { path: '/activity', skip: 'clock-sensitive' }
async function filterActivityToNothing(page: Page) {
const mobile = (page.viewportSize()?.width ?? 0) < 768
const search = page.getByPlaceholder('Search activity')
await search.fill('sitemap')
await search.press('Enter')
if (mobile) {
await page.getByRole('button', { name: /^Filters/ }).click()
}
await page.getByRole('button', { name: 'Category', exact: true }).click()
await page.getByRole('option', { name: 'Media', exact: true }).click()
await page
.getByRole('group', { name: 'Outcome' })
.getByRole('button', { name: 'Failed', exact: true })
.click()
if (mobile) {
await page.getByRole('button', { name: /^Show \d+ results?$/ }).click()
}
await page.getByText('No activity matches these filters').waitFor()
await page.mouse.move(0, 0)
}
const activityStates: Record<string, VisualState> = {
'Default, large dataset': activityClockSensitive,
Loading: {
path: '/activity',
api: activityApi({
'GET /v1/activity': { status: 200, body: activityPage([], 0), hang: true },
'GET /v1/activity/summary': { status: 200, body: activitySummaryDefault, hang: true },
}),
steps: async (page) => {
await page.waitForTimeout(400)
},
},
'Zero-value summary strip': activityClockSensitive,
'Filtered, card applied': activityClockSensitive,
'Mobile filters sheet': activityClockSensitive,
'Empty, no activity yet': {
path: '/activity',
api: activityApi({
'GET /v1/activity': { status: 200, body: activityPage([], 0) },
'GET /v1/activity/summary': { status: 200, body: activitySummaryNone },
}),
},
'Empty, filters match nothing': {
path: '/activity',
api: activityApi({
'GET /v1/activity': {
status: 200,
bodyFor: (url) =>
url.searchParams.has('q') ||
url.searchParams.has('category') ||
url.searchParams.has('outcome')
? activityPage([], 0)
: activityPage(activityBoardEvents, 1284),
},
}),
steps: filterActivityToNothing,
},
'Error with retry': {
path: '/activity',
api: activityApi({ 'GET /v1/activity': { status: 500 } }),
},
'Forbidden (403)': {
path: '/activity',
api: activityApi({ 'GET /v1/activity': { status: 403 } }, activityEditorSession),
},
'Edit with a diff': activityClockSensitive,
'Settings diff, long text': activityClockSensitive,
'Failed action, very long target title': activityClockSensitive,
'Deleted target, no diff': activityClockSensitive,
'System-initiated, no human actor': activityClockSensitive,
'Security event, failed login': activityClockSensitive,
Default: activityClockSensitive,
Empty: {
path: '/activity',
api: activityApi(
{ 'GET /v1/activity': { status: 200, body: activityPage([], 0) } },
activityEditorSession,
),
},
Details: activityClockSensitive,
}
export const visualStates: Record<string, Record<string, VisualState>> = {
activity: activityStates,
profile: {
'Account menu': {
path: '/home',