feat: activity screen in the SPA, route /activity
This commit is contained in:
1 parent
e37a46a027
commit
35db442cd3
47 files changed
+7582
-11
No files matched your search
@@ -0,0 +1,693 @@
|
||||
Feature: Activity screen, details slide-over and My activity
|
||||
|
||||
As a verified RenewCMS user
|
||||
I want /activity to read as a plain-language timeline of who did what and when, with filters,
|
||||
a summary strip, CSV export and a details panel for each event
|
||||
So that an owner can audit the site without reading a log, and any other role sees its own history
|
||||
|
||||
Background:
|
||||
Given /activity is a verified route (meta.auth "verified", meta.title "Activity") rendered as
|
||||
ActivityView (views/ActivityView.vue, the "ActivityPage" of docs/design/components.md) inside
|
||||
DashboardShell, per docs/design/routes.md and docs/design/screens/activity.md
|
||||
And the activity API already exists (features/activity-log-backend.feature and
|
||||
features/activity-log-producers.feature) and this slice wires the SPA to it unchanged, with
|
||||
no Go change
|
||||
And the Home card and the Settings "Retention" card of the same board are not in this slice
|
||||
And where docs/design prose and the board frame ActivityFrame.dc.html differ, the frame is
|
||||
followed
|
||||
And the views read the session role from GET /v1/auth/me: "owner" and "admin" are the full
|
||||
view; any other or absent value is the regular-user view ("My activity"), which no real
|
||||
account can reach today and is covered by Vitest and one visual state only
|
||||
And a full view loads, in parallel, GET /v1/activity?range=7d&page=1&size=25 and GET
|
||||
/v1/activity/summary?range=7d; a regular-user view loads only GET /v1/activity with the same
|
||||
query and never calls /summary or /export, and never sends an actor parameter
|
||||
And the list query always sends range, page and size explicitly, and adds q, actor, category,
|
||||
outcome, security, from and to only when set (outcome "all" is not sent, from and to only
|
||||
with range=custom); the summary query sends only range, from and to; the export query sends
|
||||
the list query without page and size
|
||||
And filters and the page live in component state, not in the URL, and reset when the user
|
||||
leaves /activity
|
||||
And useApi's global handling covers 401 and the unverified 403
|
||||
(features/api_client_global_error_handling.feature); this screen maps every other failure
|
||||
And DashboardShell's navigation gains "Activity" (icon i-lucide-history, linking to /activity)
|
||||
between "Users" and "Integration", in the sidebar and in the mobile drawer, so it lists Home,
|
||||
Articles, Media, Users, Activity, Integration, Settings in that order, for every verified
|
||||
user; "Activity" has aria-current="page" on /activity; the specs that list the destinations
|
||||
are amended accordingly (named in the QA file)
|
||||
And the PageHeader breadcrumb and the mobile title read the page title: "Activity", or
|
||||
"My activity" for the regular-user view
|
||||
And the page padding is 24 px on desktop and 16 px on mobile with a 16 px gap between the
|
||||
header, the strip, the filter bar, the chips, the table and the pager
|
||||
And the header is the title "Activity" (regular user: "My activity") with the subtitle "Who
|
||||
did what, and when, across your site." (regular user: "Everything you've done on this
|
||||
site."), and, in the full view except Forbidden, an outline button with icon download and
|
||||
the label "Export CSV" at the right, aligned to the bottom of the title block
|
||||
And sentences are composed in the SPA from the event's action code, its outcome, its params
|
||||
and its target name through the existing i18n (English keys, French in messages.ts), never
|
||||
stored as text; the target name is user content, is inserted untranslated as a semibold
|
||||
segment, and is the last slot of every template, so a missing target name drops the slot
|
||||
with its leading space and colon
|
||||
And the success sentences are, in English and French (the actor sits in its own column, so
|
||||
sentences start with the verb; {0} is the target name):
|
||||
| action | English | French |
|
||||
| article.create | Created the article {0} | Article créé : {0} |
|
||||
| article.edit | Edited the article {0} | Article modifié : {0} |
|
||||
| article.publish | Published the article {0} | Article publié : {0} |
|
||||
| article.unpublish | Unpublished the article {0} | Article dépublié : {0} |
|
||||
| article.delete | Deleted the article {0} | Article supprimé : {0} |
|
||||
| media.upload | Uploaded the image {0} | Image téléversée : {0} |
|
||||
| media.rename | Renamed the image {0} | Image renommée : {0} |
|
||||
| media.delete | Deleted the image {0} | Image supprimée : {0} |
|
||||
| media.merge | Merged duplicates into the image {0} | Doublons fusionnés dans l'image {0} |
|
||||
| media.purge | Purged duplicate images: {0} merged, {1} articles updated | Doublons purgés : {0} fusionnés, {1} articles mis à jour |
|
||||
| system.media_purge | Ran the scheduled duplicate purge: {0} merged, {1} articles updated | Purge automatique des doublons : {0} fusionnés, {1} articles mis à jour |
|
||||
| user.register | Registered the account {0} | Compte créé : {0} |
|
||||
| user.verify | Verified the account {0} | Compte vérifié : {0} |
|
||||
| user.update_profile | Updated the profile of {0} | Profil mis à jour : {0} |
|
||||
| invite.create | Invited {0} | Invitation envoyée à {0} |
|
||||
| invite.cancel | Cancelled the invite for {0} | Invitation annulée : {0} |
|
||||
| invite.renew | Renewed the invite for {0} | Invitation renouvelée : {0} |
|
||||
| invite.accept | Accepted the invite for {0} | Invitation acceptée : {0} |
|
||||
| api_key.create | Created API key {0} | Clé d'API créée : {0} |
|
||||
| api_key.revoke | Revoked API key {0} | Clé d'API révoquée : {0} |
|
||||
| auth.login | Logged in | Connexion |
|
||||
| auth.logout | Logged out | Déconnexion |
|
||||
| auth.password_reset_requested | Requested a password reset | Réinitialisation du mot de passe demandée |
|
||||
| auth.password_reset_completed | Reset the password | Mot de passe réinitialisé |
|
||||
| system.update_checked | Checked for updates | Recherche de mises à jour |
|
||||
| system.update_downloaded | Downloaded the update {0} | Mise à jour téléchargée : {0} |
|
||||
| system.restart_requested (kind restart) | Requested a restart | Redémarrage demandé |
|
||||
| system.restart_requested (kind update) | Restarted to apply the update {0} | Redémarrage pour appliquer la mise à jour {0} |
|
||||
| system.activity_retention_changed | Changed the activity retention from {0} to {1} | Durée de conservation de l'activité modifiée : {0} → {1} |
|
||||
| system.activity_cleanup | Removed {0} old events from the activity log | {0} anciens événements supprimés du journal d'activité |
|
||||
And the failed sentences, used when outcome is "failed", are:
|
||||
| action | English | French |
|
||||
| article.create | Failed to create the article {0} | Échec de la création de l'article {0} |
|
||||
| article.edit | Failed to edit the article {0} | Échec de la modification de l'article {0} |
|
||||
| article.publish | Failed to publish the article {0} | Échec de la publication de l'article {0} |
|
||||
| article.unpublish | Failed to unpublish the article {0} | Échec de la dépublication de l'article {0} |
|
||||
| article.delete | Failed to delete the article {0} | Échec de la suppression de l'article {0} |
|
||||
| media.upload | Failed to upload the image {0} | Échec du téléversement de l'image {0} |
|
||||
| media.rename | Failed to rename the image {0} | Échec du renommage de l'image {0} |
|
||||
| media.delete | Failed to delete the image {0} | Échec de la suppression de l'image {0} |
|
||||
| media.merge | Failed to merge duplicates into the image {0} | Échec de la fusion des doublons dans l'image {0} |
|
||||
| media.purge | Failed to purge duplicate images | Échec de la purge des doublons |
|
||||
| system.media_purge | Failed to run the scheduled duplicate purge | Échec de la purge automatique des doublons |
|
||||
| user.register | Failed to register the account {0} | Échec de la création du compte {0} |
|
||||
| user.verify | Failed to verify the account {0} | Échec de la vérification du compte {0} |
|
||||
| user.update_profile | Failed to update the profile of {0} | Échec de la mise à jour du profil de {0} |
|
||||
| invite.create | Failed to invite {0} | Échec de l'invitation de {0} |
|
||||
| invite.cancel | Failed to cancel the invite for {0} | Échec de l'annulation de l'invitation de {0} |
|
||||
| invite.renew | Failed to renew the invite for {0} | Échec du renouvellement de l'invitation de {0} |
|
||||
| invite.accept | Failed to accept the invite for {0} | Échec de l'acceptation de l'invitation de {0} |
|
||||
| api_key.create | Failed to create API key {0} | Échec de la création de la clé d'API {0} |
|
||||
| api_key.revoke | Failed to revoke API key {0} | Échec de la révocation de la clé d'API {0} |
|
||||
| auth.login_failed | Failed login attempt for {0} | Échec de connexion pour {0} |
|
||||
| system.update_checked | Failed to check for updates | Échec de la recherche de mises à jour |
|
||||
| system.update_downloaded | Failed to download the update {0} | Échec du téléchargement de la mise à jour {0} |
|
||||
| system.restart_requested | Failed to restart | Échec du redémarrage |
|
||||
And an action whose failed sentence is not listed (for example auth.login) renders its success
|
||||
sentence with the Failed badge; an action code that is not in the catalogue renders the raw
|
||||
code (for example "article.archive") and never throws
|
||||
And for auth.login_failed the slot {0} is the actor's name (the attempted username, since the
|
||||
event has no target), while the Actor column reads "Not signed in"
|
||||
And counts in params are shown with the locale's number format; "{0} old events" and "{1}
|
||||
articles updated" use the singular form for 1 ("1 old event", "1 article updated"); a
|
||||
media.purge or system.media_purge event with params.failed_groups appends ", {2} groups
|
||||
failed" ("1 group failed") in English and ", {2} groupes en échec" in French;
|
||||
system.activity_retention_changed shows days as "{n} days" and 0 as "Forever" ("{n} jours",
|
||||
"Toujours")
|
||||
And the category icons are: articles file-text, media image, users users, api_keys key-round,
|
||||
auth shield-check, system server-cog; the category labels are Articles, Media, Users and
|
||||
invites, API keys, Authentication, System
|
||||
And the Actor cell is the actor's name with: a UAvatar of initials (first letters of the first
|
||||
two words, upper case) for type "user"; a key-round icon avatar and an "API key" badge for
|
||||
"api_key"; a cog icon avatar and a "System" badge for "system"; a user-round-x icon avatar
|
||||
and the text "Not signed in" for "anonymous"
|
||||
And the Target cell is "—" (muted) when target is null; when target.exists it is the name as an
|
||||
underlined link with an arrow-up-right icon, plus the target type as a muted second line
|
||||
(Article, Image, User, Invite, API key, Release); when it does not exist the name is struck
|
||||
through, muted, with a "Deleted" badge (icon trash-2) and the type line; the link goes to
|
||||
/article/{id}/edit for article, /media for image, /users for user and invite,
|
||||
/integration/api-keys for api_key, and a target type with no page (release) is plain text
|
||||
And the Outcome cell is a badge with icon and word, never colour alone: "Succeeded" (check,
|
||||
bordered) or "Failed" (circle-x, --rc-error-soft background, --ui-error text)
|
||||
And a row whose event has security true shows a shield-alert icon in --ui-warning after the
|
||||
sentence and a 2 px inset --ui-warning edge on its left side; on mobile it also shows a
|
||||
"Security" badge (shield-alert, --rc-warning-soft, --ui-warning)
|
||||
And the table (md and up) has the columns When, Actor, Action, Target, Outcome in a grid
|
||||
96px 210px minmax(0,1fr) 230px 120px (regular user: When, Action, Target, Outcome in 96px
|
||||
minmax(0,1fr) 230px 120px), a 36 px header on --ui-bg-elevated with an arrow-down icon on
|
||||
When, day separator rows of 32 px on --ui-bg-muted with the label in 12 px semibold, and
|
||||
rows at least 52 px high with a top border; a selected row (details open) and a hovered row
|
||||
use --ui-bg-elevated
|
||||
And rows are grouped by the user's local calendar day under a separator labelled "Today",
|
||||
"Yesterday" or the date as "3 Oct 2026" (French "3 oct. 2026"), in the order the API
|
||||
returns them (newest first); the When cell shows the time as drawn, relative within today
|
||||
("2 min ago", "29 min ago", "1 h ago") and the local clock time "HH:mm" on earlier days,
|
||||
and carries the full local time "3 Oct 2026, 14:20:05" as its title attribute
|
||||
And below md the table is replaced by a stacked list of cards (ActivityCardList) with the same
|
||||
separators: a first line with the actor (avatar, name, badge; hidden for the regular user)
|
||||
and the time at the right, the category icon and the sentence (15 px, up to 3 lines), then a
|
||||
line of badges: the Outcome badge, "Security" when flagged, and "Target deleted" when the
|
||||
target is gone; each card is at least 44 px high as a touch target
|
||||
And rows and cards are focusable, open the details on click and on Enter, and a click on the
|
||||
target link navigates to the target without opening the details
|
||||
And the summary strip (ActivitySummaryStrip, full view only) has four cards that are real
|
||||
buttons: "Total actions", "Active people", "Failed actions", "Security events", each with
|
||||
the number (locale-formatted), a sub-line, and a seven-bar sparkline of seven div bars,
|
||||
4 px wide, 2 px apart, oldest at the left, bar height round(value / max(series, 1) * H)
|
||||
with a 2 px minimum and H of 20 px on desktop and 16 px on mobile, drawn from
|
||||
series.total, series.active, series.failed and series.security
|
||||
And on desktop the strip is a 4-column grid with a 12 px gap; below md it is, as drawn in
|
||||
ActivityFrame.dc.html, a single row of 156 px cards with an 8 px gap that is clipped at the
|
||||
page edge
|
||||
And the Total sub-line is "{pct}% vs previous 7 days" with icon trending-up when total is at
|
||||
least previous_total and trending-down otherwise, pct being the rounded absolute change
|
||||
against previous_total; for the ranges Today, 30 days and Custom the words read "vs previous
|
||||
day", "vs previous 30 days" and "vs previous period"; when previous_total is 0 the sub-line
|
||||
is "No earlier data" and when the two totals are equal it is "No change", both without an
|
||||
icon (French "Aucune donnée antérieure", "Aucun changement")
|
||||
And the Active people number is active_people.users plus active_people.api_keys with the
|
||||
sub-line "{users} people, {api_keys} API keys" ("1 person", "1 API key")
|
||||
And the Failed sub-line is "{pct}% of all actions" (one decimal, "0.5%") with icon circle-x
|
||||
and the --rc-error-soft background and --ui-error number
|
||||
And the Security sub-line is "{n} flagged" (French "{n} signalés", "1 signalé"), n being
|
||||
summary.security, with icon shield-alert and the --rc-warning-soft background and
|
||||
--ui-warning number
|
||||
And a Failed or Security count of 0 reads "0" with "Nothing failed" or "Nothing to flag" and
|
||||
icon circle-check in --ui-success, drops the emphasis fill and draws its bars in
|
||||
--ui-border-accented; with total 0 Total and Active people read "0" with "No data yet"
|
||||
And the strip follows only the date range; q, person, category, outcome and security never
|
||||
change it, and it is not requested again when they change
|
||||
And when the summary request fails with any status or never reaches the server, the strip is
|
||||
not rendered and nothing is shown in its place
|
||||
And the filter bar (ActivityFilterBar) has: a UInput (icon search, clearable, placeholder
|
||||
"Search activity", 240 px) that applies 300 ms after the last keystroke or at once on Enter;
|
||||
a Person USelectMenu (icon user-round, "All people", 168 px, full view only) with the groups
|
||||
users, API keys and "System"; a Category USelectMenu (icon layout-list, "All categories",
|
||||
176 px); an Outcome button group "All / Succeeded / Failed" (soft is the selected one,
|
||||
outline the others); and the date presets "Today / 7 days / 30 days / Custom" (Custom has
|
||||
icon calendar and opens a date-range popover) at the right; controls are 32 px high
|
||||
And the Person options come from GET /v1/user/ (accepted users, shown by display name or
|
||||
username) and GET /v1/api-key/, requested once, the first time the Person menu opens, so a
|
||||
page load makes no other call than the two above
|
||||
And choosing a Person sends actor=user:{id}, actor=key:{id} or actor=system; a Category sends
|
||||
category=articles|media|users|api_keys|auth|system; Outcome Succeeded sends outcome=success
|
||||
and Failed outcome=failed; Today, 7 days and 30 days send range=today|7d|30d; Custom sends
|
||||
range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD once both dates are chosen; any filter change
|
||||
resets the page to 1 and sends a new list request, and the page size is kept
|
||||
And the active filter chips (UBadge subtle with a trailing ghost x button) read "Search:
|
||||
spring", "Person: Marta Alves", "Category: Authentication", "Outcome: Failed", "Security
|
||||
events", "Dates: 30 days" or "Dates: 1 – 30 Sep 2026" (same month "1 – 30 Sep 2026", across
|
||||
months "28 Sep – 3 Oct 2026"), followed by a link button "Clear all"; the default range 7
|
||||
days is never a chip; removing a chip resets that filter; "Clear all" resets every filter to
|
||||
the default (range 7d)
|
||||
And below md the bar is a search field and a "Filters" button (icon sliders-horizontal) with a
|
||||
count badge (the number of chips, hidden at 0); it opens a bottom sheet titled "Filters"
|
||||
with Person (full view only), Category, Outcome, Dates (two date fields when Custom is
|
||||
chosen) and a footer "Clear all" (disabled at 0 filters) and "Show {total} results" ("Show 1
|
||||
result"); the sheet applies each change at once, so the footer count is the current total,
|
||||
and "Show … results" closes the sheet
|
||||
And the pager (full width under the table) reads "Showing {from}–{to} of {total}" with
|
||||
locale-formatted numbers, a UPagination (previous, 1, 2, 3, …, last, next; previous
|
||||
disabled on page 1), and "Rows per page" with a select of 25, 50 and 100; changing the size
|
||||
resets the page to 1; below md it reads "Page {page} of {pages}" above "{from}–{to} of
|
||||
{total}", with previous and next buttons and the size select
|
||||
And while a list request is in flight for longer than 150 ms, 9 skeleton rows (desktop) or 5
|
||||
skeleton cards (below md) replace the rows and the pager is hidden; the strip shows 4
|
||||
skeleton cards while the summary request is in flight for longer than 150 ms; nothing is
|
||||
drawn in their place for the first 150 ms
|
||||
And the list is "empty" when total is 0: with no chip it is "No activity yet" (icon history,
|
||||
"Actions on articles, media, users and API keys will appear here.", regular user: "Your
|
||||
actions will appear here once you start working on the site."), with at least one chip it is
|
||||
"No activity matches these filters" (icon search-x, action "Clear filters")
|
||||
And the error, empty and forbidden panels are drawn as in ActivityFrame.dc.html: "Couldn't
|
||||
load activity" is an error alert with icon circle-x, the text and an outline "Try again"
|
||||
button, and "You can't view this activity" (no trailing period) uses icon lock and the
|
||||
action "Back to home"
|
||||
And "Export CSV" is disabled in "No activity yet" and while the list is in the Error state,
|
||||
and enabled otherwise
|
||||
And ActivityDetailsPanel is a USlideover: 480 px wide from the right on desktop, an 88 %-high
|
||||
bottom sheet with a 36×4 px handle below md; its header reads "Activity details" with a
|
||||
close button (and "{position} of {total}" below md); it opens from a row or card, closes
|
||||
with Escape, the close button or the scrim, and focus returns to the row that opened it
|
||||
And the panel body has, in this order: the full sentence (18 px semibold, target in semibold)
|
||||
with the Outcome badge, a "Security" badge when flagged and the category icon and label; an
|
||||
error alert "Why it failed" with failure_reason, only when outcome is "failed" and the
|
||||
reason is present; the grid When, Actor, Source, IP address, Browser; a Target section;
|
||||
a Changes section
|
||||
And When shows the local time as "6 Oct 2026, 10:42:18 (your time)" and under it, muted, the
|
||||
UTC time as "6 Oct 2026, 10:42:18 UTC"; Actor is the avatar, the name and the same badges
|
||||
as the table; Source is "Web app" for web, "API key" for api_key and "System task" for
|
||||
system; IP address (IBM Plex Mono 12 px) and Browser read "Not applicable" (muted) when ip
|
||||
or user_agent is null
|
||||
And the Target section shows the name (a link with arrow-up-right when it exists, otherwise
|
||||
struck through with a "Deleted" badge and the line "This item was deleted. Its name is kept
|
||||
for reference."), and the line "{type} · {id}"; it is absent when target is null
|
||||
And the Changes section (ActivityDiff) is absent when changes is empty; each change is a row
|
||||
headed by its field label: a "text" change shows Before and After boxes (side by side with
|
||||
an arrow-right on desktop, stacked with an arrow-down below md), a "body" change shows only
|
||||
the line "Body edited (+312 characters)" with icon file-pen-line ("-48", "+1 character")
|
||||
and never any text
|
||||
And the field labels are title "Title", online "Publish state", body "Body", filename "File
|
||||
name", display_name "Display name" (French "Titre", "État de publication", "Corps", "Nom du
|
||||
fichier", "Nom d'affichage"), an unknown field shows its raw code, and for online the
|
||||
values "false" and "true" read "Draft" and "Published" ("Brouillon", "Publié")
|
||||
And a Before or After value taller than two lines is clamped to two lines and a "Show more"
|
||||
link button appears under that row; it toggles to "Show less"; shorter values show no toggle
|
||||
And the panel footer has "Previous event" (icon chevron-up) and "Next event" (icon
|
||||
chevron-down) buttons that move through the current filtered list, and on desktop the
|
||||
muted text "Event {position} of {total}" with position = (page − 1) × size + index + 1;
|
||||
below md the two buttons are full width and there is no footer text
|
||||
And when Previous or Next crosses a page boundary the list page follows, and a failed request
|
||||
for the neighbour page keeps the panel on the current event with no message
|
||||
|
||||
Scenario: Navigation — "Activity" sits between Users and Integration
|
||||
Given the user is on /activity
|
||||
When DashboardShell mounts
|
||||
Then the sidebar and the mobile drawer list Home, Articles, Media, Users, Activity,
|
||||
Integration, Settings in that order
|
||||
And "Activity" has the history icon, links to /activity and has aria-current="page"
|
||||
And the breadcrumb reads "Activity" (mobile title "Activity")
|
||||
|
||||
Scenario: Default — an owner sees the strip, the filter bar and a day-grouped table
|
||||
Given GET /v1/activity resolves 200 with 25 items and total 1284, and GET /v1/activity/summary
|
||||
resolves 200 with total 1284, previous_total 1146, active_people {users 7, api_keys 2},
|
||||
failed 6, security 11 and seven-day series, and the session role is "owner"
|
||||
When the user navigates to /activity
|
||||
Then the header shows "Activity", "Who did what, and when, across your site." and an enabled
|
||||
"Export CSV"
|
||||
And the strip reads "1,284" "12% vs previous 7 days" with trending-up; "9" "7 people, 2 API
|
||||
keys"; "6" "0.5% of all actions"; "11" "11 flagged"
|
||||
And the filter bar shows "Search activity", "All people", "All categories", All selected in
|
||||
the Outcome group, and 7 days selected, with no chip
|
||||
And the table rows follow the API order under day separators
|
||||
And the pager reads "Showing 1–25 of 1,284" and "Rows per page" 25
|
||||
And GET /v1/activity was requested once with range=7d, page=1, size=25 and no other
|
||||
parameter, and GET /v1/activity/summary once with range=7d
|
||||
|
||||
Scenario Outline: Rows — the Actor cell and the sentence follow the event
|
||||
Given a row for an event with actor type "<type>" named "<name>", action "<action>", outcome
|
||||
"<outcome>" and security <security>
|
||||
Then the Actor cell shows <actor>
|
||||
And the Action cell shows "<sentence>" with the target name in semibold, and <marker>
|
||||
And the Outcome cell shows "<badge>"
|
||||
|
||||
Examples:
|
||||
| type | name | action | outcome | security | actor | sentence | marker | badge |
|
||||
| user | Marta Alves | article.publish | success | false | an avatar "MA" and "Marta Alves" | Published the article Spring release | no shield | Succeeded |
|
||||
| api_key | Website sync | media.upload | success | false | a key icon, "Website sync", "API key" | Uploaded the image north-wall.jpg | no shield | Succeeded |
|
||||
| system | System | system.media_purge | success | false | a cog icon, "System", "System" | Ran the scheduled duplicate purge: 3 merged, 5 articles updated | no shield | Succeeded |
|
||||
| anonymous | alex@example.com | auth.login_failed | failed | true | a user-round-x icon, "Not signed in" | Failed login attempt for alex@example.com | a shield-alert marker | Failed |
|
||||
| user | Inès Duarte | article.publish | failed | false | an avatar "ID" and "Inès Duarte" | Failed to publish the article Spring release | no shield | Failed |
|
||||
| user | Jonas Weber | api_key.revoke | success | true | an avatar "JW" and "Jonas Weber" | Revoked API key Mobile app | a shield-alert marker | Succeeded |
|
||||
|
||||
Scenario: Rows — a deleted target is struck through, a living one is a link
|
||||
Given an event of target {"type": "image", "id": "9", "name": "old-banner.png", "exists": false}
|
||||
and an event of target {"type": "article", "id": "8", "name": "Spring release", "exists": true}
|
||||
Then the first Target cell shows "old-banner.png" struck through with a "Deleted" badge and
|
||||
"Image"
|
||||
And the second Target cell shows "Spring release" as a link to /article/8/edit and "Article"
|
||||
And clicking that link navigates to /article/8/edit and does not open the details
|
||||
|
||||
Scenario: Rows — an event with no target shows a dash
|
||||
Given an event whose target is null
|
||||
Then its Target cell shows a muted "—"
|
||||
|
||||
Scenario: Rows — an unknown action code renders the code
|
||||
Given an event with action "article.archive"
|
||||
Then its Action cell shows "article.archive" and the row still opens the details
|
||||
|
||||
Scenario Outline: Sentences — English and French come from the same code and params
|
||||
Given an event with action "<action>", outcome "<outcome>", params <params> and target
|
||||
"<target>"
|
||||
When the language is <language>
|
||||
Then the sentence reads "<sentence>" with "<target>" untranslated
|
||||
|
||||
Examples:
|
||||
| action | outcome | params | target | language | sentence |
|
||||
| article.publish | success | {} | Spring release | English | Published the article Spring release |
|
||||
| article.publish | success | {} | Spring release | French | Article publié : Spring release |
|
||||
| article.publish | failed | {} | Spring release | French | Échec de la publication de l'article Spring release |
|
||||
| media.purge | success | {"merged": 3, "articles_updated": 5} | | English | Purged duplicate images: 3 merged, 5 articles updated |
|
||||
| media.purge | success | {"merged": 3, "articles_updated": 5, "failed_groups": 1} | | English | Purged duplicate images: 3 merged, 5 articles updated, 1 group failed |
|
||||
| system.activity_cleanup | success | {"removed": 1} | | English | Removed 1 old event from the activity log |
|
||||
| system.activity_retention_changed | success | {"from": 90, "to": 0} | | English | Changed the activity retention from 90 days to Forever |
|
||||
| system.restart_requested | success | {"kind": "update", "tag": "v1.4.0"} | v1.4.0 | English | Restarted to apply the update v1.4.0 |
|
||||
| media.upload | failed | {} | | English | Failed to upload the image |
|
||||
|
||||
Scenario: Loading — skeletons after 150 ms while both requests are in flight
|
||||
Given GET /v1/activity and GET /v1/activity/summary have not resolved
|
||||
When the user navigates to /activity
|
||||
Then the header, the filter bar and an enabled "Export CSV" are drawn at once
|
||||
And nothing is drawn in place of the strip and the table for the first 150 ms
|
||||
And after 150 ms 4 skeleton cards replace the strip and 9 skeleton rows (5 cards below md)
|
||||
replace the table, the pager is hidden and the table container is aria-busy="true"
|
||||
When both requests resolve 200 with the Default data
|
||||
Then the skeletons are replaced by the Default state's strip, rows and pager
|
||||
|
||||
Scenario: Zero-value summary strip — a quiet period shows a calm 0
|
||||
Given GET /v1/activity/summary resolves 200 with total 214, previous_total 223, active_people
|
||||
{users 4, api_keys 1}, failed 0, security 0 and series of zeros for failed and security
|
||||
And GET /v1/activity resolves 200 with 25 items
|
||||
When the user navigates to /activity
|
||||
Then the Total card reads "214" "4% vs previous 7 days" with trending-down
|
||||
And the Active people card reads "5" "4 people, 1 API key"
|
||||
And the Failed card reads "0" "Nothing failed" with circle-check and no error fill
|
||||
And the Security card reads "0" "Nothing to flag" with circle-check and no warning fill
|
||||
And their bars are 2 px high
|
||||
|
||||
Scenario Outline: The Total sub-line when there is nothing to compare
|
||||
Given GET /v1/activity/summary resolves 200 with total <total> and previous_total <previous>
|
||||
When the user navigates to /activity
|
||||
Then the Total card reads "<total>" and "<subline>" without a trend icon
|
||||
|
||||
Examples:
|
||||
| total | previous | subline |
|
||||
| 40 | 0 | No earlier data |
|
||||
| 40 | 40 | No change |
|
||||
|
||||
Scenario: Empty, no activity yet — a new site
|
||||
Given GET /v1/activity resolves 200 with items [] and total 0, and GET /v1/activity/summary
|
||||
resolves 200 with every number 0
|
||||
And no filter chip is shown
|
||||
When the user navigates to /activity
|
||||
Then the strip reads "0" "No data yet" for Total and Active people, "0" "Nothing failed" and
|
||||
"0" "Nothing to flag"
|
||||
And an empty panel with icon history shows "No activity yet" and "Actions on articles,
|
||||
media, users and API keys will appear here."
|
||||
And "Export CSV" is disabled and no table header or pager is shown
|
||||
|
||||
Scenario: Empty, filters match nothing — the panel offers Clear filters
|
||||
Given the Default state has loaded
|
||||
When the user types "sitemap" in Search, chooses Category "Media" and Outcome "Failed", and
|
||||
GET /v1/activity resolves 200 with items [] and total 0
|
||||
Then the chips read "Search: sitemap", "Category: Media", "Outcome: Failed" and "Clear all"
|
||||
And an empty panel with icon search-x shows "No activity matches these filters" and "Try a
|
||||
different search or a wider date range." with the action "Clear filters"
|
||||
And "Export CSV" stays enabled and the strip is unchanged
|
||||
When the user activates "Clear filters"
|
||||
Then every filter is reset, GET /v1/activity is sent with range=7d, page=1, size=25 only and
|
||||
the rows come back
|
||||
|
||||
Scenario Outline: Error with retry — a 500, a 400 or a network failure replaces the table
|
||||
Given GET /v1/activity <failure> and GET /v1/activity/summary resolves 200
|
||||
When the user navigates to /activity
|
||||
Then the strip and the filter bar are shown
|
||||
And an error alert shows "Couldn't load activity" and "Check your connection and try again."
|
||||
with the action "Try again"
|
||||
And "Export CSV" is disabled and no table or pager is shown
|
||||
When the user activates "Try again"
|
||||
Then the Loading state shows for the list and GET /v1/activity is sent again with the same
|
||||
query
|
||||
When it then resolves 200 with the Default data
|
||||
Then the Default rows replace the alert
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| resolves with a 400 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario Outline: The summary alone fails — the strip is not drawn and the list still works
|
||||
Given GET /v1/activity resolves 200 with the Default items and GET /v1/activity/summary
|
||||
<failure>
|
||||
When the user navigates to /activity
|
||||
Then the strip is not rendered, with no error shown for it
|
||||
And the filter bar, the rows and the pager are shown as in Default
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| resolves with a 403 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Forbidden (403) — the page itself is refused
|
||||
Given GET /v1/activity resolves 403 and GET /v1/auth/me resolves 200 with a verified user
|
||||
When the user navigates to /activity
|
||||
Then the header shows "Activity" and no "Export CSV"
|
||||
And no strip, filter bar, chips, table or pager is shown
|
||||
And an empty panel with icon lock shows "You can't view this activity" and "Your role
|
||||
doesn't include this page. Ask an owner or admin if you need access." with the action "Back
|
||||
to home"
|
||||
When the user activates "Back to home"
|
||||
Then the router navigates to /home
|
||||
|
||||
Scenario: An unverified 403 and a 401 are handled globally
|
||||
Given GET /v1/activity resolves 401 (or 403 for an unverified session)
|
||||
When the user navigates to /activity
|
||||
Then useApi redirects to /login?redirect=/activity&reason=expired (or to /register/pending)
|
||||
And no Forbidden or Error panel is shown
|
||||
|
||||
Scenario Outline: Strip cards — each card applies its filter
|
||||
Given the Default state has loaded with the 7 days range
|
||||
When the user activates the "<card>" card
|
||||
Then <effect>
|
||||
And <state>
|
||||
|
||||
Examples:
|
||||
| card | effect | state |
|
||||
| Total actions | every filter is cleared and GET /v1/activity is sent with range=7d | no card is marked active |
|
||||
| Active people | nothing changes and no request is sent | no card is marked active |
|
||||
| Failed actions | GET /v1/activity is sent with outcome=failed and the page 1 | the chip "Outcome: Failed" and "Clear all" appear and the card has aria-pressed="true" with a --ui-primary border |
|
||||
| Security events | GET /v1/activity is sent with security=true and the page 1 | the chip "Security events" and "Clear all" appear and the card has aria-pressed="true" with a --ui-primary border |
|
||||
|
||||
Scenario: Filtered, card applied — the Failed card on top of other filters
|
||||
Given the user has chosen Category "Authentication" and 30 days
|
||||
When the user activates the "Failed actions" card and GET /v1/activity resolves 200 with 1
|
||||
item and total 6
|
||||
Then the chips read "Category: Authentication", "Outcome: Failed", "Dates: 30 days" and
|
||||
"Clear all"
|
||||
And GET /v1/activity/summary was requested for range=30d and is not requested again
|
||||
And the pager reads "Showing 1–6 of 6"
|
||||
And below md the "Filters" button shows the count badge "3"
|
||||
|
||||
Scenario Outline: Filter bar — each control sends its query parameter and resets the page
|
||||
Given the user is on page 3 of the Default list
|
||||
When <action>
|
||||
Then GET /v1/activity is sent with <query>, page=1 and size=25
|
||||
|
||||
Examples:
|
||||
| action | query |
|
||||
| the user types "spring" and waits 300 ms | q=spring and range=7d |
|
||||
| the user types "spring" and presses Enter at once | q=spring and range=7d |
|
||||
| the user chooses Person "Marta Alves" (user 3) | actor=user:3 and range=7d |
|
||||
| the user chooses Person "Website sync" (API key 7) | actor=key:7 and range=7d |
|
||||
| the user chooses Person "System" | actor=system and range=7d |
|
||||
| the user chooses Category "Users and invites" | category=users and range=7d |
|
||||
| the user chooses Outcome "Succeeded" | outcome=success and range=7d |
|
||||
| the user chooses "Today" | range=today |
|
||||
| the user chooses "30 days" | range=30d |
|
||||
|
||||
Scenario: Filter bar — Custom opens a date-range popover and applies on the second date
|
||||
Given the Default state has loaded
|
||||
When the user chooses "Custom" and picks 1 Sep 2026 then 30 Sep 2026 in the popover
|
||||
Then GET /v1/activity and GET /v1/activity/summary are sent with range=custom, from=2026-09-01
|
||||
and to=2026-09-30
|
||||
And the chip reads "Dates: 1 – 30 Sep 2026"
|
||||
And no request is sent after the first date alone
|
||||
|
||||
Scenario: Chips — removing one resets only that filter, Clear all resets everything
|
||||
Given the chips "Search: spring", "Outcome: Failed" and "Dates: 30 days" are shown
|
||||
When the user activates the x of "Outcome: Failed"
|
||||
Then GET /v1/activity is sent with q=spring and range=30d and without outcome
|
||||
When the user activates "Clear all"
|
||||
Then GET /v1/activity is sent with range=7d only, and the summary is requested for range=7d
|
||||
And no chip and no "Clear all" is shown
|
||||
|
||||
Scenario: The Person menu loads its options once, on first open
|
||||
Given the Default state has loaded
|
||||
Then GET /v1/user/ and GET /v1/api-key/ have not been requested
|
||||
When the user opens the Person menu
|
||||
Then both are requested once and the menu lists the users, then the API keys, then "System"
|
||||
And opening it again does not request them again
|
||||
|
||||
Scenario: Pagination — next page, page size and totals
|
||||
Given the Default state has loaded with total 1284
|
||||
When the user activates page "2" of the pager
|
||||
Then GET /v1/activity is sent with page=2 and size=25 and the pager reads "Showing 26–50 of
|
||||
1,284"
|
||||
When the user chooses "50" in "Rows per page"
|
||||
Then GET /v1/activity is sent with page=1 and size=50 and the pager reads "Showing 1–50 of
|
||||
1,284"
|
||||
And on the last page 52 of size 25 the pager reads "Showing 1,276–1,284 of 1,284" and next is
|
||||
disabled
|
||||
|
||||
Scenario: Mobile — the list is cards and the controls collapse into a sheet
|
||||
Given the Default state has loaded below md with the filters Category "Authentication" and
|
||||
Outcome "Failed" applied and GET /v1/activity resolves with total 6
|
||||
Then the page shows the search field and "Filters" with the badge "2", and no Person,
|
||||
Category, Outcome or date control
|
||||
And the table is replaced by cards, each with its actor, time, sentence and badges
|
||||
And the pager reads "Page 1 of 1" with "1–6 of 6"
|
||||
When the user activates "Filters"
|
||||
Then a bottom sheet titled "Filters" shows Person, Category, Outcome, Dates, "Clear all" and
|
||||
"Show 6 results"
|
||||
When the user chooses "Custom"
|
||||
Then two date fields appear in the sheet
|
||||
When the user activates "Show 6 results"
|
||||
Then the sheet closes and the list stays filtered
|
||||
|
||||
Scenario: Export — the file is prepared, then downloads
|
||||
Given the Default state has loaded with the filters Category "Authentication" and 30 days
|
||||
When the user activates "Export CSV"
|
||||
Then GET /v1/activity/export is sent with category=auth and range=30d and without page or size
|
||||
And while it is in flight the button reads "Preparing file…" and is disabled
|
||||
When it resolves 200 with a text/csv body
|
||||
Then the file is downloaded as "activity.csv" and a toast appears: color success, icon
|
||||
i-lucide-circle-check, title "Export started. Your file will download shortly."
|
||||
And the button returns to "Export CSV", enabled
|
||||
|
||||
Scenario Outline: Export — a failure raises an error toast and re-enables the button
|
||||
Given the Default state has loaded
|
||||
When the user activates "Export CSV" and GET /v1/activity/export <failure>
|
||||
Then a toast appears: color error, icon i-lucide-circle-x, title "Couldn't export activity.
|
||||
Try again."
|
||||
And nothing is downloaded and the button returns to "Export CSV", enabled
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| resolves with a 403 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Details — a row opens the slide-over and focus returns on close
|
||||
Given the Default state has loaded
|
||||
When the user activates the second row (click, or Enter with the row focused)
|
||||
Then the slide-over opens with the title "Activity details", the row is marked selected and
|
||||
the footer reads "Event 2 of 1,284"
|
||||
When the user presses Escape (or activates the close button, or the scrim)
|
||||
Then the slide-over closes and focus is on that row
|
||||
|
||||
Scenario: Details — the Edit with a diff event
|
||||
Given the event "Why we reuse original floorboards" has action "article.edit" with changes
|
||||
[{"field": "title", "kind": "text", "before": "Why we reuse floorboards", "after": "Why we
|
||||
reuse original floorboards"}, {"field": "body", "kind": "body", "delta": 312}]
|
||||
When its details open
|
||||
Then the sentence reads "Edited the article Why we reuse original floorboards"
|
||||
And the Changes section shows a "Title" row with Before "Why we reuse floorboards" and After
|
||||
"Why we reuse original floorboards", then a "Body" row with "Body edited (+312 characters)"
|
||||
And no body text appears anywhere in the panel
|
||||
|
||||
Scenario: Details — long values clamp to two lines with Show more
|
||||
Given an event with a text change whose Before and After are 170-character values, and
|
||||
another text change with short values
|
||||
When its details open and the long values overflow two lines
|
||||
Then the long row's values are clamped and "Show more" appears under that row only
|
||||
When the user activates "Show more"
|
||||
Then the values are fully shown and the link reads "Show less"
|
||||
|
||||
Scenario: Details — a failed action with a very long target title shows its reason
|
||||
Given an event with action "article.publish", outcome "failed", failure_reason "The article
|
||||
has no cover image. Add one, then publish again." and a 150-character target name
|
||||
When its details open
|
||||
Then the sentence wraps with the title in semibold, the "Failed" badge is shown and an error
|
||||
alert "Why it failed" reads the reason
|
||||
And the Target section wraps the long name
|
||||
|
||||
Scenario: Details — a deleted target has no diff
|
||||
Given an event of action "media.delete" with target {"type": "image", "id": "9", "name":
|
||||
"old-banner.png", "exists": false} and no changes
|
||||
When its details open
|
||||
Then the Target section shows "old-banner.png" struck through, a "Deleted" badge and "This
|
||||
item was deleted. Its name is kept for reference."
|
||||
And there is no Changes section
|
||||
|
||||
Scenario: Details — a system-initiated event has no human actor
|
||||
Given an event with actor {"type": "system", "id": null, "name": "System"}, source "system",
|
||||
ip null and user_agent null
|
||||
When its details open
|
||||
Then Actor shows the cog avatar, "System" and a "System" badge, Source reads "System task",
|
||||
and IP address and Browser read "Not applicable"
|
||||
|
||||
Scenario: Details — a failed login shows the actor as not signed in
|
||||
Given an event with action "auth.login_failed", actor {"type": "anonymous", "id": null,
|
||||
"name": "alex@example.com"}, security true, outcome "failed", failure_reason "Wrong username
|
||||
or password.", ip "198.51.100.77" and user_agent "Chrome 129 on Windows"
|
||||
When its details open
|
||||
Then the sentence reads "Failed login attempt for alex@example.com" with a "Security" badge
|
||||
and a "Failed" badge
|
||||
And Actor reads "Not signed in", the alert "Why it failed" shows the reason, and the IP
|
||||
address and the Browser are shown
|
||||
|
||||
Scenario: Details — the times are shown in local time and in UTC
|
||||
Given an event created_at "2026-10-06T10:42:18Z"
|
||||
When its details open
|
||||
Then the When line ends with "(your time)" and the muted line below reads "6 Oct 2026,
|
||||
10:42:18 UTC"
|
||||
|
||||
Scenario Outline: Details — Source reads the event's source
|
||||
Given an event with source "<source>"
|
||||
When its details open
|
||||
Then Source reads "<label>"
|
||||
|
||||
Examples:
|
||||
| source | label |
|
||||
| web | Web app |
|
||||
| api_key | API key |
|
||||
| system | System task |
|
||||
|
||||
Scenario: Details — Previous and Next move through the filtered list across pages
|
||||
Given the list is on page 1 of size 25 with total 1284 and the first row's details are open
|
||||
Then "Previous event" is disabled and the footer reads "Event 1 of 1,284"
|
||||
When the user activates "Next event" 24 times
|
||||
Then the footer reads "Event 25 of 1,284" with no extra request
|
||||
When the user activates "Next event" once more
|
||||
Then GET /v1/activity is sent with the same filters, page=2 and size=25
|
||||
And the panel shows the first item of page 2, the footer reads "Event 26 of 1,284" and the
|
||||
pager under the table reads "Showing 26–50 of 1,284"
|
||||
When the user activates "Previous event"
|
||||
Then GET /v1/activity is sent with page=1 and the panel shows the last item of page 1 as
|
||||
"Event 25 of 1,284"
|
||||
And on the last event "Next event" is disabled
|
||||
|
||||
Scenario: Details — a failed neighbour page keeps the panel on the current event
|
||||
Given the last item of page 1 is open
|
||||
When the user activates "Next event" and GET /v1/activity resolves 500
|
||||
Then the panel still shows the same event, the button is enabled again and the list is
|
||||
unchanged
|
||||
|
||||
Scenario: Details — below md the slide-over is a bottom sheet
|
||||
Given the Default state has loaded below md
|
||||
When the user activates the second card
|
||||
Then a bottom sheet opens, 88 % high, with a handle, the title "Activity details", "2 of
|
||||
1,284" in the header, and full-width "Previous event" and "Next event" buttons
|
||||
And there is no "Event 2 of 1,284" footer text
|
||||
|
||||
Scenario: My activity — a role that is not owner or admin sees its own events
|
||||
Given the session role is "editor" (or absent) and GET /v1/activity resolves 200 with 25
|
||||
items and total 41
|
||||
When the user navigates to /activity
|
||||
Then the title reads "My activity", the subtitle "Everything you've done on this site." and
|
||||
the breadcrumb "My activity"
|
||||
And no summary strip, no Person filter and no "Export CSV" are rendered
|
||||
And the table has the columns When, Action, Target, Outcome and no Actor column
|
||||
And the cards show no actor line below md
|
||||
And GET /v1/activity is sent with range=7d, page=1, size=25 and no actor parameter, and
|
||||
GET /v1/activity/summary is never sent
|
||||
And the pager reads "Showing 1–25 of 41"
|
||||
|
||||
Scenario: My activity, empty — the user has not done anything yet
|
||||
Given the session role is "editor" and GET /v1/activity resolves 200 with items [] and total 0
|
||||
When the user navigates to /activity
|
||||
Then an empty panel with icon history shows "No activity yet" and "Your actions will appear
|
||||
here once you start working on the site."
|
||||
|
||||
Scenario: My activity, details — the slide-over on one of the user's own events
|
||||
Given the session role is "editor" and the Default list has loaded
|
||||
When the first row's details open
|
||||
Then the footer reads "Event 1 of 41", "Previous event" is disabled and the panel is the same
|
||||
as for an owner, with the Actor row showing the user's own name
|
||||
|
||||
Scenario: The French interface composes sentences and chrome in French
|
||||
Given the language is French and the Default state has loaded as owner
|
||||
Then the title reads "Activité", the strip labels, filter placeholders, badges, pager and
|
||||
"Exporter en CSV" are in French, the sentences use the French templates and day
|
||||
separators read "Aujourd'hui", "Hier" or "3 oct. 2026"
|
||||
And the Security sub-line reads "11 signalés"
|
||||
And every new English string of this screen has a French entry in messages.ts
|
||||
|
||||
Scenario: Hover shows the exact time
|
||||
Given the Default state has loaded
|
||||
Then each When cell has a title attribute holding the full local time with seconds
|
||||
@@ -0,0 +1,257 @@
|
||||
# QA: Activity screen, details slide-over and My activity
|
||||
|
||||
## Implementation note for coder (informational, not a QA assertion)
|
||||
|
||||
The activity API is already built and covered by features/activity-log-backend.qa.md and
|
||||
features/activity-log-producers.qa.md: this slice has no Go change and no HTTP-level Go section.
|
||||
|
||||
The shell specs that list the navigation are amended to seven destinations (Home, Articles, Media,
|
||||
Users, Activity, Integration, Settings): features/dashboard_shell_branding.feature,
|
||||
features/media_browse.feature, features/users_screen.feature and .qa.md,
|
||||
features/settings_page.feature and .qa.md, features/profile_page.feature. Their Vitest tests
|
||||
(DashboardShell.spec.ts, where Settings moves from index 5 to 6) must be updated in the same change.
|
||||
|
||||
Export needs the response body as a file, which `useApi().request` does not return (it calls
|
||||
`response.json()`). Add a raw or blob read to `useApi` that keeps the global 401 handling, builds
|
||||
an object URL and clicks a temporary anchor with `download="activity.csv"`. The toasts use
|
||||
`useToast().add({ color, icon, title })` with the one sentence as the title.
|
||||
|
||||
The page needs a date-range popover and a Custom date pair in the mobile sheet. The clamp ("Show
|
||||
more") is measured from the rendered element, so tests stub `scrollHeight` and `clientHeight`.
|
||||
|
||||
Every new English string needs a French entry in `web/src/i18n/messages.ts`. Sentences are
|
||||
composed by a composable (`useActivitySentence`) that returns segments so the target name can be
|
||||
rendered semibold. The Security sub-line is "{n} flagged" with n = `summary.security`, French
|
||||
"{n} signalés".
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi`'s `request` (and the export transport) per case, matching on method, path and parsed
|
||||
query (`URLSearchParams`); no real backend. Mount ActivityView inside DashboardShell with a
|
||||
resolved verified session (id 2, username "ines", display_name "Inès Duarte", role "owner") and a
|
||||
test router at /activity; the regular-user cases use role "editor" (and one with no role). Run
|
||||
each layout case at a desktop and a below-md width (mock `matchMedia`, see
|
||||
`components/__tests__/viewport.ts`). Timers: use `vi.useFakeTimers({ toFake: ['setTimeout',
|
||||
'clearTimeout'] })` so the 150 ms skeleton delay and the 300 ms search debounce can be advanced
|
||||
while `Date` stays real. No test freezes or asserts the system date.
|
||||
|
||||
Clock-independence rules for fixtures: every event in the Vitest fixtures is dated 3 Oct 2026 or
|
||||
earlier, created at 12:00 to 13:00 UTC so its local day is the same in any time zone from UTC-11
|
||||
to UTC+11; assert day separators with the absolute label "3 Oct 2026" and When cells only against
|
||||
the pattern `^\d{2}:\d{2}$` and a non-empty `title`; never assert "Today", "Yesterday" or a
|
||||
relative "N min ago". UTC lines can be asserted exactly; the local time line only with its
|
||||
"(your time)" suffix.
|
||||
|
||||
Fixtures: `activityItems` (a 15-item list built from the board's events, mapped to the API shape
|
||||
with the action codes of the "Visual" section), `summaryDefault` (total 1284, previous_total
|
||||
1146, active_people {users 7, api_keys 2}, failed 6, security 11, series total [4,6,5,8,7,9,6],
|
||||
active [5,6,6,7,7,8,9], failed [0,1,0,2,1,0,2], security [1,0,2,1,3,2,2]), `summaryZero` (total
|
||||
214, previous_total 223, active_people {4, 1}, failed 0, security 0, series failed and security
|
||||
zeros) and `summaryNone` (all zeros). The list response is `{ items, total, page, size }`.
|
||||
|
||||
1. **Navigation** — sidebar and drawer list Home, Articles, Media, Users, Activity, Integration,
|
||||
Settings in order; "Activity" has the `i-lucide-history` icon, links to /activity, has
|
||||
aria-current="page" on /activity and none elsewhere; breadcrumb and mobile title "Activity".
|
||||
2. **Default (owner)** — assert header texts, enabled "Export CSV", the four strip cards (labels,
|
||||
"1,284", "9", "6", "11", sub-lines "12% vs previous 7 days" with the trending-up icon, "7
|
||||
people, 2 API keys", "0.5% of all actions" with icon `circle-x`, "11 flagged" with icon
|
||||
`shield-alert`), the filter bar (placeholder, "All people", "All categories", Outcome All
|
||||
selected, 7 days selected), no chip, the pager text "Showing 1–25 of 1,284", and that `request`
|
||||
was called exactly twice at mount with the expected queries (`range=7d&page=1&size=25` and
|
||||
`range=7d`).
|
||||
3. **Rows, Examples-driven** — one fixture row per Examples row of the feature; assert the
|
||||
Actor cell (initials, key or cog or user-round-x icon, badge, "Not signed in"), the sentence
|
||||
text and that the target name is in a semibold element, the shield marker only on security
|
||||
rows, and the Outcome badge word.
|
||||
4. **Target cell** — deleted (struck-through class, "Deleted" badge, type line), living (link
|
||||
with `href` "/article/8/edit", "/media", "/users", "/integration/api-keys" per type, plain text
|
||||
for "release"), and null ("—"); clicking the link navigates and does not open the details.
|
||||
5. **Day grouping and When cell** — fixture across 3 Oct and 2 Oct 2026; assert two separators
|
||||
"3 Oct 2026" and "2 Oct 2026" in order, the When cell pattern and `title`.
|
||||
6. **Sentences, Examples-driven** — for each Examples row set the locale (`setLocale`), render
|
||||
and assert the exact sentence, including plurals, `failed_groups`, "Forever", the dropped slot
|
||||
for a missing target name, the unknown code `article.archive`, and the success sentence used
|
||||
for an unlisted failed action (`auth.login` failed). Add a completeness test: every action in
|
||||
the catalogue has an English and a French template, and every key used on the page exists in
|
||||
`messages.ts`.
|
||||
7. **Loading** — unresolved requests; at 149 ms nothing in place of strip and table; at 150 ms 4
|
||||
skeleton cards, 9 skeleton rows (5 cards below md), `aria-busy="true"`, no pager; resolve and
|
||||
assert the Default content.
|
||||
8. **Zero-value strip** — `summaryZero`; assert "214" "4% vs previous 7 days" with the
|
||||
trending-down icon, "5" "4 people, 1 API key", "0" "Nothing failed" and "0" "Nothing to flag"
|
||||
with `circle-check`, no error or warning fill class, bars at 2 px.
|
||||
9. **Total sub-line without a comparison, Examples-driven** — previous_total 0 gives "No earlier
|
||||
data" and equal totals give "No change", both without a trend icon; French "Aucune donnée
|
||||
antérieure" and "Aucun changement"; ranges Today, 30 days and Custom give "vs previous day",
|
||||
"vs previous 30 days" and "vs previous period".
|
||||
10. **Empty, no activity yet** — `summaryNone`, items []; assert "No data yet" on two cards, the
|
||||
calm lines on the other two, the empty panel copy and icon, a disabled "Export CSV", no table
|
||||
header and no pager.
|
||||
11. **Empty, filters match nothing** — type "sitemap", choose Category "Media" and Outcome
|
||||
"Failed", resolve items []; assert chips, empty panel copy, "Clear filters", enabled export;
|
||||
click "Clear filters" and assert a request with `range=7d&page=1&size=25` only.
|
||||
12. **Error, Examples-driven** — list rejects with `ApiError(500)`, `ApiError(400)`, then
|
||||
`NetworkError`, summary resolves; assert the strip, the alert copy, "Try again", a disabled
|
||||
export and no table; click "Try again"; assert the list request is repeated and the Default
|
||||
rows replace the alert.
|
||||
13. **Summary alone fails** — summary rejects with `ApiError(500)`, `ApiError(403)`, then
|
||||
`NetworkError`; assert the strip is absent, nothing is drawn in its place, and the list is
|
||||
fully rendered.
|
||||
14. **Forbidden** — list rejects with `ApiError(403)` and a verified session; assert no export
|
||||
button, no strip, no filter bar, the lock panel copy, and that "Back to home" navigates to
|
||||
/home. Also `ApiError(401)` triggers the global redirect and no panel.
|
||||
15. **Strip cards, Examples-driven** — click each card; assert the effect and `aria-pressed`
|
||||
(Failed: request with `outcome=failed&page=1`, chip "Outcome: Failed"; Security: request with
|
||||
`security=true`, chip "Security events"; Total: filters cleared; Active people: no request)
|
||||
and that the summary is not requested again.
|
||||
16. **Filtered, card applied** — Category "Authentication", 30 days, then the Failed card; assert
|
||||
the chip texts and order, "Clear all", the summary requested only once for `range=30d`, the
|
||||
pager "Showing 1–6 of 6", and below md the Filters badge "3".
|
||||
17. **Filter bar, Examples-driven** — for each Examples row assert the exact query, `page=1` after
|
||||
starting on page 3, and the 300 ms debounce with fake timers (no request at 299 ms, one at
|
||||
300 ms; Enter applies at once and the pending debounce does not send a second request).
|
||||
18. **Custom dates** — pick two dates in the popover (stub the calendar's model); assert no request
|
||||
after the first date, then `range=custom&from=2026-09-01&to=2026-09-30` on the list and the
|
||||
summary, and the chip "Dates: 1 – 30 Sep 2026"; also "28 Sep – 3 Oct 2026".
|
||||
19. **Chips** — remove one chip, then "Clear all"; assert the queries and that the summary is
|
||||
requested again only when the range changes back.
|
||||
20. **Person options** — assert no `GET /v1/user/` or `GET /v1/api-key/` call at mount, both on
|
||||
first open of the menu, none on the second; assert group order and the three query values.
|
||||
21. **Pagination** — next page, size 50, last page "Showing 1,276–1,284 of 1,284" with next
|
||||
disabled; mobile "Page 1 of 52" and previous and next.
|
||||
22. **Mobile list and sheet** — below md assert cards (actor line, sentence, badges, "Target
|
||||
deleted"), "Filters" with the badge count, the sheet content and footer ("Show 6 results", "Show
|
||||
1 result"), two date fields for Custom, and that "Show … results" closes it.
|
||||
23. **Export success** — click "Export CSV" with filters applied; assert one request to
|
||||
`/v1/activity/export` with the list filters and no `page` or `size`, "Preparing file…" and
|
||||
disabled while pending, an object URL created and an anchor clicked with
|
||||
`download="activity.csv"`, the success toast (color success, icon `i-lucide-circle-check`,
|
||||
title "Export started. Your file will download shortly."), and the button back to "Export CSV".
|
||||
24. **Export failure, Examples-driven** — 500, 403 and `NetworkError`; assert the error toast
|
||||
(color error, icon `i-lucide-circle-x`, title "Couldn't export activity. Try again."), no
|
||||
download, button re-enabled.
|
||||
25. **Details open and close** — click, then Enter on a focused row; assert the title, the selected
|
||||
row class, the footer "Event 2 of 1,284"; close with Escape, the close button and the scrim and
|
||||
assert focus on the row.
|
||||
26. **Details content, Examples-driven** — the six board events: Edit with a diff (Title row with
|
||||
both values and the "Body edited (+312 characters)" row, "(+1 character)", "(-48 characters)"),
|
||||
long text (stub overflow; "Show more" on that row only, toggles to "Show less"), failed with a
|
||||
long title (alert "Why it failed", wrapping), deleted target (struck-through name, "Deleted"
|
||||
badge, explanation, no Changes), system event ("System" badge, "System task", two "Not
|
||||
applicable"), failed login ("Not signed in", "Security" badge, reason, IP and Browser). Assert
|
||||
the field labels and the Draft and Published values for `online`, the raw code for an unknown
|
||||
field, and that no body text is ever rendered.
|
||||
27. **Details times** — `created_at` "2026-10-06T10:42:18Z": the UTC line is exactly "6 Oct 2026,
|
||||
10:42:18 UTC" and the local line ends with "(your time)".
|
||||
28. **Previous and Next across pages** — first item: previous disabled, "Event 1 of 1,284"; 24
|
||||
nexts without a request; the 25th next sends `page=2&size=25` with the same filters, shows
|
||||
page 2's first item, "Event 26 of 1,284" and the pager "Showing 26–50 of 1,284"; previous
|
||||
sends `page=1` and shows "Event 25 of 1,284"; the last event disables next; a 500 on the
|
||||
neighbour page keeps the panel on the current event and re-enables the button; the position
|
||||
also follows a non-default size (size 50, page 2, index 0 is "Event 51").
|
||||
29. **Details below md** — the bottom-sheet variant: header "2 of 1,284", full-width buttons, no
|
||||
footer text.
|
||||
30. **My activity, Examples-driven** — role "editor" then no role: title, subtitle, breadcrumb "My
|
||||
activity", no strip, no Person filter, no export, no Actor column or card actor line; assert no
|
||||
request to `/summary` or `/export` and no `actor` parameter; pager "Showing 1–25 of 41"; empty
|
||||
state copy; details "Event 1 of 41" with previous disabled.
|
||||
31. **French** — `setLocale('fr')`; assert the chrome strings (Activity "Activité", My activity
|
||||
"Mon activité", Export CSV "Exporter en CSV", Preparing file… "Préparation du fichier…",
|
||||
Total actions "Actions au total", Active people "Personnes actives", Failed actions "Actions
|
||||
en échec", Security events "Événements de sécurité", Succeeded "Réussie", Failed "Échec", Not
|
||||
signed in "Non connecté", Activity details "Détails de l'activité", Previous event and Next
|
||||
event "Événement précédent" and "Événement suivant", "11 signalés"), the French sentences, the
|
||||
plural and "Forever" ("Toujours") forms, and the day separator format.
|
||||
32. **Hover time** — each When cell has a `title` that is non-empty and contains seconds.
|
||||
|
||||
## Visual (task visual:check -- activity, desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/screens/activity.md`, board `docs/design/source/RenewCMS Screens
|
||||
Activity.dc.html`, groups "Activity, owner and admin", "Details slide-over" and "My activity"
|
||||
(the last two are already declared as `extraSources` in `web/visual/check.ts`); exact values in
|
||||
`docs/design/source/ActivityFrame.dc.html`. Add each state to `visualStates.activity` in
|
||||
`web/visual/states.ts` with the board's own sample data. The gate is hard: every capture of every
|
||||
non-skipped state must be within 10 %, for both viewports and both themes (6 gated states, 24
|
||||
captures). No state uses the `now` option or a frozen clock. Mocks are keyed by method and path
|
||||
(`GET /v1/activity`, `GET /v1/activity/summary`, `GET /v1/auth/me`); use `bodyFor` to read the
|
||||
query. Do not mock or call `/v1/user/` or `/v1/api-key/` (Person options load only when the menu
|
||||
opens, which no state does), so there is no unmocked request.
|
||||
|
||||
Sessions: add `activityOwnerSession` (id 2, display_name "Inès Duarte", role "owner") and
|
||||
`activityEditorSession` (id 3, display_name "Marta Alves", role "editor"), as the board draws them
|
||||
in its sidebar chip. The board draws a 4-item sidebar for the regular user as illustrative; the
|
||||
real shell keeps all 7 items, a known small difference in the My activity Empty capture.
|
||||
|
||||
Fixtures (all taken from the frame's script): `activityBoardEvents`, the 15 events of the frame in
|
||||
its order, mapped to API items. Where the frame's sample has no real producer the nearest real
|
||||
code is used and the sentence differs from the board (accepted; those rows only appear in skipped
|
||||
states): 1 `article.publish`, 2 `article.edit` with title and body (+312) changes, 3
|
||||
`auth.login_failed` (anonymous, name "alex@example.com", reason from the frame), 4
|
||||
`api_key.revoke`, 5 `system.media_purge` (system, ip and user_agent null), 6 `invite.accept`
|
||||
(replaces "Changed the role"), 7 `media.upload` by the API key "Website sync", 8 `media.delete`
|
||||
(target not existing), 9 `article.publish` failed with the 150-character title, 10
|
||||
`system.activity_cleanup`, 11 `article.edit` with long title values (replaces the "Site
|
||||
description" setting diff), 12 `api_key.create`, 13 `user.update_profile`, 14 `media.upload`, 15
|
||||
`auth.password_reset_completed`; times are the frame's local times minus one hour as UTC
|
||||
(`created_at`), IPs and browsers as in the frame, security as in the frame. Summary fixtures are
|
||||
the three in the Automated section; the strip of the Default state is "1,284 / 9 / 6 / 11" with
|
||||
the sub-line "11 flagged". Total is 1284 with page size 25 for the owner and 41 for the editor.
|
||||
|
||||
- [ ] Loading: both requests with `hang: true`; the setup waits 150 ms or more so the skeleton
|
||||
strip and rows are drawn before the capture.
|
||||
- [ ] Empty, no activity yet: list `items: [], total: 0`, summary `summaryNone`; "Export CSV"
|
||||
disabled.
|
||||
- [ ] Empty, filters match nothing: the board applies Search "sitemap", Category "Media", Outcome
|
||||
"Failed" (steps type and select them, or the mock answers with the filtered query), list
|
||||
`items: [], total: 0`, summary Default.
|
||||
- [ ] Error with retry: list 500, summary Default; the alert, disabled export, strip drawn.
|
||||
- [ ] Forbidden (403): list 403, summary 403; session with a non-admin role as the board does
|
||||
(`role: 'user'`), so no "Export CSV".
|
||||
- [ ] My activity, Empty: `activityEditorSession`, list `items: [], total: 0`.
|
||||
- [ ] skip: clock-sensitive. Default, large dataset: `skip: 'clock-sensitive'` (day separators and
|
||||
"2 min ago" depend on the real clock). Prints SKIP, never investigated or date-frozen.
|
||||
- [ ] skip: clock-sensitive. Zero-value summary strip, Filtered card applied and Mobile filters
|
||||
sheet (all draw Today rows behind the strip or sheet).
|
||||
- [ ] skip: clock-sensitive. The six Details slide-over states (Edit with a diff, Settings diff
|
||||
long text, Failed action very long target title, Deleted target no diff, System-initiated no
|
||||
human actor, Security event failed login) and the My activity states Default and Details:
|
||||
the rows behind the scrim show relative times and the panel shows the browser's local
|
||||
time. Their Vitest coverage is cases 3, 5, 25 to 30.
|
||||
- [ ] Layout, from `ActivityFrame.dc.html`, not `components.md`: header row is the title block
|
||||
(20 px/28 px title) on the left and the 32 px (44 px mobile) "Export CSV" button on the right,
|
||||
aligned to the bottom; the strip is 4 columns with a 12 px gap on desktop and a clipped row
|
||||
of 156 px cards with an 8 px gap on mobile; the table header is 36 px, day rows 32 px, rows at
|
||||
least 52 px; the slide-over is 480 px wide on desktop, the bottom sheet 88 % high.
|
||||
- [ ] Error is drawn as the frame's alert (error soft background, circle-x, title, text, 28 px
|
||||
outline "Try again"), the empty states as the frame's centred panel (44 px icon box, 15 px
|
||||
semibold title, muted text up to 340 px).
|
||||
- [ ] Not covered by screenshots: keyboard and focus order (Tab reaches Export CSV, the strip
|
||||
cards, the search, the selects, the groups, a row; Enter opens the details; focus is trapped
|
||||
in the slide-over and returns to the row on close; Escape closes it); hover (the row, the
|
||||
native `title` tooltip), the 240 ms slide-over and sheet motion and the skeleton pulse, all
|
||||
stopped under reduced motion; the date-range popover.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real round trips against `task dev`** — Vitest only mocks `useApi`. As owner: perform an
|
||||
article publish, an image upload and a failed login, then confirm they appear newest first with
|
||||
the right sentences, the shield marker on the failed login, the Succeeded and Failed badges, the
|
||||
summary numbers and the sparkline bars; apply each filter and each card; page through with size
|
||||
25, 50 and 100; open the details across a page boundary.
|
||||
- **Real CSV** — click "Export CSV" with and without filters and open `activity.csv`: columns
|
||||
`when,actor,action,target,outcome,source,ip`, the filtered rows only, no page or size applied,
|
||||
neutralised cells that start with `=`, `+`, `-` or `@`; confirm the browser download prompt and
|
||||
the two toasts.
|
||||
- **Admin session** — sign in as an admin: the same full view as the owner.
|
||||
- **Time zones** — set the OS time zone to UTC-8 and UTC+9, reload, and confirm the day
|
||||
separators, the clock times, the "(your time)" line and the UTC line are consistent.
|
||||
- **Regular user** — no real account can reach "My activity" today; confirm by a mocked
|
||||
`/v1/auth/me` in the browser (or a future role) that the view has no strip, no Person filter and
|
||||
no export, and that the server answers 403 for `actor=user:2` and for the summary and export.
|
||||
- **API key** — calling `GET /v1/activity` with an API key answers 403 "This action needs a real
|
||||
session." and the SPA never does it.
|
||||
- **"Today", "Yesterday" and relative times** — confirm them against the real clock, since
|
||||
visual:check skips every state that shows them.
|
||||
- **French** — switch the language and read every state, the sentences and the toasts.
|
||||
- **Real screen readers and zoom** — row selection, the sparkline buttons (labelled by the card
|
||||
text), the sheet focus trap.
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user