feat(activity): update summary cards to toggles and introduce export menu

This commit is contained in:
Florian Sylvain committed 2026-10-12 05:13:25 +02:00
1 parent 73f4b539fe
commit 03b39e64f2
30 files changed
+2375 -237

No files matched your search

+1 -1
View File
@@ -32,7 +32,7 @@ type filterQuery struct {
Category string `validate:"omitempty,oneof=articles media users api_keys auth system"`
Outcome string `validate:"omitempty,oneof=all success failed"`
Security string `validate:"omitempty,oneof=true"`
Range string `validate:"omitempty,oneof=today 7d 30d custom"`
Range string `validate:"omitempty,oneof=today 7d 30d all custom"`
From string `validate:"max=10"`
To string `validate:"max=10"`
}
+1 -1
View File
@@ -40,7 +40,7 @@ key never reads the audit trail. Events are append-only; there is no `POST`, `PU
Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`,
`api_keys`, `auth`, `system`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range`
(`today`, `7d`, `30d`, `custom`; default `7d`), `from` and `to` (dates, with `custom`), `page` (from
(`today`, `7d`, `30d`, `all`, `custom`; default `7d`), `from` and `to` (dates, with `custom`), `page` (from
1) and `size` (25, 50 or 100). Any invalid value answers 400 with the body-mismatch message. Dates
are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`).
+7
View File
@@ -271,10 +271,17 @@ Feature: The back-office keeps an append-only activity log that owners can read
| range=today | events since 00:00 UTC today |
| range=7d | events of the last 7 days |
| range=30d | events of the last 30 days |
| range=all | every event, whatever its age |
| range=custom&from=2026-09-01&to=2026-09-30 | events from 1 to 30 September 2026 UTC |
| page=2&size=50 | the second page of 50 |
| size=5 | the first page of 5 |
Scenario: range=all covers every event
Given events 2 days old and 90 days old
When the owner sends GET /v1/activity?range=all
Then both events are listed
And the summary and the export with range=all cover both events too
Scenario: Omitting range means 7 days
Given events 2 days old and 10 days old
When the owner sends GET /v1/activity
@@ -0,0 +1,228 @@
Feature: Activity screen, filter cards as toggles and the Export menu
As an owner or admin
I want the Failed actions and Security events cards to toggle their filter, and Export to offer the current view or all activity as CSV
So that I can isolate problems in one click and keep a copy of the log
Background:
Given this slice amends features/activity_screen.feature (header Export CSV button, strip-card effects, Export scenarios, French export label, empty/error export rules) per docs/design/screens/activity.md groups "Activity: filter cards" and "Activity: export" and docs/design/states.md
And the screen, strip, chips, filter bar and list are otherwise as in features/activity_screen.feature
And a page filter is the set of q, actor, category, outcome, security, range, from, to
And the filters live in component state, so card, chip and Outcome segment read the same state: card Failed pressed = outcome "failed" is set = chip "Outcome: Failed" shown = Outcome segment on Failed; card Security pressed = security is true = chip "Type: Security events" shown, Outcome segment unchanged
And "Failed actions" and "Security events" are buttons with aria-pressed (true or false); "Total actions" and "Active people" are not toggles and carry no aria-pressed
And a pressed card uses --rc-sand background with --rc-on-sand text; an unpressed card uses the default look
And the card hint lines are: unpressed "Click to filter the list" (on hover), pressed "Filtering. Click to clear", pressed and hovered "Click to clear this filter" with an x icon
And hover on an unpressed card uses --ui-bg-elevated; keyboard focus-visible draws a 2 px --ui-primary outline with a 2 px offset; Enter and Space toggle
And the Outcome segment can still be changed by hand: choosing Failed presses the Failed card, choosing All or Succeeded unpresses it
And the Export control is shown only in the full view (owner or admin); the regular-user view and the Forbidden panel have none
And the Export control is one outline button, icon download, label "Export", trailing chevron; it is a dropdown menu on desktop and a bottom sheet below md; Enter or ArrowDown opens it, Escape closes it, and focus-visible is a 2 px --ui-primary outline with a 2 px offset
And the menu has two items:
| item | sub-line (enabled) | disabled reason |
| Export current view | CSV · {n} events matching your filters and sort, across all pages (n = list total) | Nothing to export. No events match these filters. |
| Export all activity… | CSV · every event, ignoring filters. Owners and admins only. | Only owners and admins can export all activity. |
And export is CSV only, synchronous, one streamed download: no progress percentage, no job, no "ready later"; JSON (format=json) is proposed in the design, is NOT implemented here, and no format control is offered
And "Export current view" sends GET /v1/activity/export with the active page filters (the list query without page and size) and no format parameter
And "Export all activity…" sends GET /v1/activity/export with no page filter at all and only the dialog's date range: All time sends range=all; Today, 7 days and 30 days send range=today|7d|30d; Custom sends range=custom&from=YYYY-MM-DD&to=YYYY-MM-DD
And the backend accepts range=all on the list, summary and export endpoints (features/activity-log-backend.feature); an omitted range still means 7d
And the download is built by the SPA from the response body (blob and anchor) with the file name activity-YYYY-MM-DD.csv for the current view and activity-all-YYYY-MM-DD.csv for all activity (local date)
And a response whose CSV has only the header row means nothing to export: nothing is downloaded
And 401 and the unverified 403 stay with useApi's global handling; the role-lowered 403 is handled here
And the dialog "Export all activity" is a modal (sm:max-w-md, a bottom sheet below md) with the title "Export all activity", a read-only "Format" row reading "CSV", a "Date range" select (default "All time") with the hint "Default is all time. Filters on the page are ignored.", and the footer Cancel on the left, "Export CSV" on the right
And every new English string has a French entry in messages.ts
Scenario Outline: Cards — the toggle cycle keeps card, chip, segment and request in sync
Given the Default state has loaded with no filter
When the user activates the "<card>" card
Then the card has aria-pressed="true", <chip> is shown with "Clear all", and <segment>
And GET /v1/activity is sent once with <query>, page=1 and size=25
And GET /v1/activity/summary is not requested again
When the user activates the "<card>" card again
Then aria-pressed is "false", the chip and "Clear all" are gone, the Outcome segment is on All
And GET /v1/activity is sent with range=7d, page=1 and size=25 only
Examples:
| card | chip | segment | query |
| Failed actions | "Outcome: Failed" | the Outcome segment is on Failed | outcome=failed |
| Security events | "Type: Security events" | the Outcome segment stays on All | security=true |
Scenario Outline: Cards — every way of removing the filter returns all three controls to default
Given the "<card>" card is pressed with <chip> shown
When the user <remove>
Then the card has aria-pressed="false" and default look, the chip is gone, the Outcome segment is on All
And "Clear all" is gone unless another chip remains
Examples:
| card | chip | remove |
| Failed actions | "Outcome: Failed" | activates the x of the chip |
| Failed actions | "Outcome: Failed" | activates "Clear all" |
| Failed actions | "Outcome: Failed" | chooses All in the Outcome segment |
| Failed actions | "Outcome: Failed" | chooses Succeeded in the Outcome segment |
| Security events | "Type: Security events" | activates the x of the chip |
| Security events | "Type: Security events" | activates "Clear all" |
Scenario: Cards — choosing Failed in the Outcome segment presses the card
Given the Default state has loaded
When the user chooses Failed in the Outcome segment
Then the Failed actions card has aria-pressed="true" and the chip "Outcome: Failed" is shown
Scenario: Cards — both cards can be pressed together
Given the "Failed actions" card is pressed
When the user activates the "Security events" card
Then both cards are pressed, the chips "Outcome: Failed" and "Type: Security events" are shown
And GET /v1/activity is sent with outcome=failed and security=true
Scenario: Cards — the card plus other filters; unpressing removes only its chip
Given Category "Authentication" and 30 days are applied and the "Failed actions" card is pressed
Then the chips read "Category: Authentication", "Outcome: Failed", "Dates: 30 days" and "Clear all"
When the user activates the "Failed actions" card
Then only "Outcome: Failed" is removed, the other two chips remain, and the request keeps category=auth and range=30d
Scenario: Cards — hint lines, hover and keyboard
Given the Default state has loaded
Then the Failed actions card hint reads "Click to filter the list" on hover and the card background is --ui-bg-elevated
When the Failed actions card is focused with the keyboard
Then a 2 px --ui-primary outline with a 2 px offset is drawn
When the user presses Space (or Enter)
Then the card is pressed with the hint "Filtering. Click to clear"
When the pointer is over the pressed card
Then the hint reads "Click to clear this filter" with an x icon
And "Clear all" and the chip x show hover and focus-visible styles per the board
Scenario: Cards — Total actions and Active people are not toggles
Given the "Failed actions" card is pressed
When the user activates "Total actions"
Then every filter is cleared and no card is pressed
When the user activates "Active people"
Then nothing changes and no request is sent
And neither card has aria-pressed
Scenario: Cards — the empty panel's "Clear filters" unpresses cards
Given the "Security events" card is pressed and the list is empty
When the user activates "Clear filters" in the empty panel
Then the card is unpressed and no chip is shown
Scenario: Cards — a mobile Filters sheet shares the same state
Given below md the "Failed actions" card is pressed
Then the Filters button badge counts the Outcome chip and the sheet's Outcome control is on Failed
When the user sets Outcome to All in the sheet
Then the card is unpressed
Scenario: Export menu — open, items and hover
Given the Default state has loaded as owner with total 1284
When the user activates "Export"
Then the menu lists "Export current view" ("CSV · 1,284 events matching your filters and sort, across all pages") and "Export all activity…", both enabled
And hovering "Export all activity…" highlights it
When the user presses Escape
Then the menu closes and focus returns to the Export button
Scenario: Export menu — filtered view states the count
Given the "Failed actions" card is pressed and the list total is 6
When the user opens the Export menu
Then "Export current view" reads "CSV · 6 events matching your filters and sort, across all pages"
Scenario: Export menu — nothing to export in the current view
Given Category "Media" and Outcome "Failed" match nothing (total 0)
When the user opens the Export menu
Then "Export current view" is disabled with "Nothing to export. No events match these filters." and "Export all activity…" is enabled
Scenario: Export menu — no activity at all and error state
Given the list is "No activity yet" (total 0, no chip) or in the Error state
Then the Export button is disabled
Scenario: Export current view — success
Given Category "Authentication" and 30 days are applied
When the user chooses "Export current view"
Then GET /v1/activity/export is sent once with category=auth and range=30d, no page, no size, no format
And when it resolves 200 with a text/csv body that has data rows, the file activity-YYYY-MM-DD.csv is downloaded
And a toast appears: title "Download started", body "activity-YYYY-MM-DD.csv is downloading."
And the menu is closed and no dialog was shown
Scenario Outline: Export current view — failures raise an error toast
When the user chooses "Export current view" and the request <failure>
Then an error toast titled "Couldn't prepare the download" appears with the body "<body>"
And nothing is downloaded and the Export button is enabled
Examples:
| failure | body |
| resolves with a 500 response | Something went wrong on our side. Nothing was changed. Try again in a moment. |
| never reaches the server | Something went wrong on our side. Nothing was changed. Try again in a moment. |
| resolves with a 400 response | That request wasn't valid. Check the fields and try again. |
Scenario: Export current view — the role was lowered while the page was open (403)
When the user chooses "Export current view" and the request resolves 403 with the owner/admin-required message
Then an error toast titled "You can't export all activity" appears and nothing is downloaded
And the Export menu then shows both items disabled with "Only owners and admins can export all activity."
Scenario: Export current view — nothing to export
When the user chooses "Export current view" and the response is 200 with only the CSV header row
Then nothing is downloaded and a toast titled "Nothing to export" appears
Scenario: Export all — the dialog opens configured
When the user chooses "Export all activity…"
Then the dialog "Export all activity" shows Format "CSV" (read-only, no JSON option), Date range "All time", Cancel on the left and "Export CSV" on the right
And no request has been sent
Scenario Outline: Export all — the date range maps to the query and ignores page filters
Given Category "Media", Outcome "Failed" and the "Failed actions" card are applied on the page
When the user picks Date range "<range>" in the dialog and activates "Export CSV"
Then GET /v1/activity/export is sent with <query> and no other parameter
Examples:
| range | query |
| All time | range=all |
| Today | range=today |
| 7 days | range=7d |
| 30 days | range=30d |
| Custom 1 Sep to 30 Sep 2026 | range=custom&from=2026-09-01&to=2026-09-30 |
Scenario: Export all — Custom needs a valid pair
When the user picks "Custom" and sets only one date, or from after to
Then "Export CSV" is disabled and no request is sent
Scenario: Export all — preparing, then download started
When the user activates "Export CSV" in the dialog
Then the dialog shows "Preparing download…" with an indeterminate bar and no percentage, "Keep this window open.", Cancel enabled and a disabled "Preparing…"
When the response resolves 200 with a CSV that has data rows
Then the file activity-all-YYYY-MM-DD.csv is downloaded and the dialog reads "Download started" and "activity-all-YYYY-MM-DD.csv is downloading. Check your browser's downloads if nothing appears." with only "Close"
When the user activates "Close"
Then the dialog closes and a toast appears: "Download started", "activity-all-YYYY-MM-DD.csv is downloading."
Scenario: Export all — Cancel while preparing
Given the export request is in flight
When the user activates "Cancel"
Then the request is aborted, nothing is downloaded, the dialog closes and no toast appears
Scenario Outline: Export all — failure shows Retry
When the request <failure>
Then the dialog reads "Couldn't prepare the download" and "Nothing was downloaded. Try again in a moment." with Cancel and "Retry"
When the user activates "Retry"
Then the same request is sent again and the dialog shows "Preparing download…"
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: Export all — the chosen range has no events
When the response is 200 with only the CSV header row
Then nothing is downloaded and the dialog reads "Nothing to export" and "There is no activity in this date range. Choose a wider range." with only "Close"
Scenario: Export all — not allowed (403)
When the response is 403
Then the dialog reads "You can't export all activity" and "Only owners and admins can export all activity. Ask an owner if you need a copy." with only "Close"
And nothing is downloaded
Scenario: Export — below md the menu and the dialog are bottom sheets
Given the Default state has loaded below md
When the user activates "Export"
Then a bottom sheet lists "Export current view" and "Export all activity…"
When the user chooses "Export all activity…"
Then the dialog is a bottom sheet with the same fields and footer (secondary left, primary right)
Scenario: Export — owners and admins only
Given the session role is "editor" (or absent)
Then no Export control is rendered and GET /v1/activity/export is never sent
Scenario: Export — French
Given the language is French
Then every string above (menu, dialog, toasts, hints, chips "Type : Événements de sécurité") has a French entry, and the former "Exporter en CSV" button label is replaced by the menu button
@@ -0,0 +1,63 @@
# QA: Activity filter cards as toggles and the Export menu
## Implementation note for coder (informational)
SPA slice with one small backend change: accept `range=all` in the list, summary and export range validation (use case range handling and the handler `validate` tag). An omitted range stays 7d. The range table in features/activity-log-backend.feature is updated accordingly.
This slice amends features/activity_screen.feature and .qa.md: the header button is now "Export" with a menu, the Export success and failure scenarios are replaced, and the French label "Exporter en CSV" becomes "Exporter". Update the matching Vitest cases (strip-card effects, Export success and failure, empty/error export-disabled lines, French labels) in the same change.
`useApi().request` calls response.json(); add a blob read that keeps the global 401 handling and supports an AbortSignal. Downloads use an object URL and an anchor with a `download` file name built from the local date. Toasts use `useToast().add({ color, icon, title, description })`.
## Automated (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi` per case matching on method, path and parsed query. Mount ActivityView in DashboardShell as in the existing QA (owner session; "editor" for the regular case). Run layout cases at desktop and below-md widths. Use fake timers for setTimeout/clearTimeout only. No test freezes or asserts the system date: file names are asserted with `^activity-\d{4}-\d{2}-\d{2}\.csv$` and `^activity-all-\d{4}-\d{2}-\d{2}\.csv$`. Fixtures: existing `activityItems`, `summaryDefault`, and a CSV text with a header and two rows (and a header-only CSV).
1. **Toggle cycle, Examples-driven (Failed, Security)** - click: aria-pressed true, chip text, Outcome segment, request query, no extra summary request; click again: aria-pressed false, chip and Clear all gone, segment All, query without the key.
2. **Every removal path** - chip x, Clear all, Outcome All, Outcome Succeeded (Failed card only): card, chip and segment all return to default (assert the three together after each).
3. **Segment drives the card** - choose Failed in the segment: Failed card pressed and chip present.
4. **Both cards pressed** - queries `outcome=failed&security=true`, two chips.
5. **Card plus other filters** - Category auth, 30d, Failed: unpress removes only the Outcome chip; request keeps category and range.
6. **Hint and style** - hint texts for unpressed, hover, pressed, pressed+hover (with x icon); pressed class uses the sand tokens (assert the token class, not a hex); unpressed hover class; focus-visible class; Enter and Space toggle.
7. **Non-toggle cards** - Total clears all, Active people sends no request, neither has aria-pressed.
8. **Clear filters from the empty panel** unpresses cards; mobile Filters sheet shares state and badge count.
9. **Export menu structure** - owner: button "Export" with the download icon and chevron; menu items and sub-lines with total 1284 and with Failed card total 6; Escape closes and returns focus; ArrowDown and Enter open; below md rendered as a bottom sheet.
10. **Disabled states** - total 0 with a chip: "Export current view" disabled with its reason, "Export all activity…" enabled; "No activity yet" and Error states: Export button disabled; after a 403 on export: both items disabled with the owner/admin reason.
11. **Role gating** - role "editor" or none: no Export control, no `/export` request; Forbidden panel: none.
12. **Export current view success** - request to `/v1/activity/export` with the list filters, no page or size, no format; blob read; one anchor click with download matching the file-name pattern; toast "Download started"; no dialog.
13. **Export current view failure, Examples-driven (500, 400, network)** - error toast "Couldn't prepare the download" with the mapped body; no download; button enabled.
14. **Export current view 403** - toast "You can't export all activity"; both items disabled afterwards.
15. **Export current view, header-only CSV** - no download, toast "Nothing to export".
16. **Export all dialog** - opens configured (Format "CSV" read-only, no JSON, Date range "All time", hint, Cancel left, "Export CSV" right); no request on open.
17. **Export all query, Examples-driven** - with page filters applied: All time -> `range=all` only; Today, 7d, 30d; Custom -> `range=custom&from=2026-09-01&to=2026-09-30`; Custom with one date or from>to keeps "Export CSV" disabled.
18. **Preparing** - request pending: indeterminate bar (no `aria-valuenow`), copy "Preparing download…", Cancel enabled, "Preparing…" disabled.
19. **Started** - resolve with data rows: download triggered once; dialog copy and only "Close"; Close shows the toast with the all-activity file name.
20. **Cancel while preparing** - AbortController aborted, no download, dialog closed, no toast.
21. **Failed + Retry** - 500 and network: copy, Cancel and Retry; Retry resends and re-enters Preparing.
22. **Nothing to export** - header-only CSV: no download, copy, only "Close".
23. **403 dialog** - copy and only "Close"; no download.
24. **Below md** - menu and dialog render as bottom sheets with the same content.
25. **French** - every new key exists in messages.ts (completeness test) and a spot-check of the menu, dialog and toast strings.
26. **No JSON** - no element with text "JSON" and no `format` query parameter in any export request.
Backend (Go, in test/, on the activity handler tests): `range=all` on list, summary and export returns 200 over all events (older than 30 days included); invalid values still 400; omitted range still means 7d; non-owner export still 403.
## Visual (task visual:check -- activity, desktop and mobile, light and dark)
Reference: board groups "Activity: filter cards" and "Activity: export" under `docs/design/source/`; exact values in `docs/design/source/ActivityFrame.dc.html`. Add states to the activity entry in `web/visual/states.ts` with the board's own sample data. Hard gate: every capture within 10 %. Mock only the calls each state needs; an unmocked call fails the run.
- [ ] Filter cards: Default, card hover, card focus-visible, Failed active, active hover, Security active, chip x hover, Clear all hover and focus-visible, card plus other filters. Mark `skip: 'clock-sensitive'` where the rows behind show relative times.
- [ ] Export: menu open, item hover, filtered view, nothing to export (Media / Failed, total 0), no permission, button focus-visible.
- [ ] Export dialog: configure (CSV only), preparing (request hangs), started, failed, nothing to export, not allowed (403). Mock `/export` with hang / 500 / 403 / header-only CSV as needed.
- [ ] Export "CSV or JSON (proposed)": NOT implemented; skip with reason "proposed, JSON not shipped".
- [ ] Export toasts: file name contains the current date; `skip: 'clock-sensitive'`.
- [ ] Mobile bottom sheets for the menu and the dialog at 390x844.
- [ ] Layout from the frame (not components.md): dialog sm:max-w-md; footer secondary left, primary right; Export button 32 px (44 px mobile); pressed card sand fill.
- [ ] Not covered by screenshots: keyboard and focus order (Tab to cards, Space/Enter toggle, focus returns to the Export button after the menu closes), focus trap in the dialog, Escape, hover and motion, the reduced-motion indeterminate bar.
## Manual verification
- **Real CSV download** against `task dev`: Export current view with and without filters, and Export all with each range; open the file: only the filtered rows for the current view, every row for all time; file names and the browser's download prompt.
- **Large export**: with thousands of events, confirm the download starts and the "Preparing download…" dialog behaves.
- **Role lowered while the page is open**: change the user's role in another session, then export; confirm the 403 handling.
- **Time zones**: file names use the local date; check near midnight.
- **French** and **screen readers**: aria-pressed announcements, menu and dialog semantics.
+10 -33
View File
@@ -41,7 +41,7 @@ Feature: Activity screen, details slide-over and My activity
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
the label "Export" and a trailing chevron at the right, opening the menu described in activity_filter_cards_and_export.feature, 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
@@ -226,7 +226,7 @@ Feature: Activity screen, details slide-over and My activity
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 "Export" 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
@@ -277,7 +277,7 @@ Feature: Activity screen, details slide-over and My activity
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"
"Export"
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
@@ -340,7 +340,7 @@ Feature: Activity screen, details slide-over and My activity
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
Then the header, the filter bar and an enabled "Export" 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"
@@ -377,7 +377,7 @@ Feature: Activity screen, details slide-over and My activity
"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
And "Export" 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
@@ -386,7 +386,7 @@ Feature: Activity screen, details slide-over and My activity
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
And "Export" 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
@@ -397,7 +397,7 @@ Feature: Activity screen, details slide-over and My 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
And "Export" 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
@@ -426,7 +426,7 @@ Feature: Activity screen, details slide-over and My activity
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"
Then the header shows "Activity" and no "Export"
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
@@ -529,29 +529,6 @@ Feature: Activity screen, details slide-over and My activity
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)
@@ -661,7 +638,7 @@ Feature: Activity screen, details slide-over and My activity
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 no summary strip, no Person filter and no "Export" 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
@@ -683,7 +660,7 @@ Feature: Activity screen, details slide-over and My activity
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
"Exporter" 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
+9 -15
View File
@@ -52,7 +52,7 @@ zeros) and `summaryNone` (all zeros). The list response is `{ items, total, page
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,
2. **Default (owner)** — assert header texts, enabled "Export", 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
@@ -85,7 +85,7 @@ zeros) and `summaryNone` (all zeros). The list response is `{ items, total, page
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
calm lines on the other two, the empty panel copy and icon, a disabled "Export", 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;
@@ -122,14 +122,8 @@ zeros) and `summaryNone` (all zeros). The list response is `{ items, total, page
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.
23. **Export** — superseded by activity_filter_cards_and_export.qa.md (menu, dialog, toasts).
24. **Export failure** — superseded by activity_filter_cards_and_export.qa.md.
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.
@@ -156,7 +150,7 @@ zeros) and `summaryNone` (all zeros). The list response is `{ items, total, page
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…",
"Mon activité", Export CSV "Exporter", 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
@@ -199,14 +193,14 @@ the sub-line "11 flagged". Total is 1284 with page size 25 for the owner and 41
- [ ] 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"
- [ ] Empty, no activity yet: list `items: [], total: 0`, summary `summaryNone`; "Export"
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".
(`role: 'user'`), so no "Export".
- [ ] 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.
@@ -218,7 +212,7 @@ the sub-line "11 flagged". Total is 1284 with page size 25 for the owner and 41
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,
(20 px/28 px title) on the left and the 32 px (44 px mobile) "Export" 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.
@@ -238,7 +232,7 @@ the sub-line "11 flagged". Total is 1284 with page size 25 for the owner and 41
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
- **Real CSV** — click "Export" 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.
@@ -9,6 +9,7 @@ const (
activityRangeToday = "today"
activityRange7Days = "7d"
activityRange30Days = "30d"
activityRangeAll = "all"
activityRangeCustom = "custom"
activityDateLayout = "2006-01-02"
activityDefaultSize = 25
@@ -47,6 +48,8 @@ func resolveActivityWindow(query ActivityQuery, now time.Time) (activityWindow,
return activityWindow{from: end.AddDate(0, 0, -30), to: end}, nil
case activityRangeToday:
return activityWindow{from: time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC), to: end}, nil
case activityRangeAll:
return activityWindow{from: time.Unix(0, 0).UTC(), to: end}, nil
case activityRangeCustom:
return resolveCustomWindow(query)
default:
@@ -0,0 +1,77 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/audit"
"encoding/csv"
"encoding/json"
"net/http"
"reflect"
"strings"
"testing"
"time"
)
func TestActivityRangeAll(t *testing.T) {
const day = 24 * time.Hour
t.Run("range=all covers every event", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
recent := e.seedBy(t, userActor(1, "a"), 2*day)
old := e.seedBy(t, userActor(1, "a"), 90*day, func(ev *audit.Event) {
ev.Params = map[string]any{"marker": "kept"}
})
got := decodeActivityList(t, e.get("/v1/activity?range=all", asUser(e, activityOwnerID))).ids()
if !sameIDs(got, []uint64{recent.ID, old.ID}) {
t.Errorf("expected both events listed, got %v", got)
}
raw := e.get("/v1/activity?range=all", asUser(e, activityOwnerID)).Body.String()
if !strings.Contains(raw, `"changes":[]`) || strings.Contains(raw, `"changes":null`) || !strings.Contains(raw, `"params":{}`) || !strings.Contains(raw, `"marker":"kept"`) {
t.Errorf("expected empty changes arrays, got %s", raw)
}
summary := e.get("/v1/activity/summary?range=all", asUser(e, activityOwnerID))
if summary.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", summary.Code)
}
var counts map[string]any
_ = json.Unmarshal(summary.Body.Bytes(), &counts)
if counts["total"] != float64(2) {
t.Errorf("expected a summary total of 2, got %v", counts["total"])
}
export := e.get("/v1/activity/export?range=all", asUser(e, activityOwnerID))
rows, err := csv.NewReader(strings.NewReader(export.Body.String())).ReadAll()
if export.Code != http.StatusOK || err != nil || len(rows) != 3 {
t.Errorf("expected header and two rows, got %d %q %v", export.Code, export.Body.String(), err)
}
})
t.Run("Omitting range still means 7 days", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
recent := e.seedBy(t, userActor(1, "a"), 2*day)
e.seedBy(t, userActor(1, "a"), 90*day)
got := decodeActivityList(t, e.get("/v1/activity", asUser(e, activityOwnerID))).ids()
if !reflect.DeepEqual(got, []uint64{recent.ID}) {
t.Errorf("expected only the recent event, got %v", got)
}
})
t.Run("Other invalid ranges still answer 400", func(t *testing.T) {
for _, path := range []string{"/v1/activity", "/v1/activity/summary", "/v1/activity/export"} {
for _, query := range []string{"range=ALL", "range=everything", "range=year"} {
e := newActivityHTTPEnv(t, 1000)
assertPlain(t, e.get(path+"?"+query, asUser(e, activityOwnerID)), http.StatusBadRequest, activityMismatch)
}
}
})
t.Run("A non-owner export with range=all is still 403", func(t *testing.T) {
e := newActivityHTTPEnv(t, 1000)
assertPlain(t, e.get("/v1/activity/export?range=all", asUser(e, activityEditorID)), http.StatusForbidden, apperr.OwnerOrAdminRequiredErrorMessage)
})
}
+2 -2
View File
@@ -36,7 +36,7 @@ const emit = defineEmits<{
variant="ghost"
icon="i-lucide-x"
:aria-label="t('Remove filter {0}', [chip.label])"
class="size-5 justify-center p-0 text-muted"
class="size-5 justify-center p-0 text-muted hover:bg-accented hover:text-highlighted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
:ui="{ leadingIcon: 'size-3' }"
@click="emit('remove', chip.key)"
/>
@@ -45,7 +45,7 @@ const emit = defineEmits<{
<UButton
color="neutral"
variant="link"
class="h-11 px-1.5 text-xs font-medium text-highlighted underline underline-offset-3 md:h-6"
class="h-11 px-1.5 text-xs font-medium text-highlighted underline underline-offset-3 hover:bg-elevated focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary md:h-6"
@click="emit('clear')"
>
{{ t('Clear all') }}
+248
View File
@@ -0,0 +1,248 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { ExportStage } from '@/composables/useActivityExport'
import { EXPORT_RANGE_KEYS, exportRangeLabel, type ExportRange } from '@/lib/activity'
const props = defineProps<{
stage: ExportStage
canSubmit: boolean
fileName: string
}>()
const emit = defineEmits<{
submit: []
close: []
}>()
const open = defineModel<boolean>('open', { default: false })
const range = defineModel<ExportRange>('range', { default: 'all' })
const from = defineModel<string>('from', { default: '' })
const to = defineModel<string>('to', { default: '' })
const MODAL_CONTENT =
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:max-w-md md:top-35 md:translate-y-0 max-md:!inset-x-0 max-md:!top-auto max-md:!bottom-0 max-md:!left-0 max-md:!w-auto max-md:!max-w-none max-md:!translate-x-0 max-md:!translate-y-0 max-md:before:absolute max-md:before:top-2 max-md:before:left-1/2 max-md:before:h-1 max-md:before:w-9 max-md:before:-translate-x-1/2 max-md:before:bg-(--ui-border-accented) max-md:pt-3'
const BUTTON_CLASS = 'h-11 justify-center px-3 text-sm md:h-8'
const OUTLINE_CLASS = `${BUTTON_CLASS} border border-accented bg-default text-highlighted ring-0`
const FIELD_LABEL_CLASS = 'font-medium text-highlighted'
const CLOSE_BUTTON = {
color: 'neutral',
variant: 'ghost',
class: 'size-11 justify-center p-0 text-muted md:size-7',
} as const
const preparing = computed(() => props.stage === 'preparing')
const rangeItems = computed(() =>
EXPORT_RANGE_KEYS.map((key) => ({ value: key, label: exportRangeLabel(key) })),
)
const showCustomFields = computed(() => range.value === 'custom')
function onOpenChange(value: boolean) {
if (!value) emit('close')
}
</script>
<template>
<UModal
:open="open"
:title="t('Export all activity')"
:description="t('Downloads every event as a CSV file. Filters on the page are ignored.')"
:dismissible="!preparing"
:close="preparing ? false : CLOSE_BUTTON"
:ui="{
content: MODAL_CONTENT,
close: 'end-6 top-8 md:top-5',
header: 'min-h-0 items-start px-6 pt-5 pb-0 sm:px-6',
title: 'pe-14 text-lg/6 font-semibold md:pe-10',
description: 'mt-1 pe-14 text-sm text-muted text-pretty md:pe-10',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}"
@update:open="onOpenChange"
>
<template #body>
<div class="flex flex-col gap-4">
<template v-if="stage === 'configure'">
<UFormField :label="t('Format')" :ui="{ label: FIELD_LABEL_CLASS }">
<UInput
model-value="CSV"
readonly
:aria-label="t('Format')"
class="w-full"
:ui="{
base: 'h-8 rounded-none border border-accented bg-default text-sm text-highlighted ring-0',
}"
/>
</UFormField>
<UFormField
:label="t('Date range')"
:help="t('Default is all time. Filters on the page are ignored.')"
:ui="{ label: FIELD_LABEL_CLASS, help: 'mt-1.5 text-xs text-muted' }"
>
<USelect
v-model="range"
:items="rangeItems"
value-key="value"
icon="i-lucide-calendar"
color="neutral"
variant="outline"
:aria-label="t('Date range')"
class="w-full"
: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',
}"
/>
</UFormField>
<div v-if="showCustomFields" class="flex items-center gap-2">
<UInput
v-model="from"
type="date"
icon="i-lucide-calendar"
:aria-label="t('Start date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm' }"
/>
<UInput
v-model="to"
type="date"
icon="i-lucide-calendar"
:aria-label="t('End date')"
class="min-w-0 flex-1"
:ui="{ base: 'h-11 w-full min-w-0 text-lg md:h-8 md:text-sm' }"
/>
</div>
</template>
<div v-else-if="preparing" class="flex flex-col gap-3">
<span class="flex items-center gap-2 font-medium text-highlighted">
<UIcon name="i-lucide-loader-circle" class="size-4 animate-spin" />
{{ t('Preparing download…') }}
</span>
<UProgress size="xs" color="primary" />
<span class="text-muted text-pretty">
{{
t(
'The file starts downloading as soon as it is ready. Keep this window open.',
)
}}
</span>
</div>
<div v-else-if="stage === 'started'" class="flex items-start gap-3">
<UIcon
name="i-lucide-circle-check"
class="mt-0.5 size-5 shrink-0 text-success"
/>
<div class="flex flex-col gap-0.5">
<span class="text-md font-semibold text-highlighted">{{
t('Download started')
}}</span>
<span class="text-muted">{{
t(
"{0} is downloading. Check your browser's downloads if nothing appears.",
[fileName],
)
}}</span>
</div>
</div>
<UAlert
v-else-if="stage === 'failed'"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:close="false"
:title="t('Couldn\'t prepare the download')"
:description="t('Nothing was downloaded. Try again in a moment.')"
/>
<UAlert
v-else-if="stage === 'denied'"
role="alert"
color="error"
variant="soft"
icon="i-lucide-lock"
:close="false"
:title="t('You can\'t export all activity')"
:description="
t(
'Only owners and admins can export all activity. Ask an owner if you need a copy.',
)
"
/>
<div
v-else-if="stage === 'empty'"
class="flex flex-col items-center gap-2.5 py-3 text-center"
>
<span class="flex size-11 items-center justify-center bg-elevated text-toned">
<UIcon name="i-lucide-file-x" class="size-5" />
</span>
<span class="text-md font-semibold text-highlighted">{{
t('Nothing to export')
}}</span>
<span class="max-w-75 text-muted text-pretty">{{
t('There is no activity in this date range. Choose a wider range.')
}}</span>
</div>
</div>
</template>
<template #footer>
<div class="flex w-full flex-col-reverse gap-2 md:flex-row md:justify-end">
<template v-if="stage === 'configure' || preparing || stage === 'failed'">
<UButton
color="neutral"
variant="outline"
block
:class="[OUTLINE_CLASS, 'md:w-auto']"
@click="emit('close')"
>
{{ t('Cancel') }}
</UButton>
<UButton
v-if="stage === 'configure'"
icon="i-lucide-download"
block
:class="[BUTTON_CLASS, 'md:w-auto']"
:disabled="!canSubmit"
@click="emit('submit')"
>
{{ t('Export CSV') }}
</UButton>
<UButton
v-else-if="preparing"
block
disabled
:class="[BUTTON_CLASS, 'md:w-auto']"
>
{{ t('Preparing…') }}
</UButton>
<UButton
v-else
icon="i-lucide-rotate-cw"
block
:class="[BUTTON_CLASS, 'md:w-auto']"
@click="emit('submit')"
>
{{ t('Retry') }}
</UButton>
</template>
<UButton
v-else
color="neutral"
variant="outline"
block
:class="[OUTLINE_CLASS, 'md:w-auto']"
@click="emit('close')"
>
{{ t('Close') }}
</UButton>
</div>
</template>
</UModal>
</template>
+157
View File
@@ -0,0 +1,157 @@
<script setup lang="ts">
import { formatNumber, t } from '@/i18n'
import { NOTHING_TO_EXPORT_COPY, OWNERS_ONLY_COPY } from '@/lib/activity'
import { computed, ref } from 'vue'
const props = defineProps<{
total: number
isDesktop: boolean
disabled: boolean
nothingToExport: boolean
forbidden: boolean
}>()
const emit = defineEmits<{
current: []
all: []
}>()
const open = ref(false)
const BUTTON_CLASS =
'h-11 border border-accented bg-default px-3 text-sm text-highlighted ring-0 data-[state=open]:bg-elevated md:h-8'
const ITEM_HINT_CLASS = 'text-xs/4.5 text-muted text-pretty'
const currentDisabled = computed(() => props.forbidden || props.nothingToExport)
const currentHint = computed(() => {
if (props.forbidden) return t(OWNERS_ONLY_COPY)
if (props.nothingToExport) return t(NOTHING_TO_EXPORT_COPY)
return props.total === 1
? t('CSV · 1 event matching your filters and sort, across all pages')
: t('CSV · {0} events matching your filters and sort, across all pages', [
formatNumber(props.total),
])
})
const allHint = computed(() =>
props.forbidden
? t(OWNERS_ONLY_COPY)
: t('CSV · every event, ignoring filters. Owners and admins only.'),
)
const trailingIcon = computed(() => (open.value ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'))
const entries = computed(() => [
{
action: 'current' as const,
label: t('Export current view'),
description: currentHint.value,
icon: 'i-lucide-download',
disabled: currentDisabled.value,
},
{
action: 'all' as const,
label: t('Export all activity…'),
description: allHint.value,
icon: props.forbidden ? 'i-lucide-lock' : 'i-lucide-archive',
disabled: props.forbidden,
},
])
const items = computed(() =>
entries.value.map(({ action, label, description, icon, disabled }) => [
{ label, description, icon, disabled, onSelect: () => choose(action) },
]),
)
function choose(action: 'current' | 'all') {
open.value = false
if (action === 'current') emit('current')
else emit('all')
}
</script>
<template>
<UDropdownMenu
v-if="isDesktop"
v-model:open="open"
:items="items"
:content="{ align: 'end', sideOffset: 4 }"
:ui="{
content: 'w-90 rounded-none p-1 shadow-(--rc-shadow-pop)',
group: 'p-0',
separator: 'my-0',
item: 'min-h-14 items-start gap-3 rounded-none px-3 py-2.5 text-sm data-disabled:opacity-100 data-disabled:text-muted',
itemLeadingIcon: 'mt-0.5 size-4 text-highlighted group-data-disabled:text-muted',
itemLabel: 'font-medium',
itemDescription: 'text-xs/4.5 text-muted text-pretty whitespace-normal',
}"
>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-download"
:trailing-icon="trailingIcon"
:disabled="disabled"
:class="BUTTON_CLASS"
:ui="{ trailingIcon: 'size-3.5 text-muted' }"
>
{{ t('Export') }}
</UButton>
</UDropdownMenu>
<template v-else>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-download"
:trailing-icon="trailingIcon"
:disabled="disabled"
:class="BUTTON_CLASS"
:ui="{ trailingIcon: 'size-3.5 text-muted' }"
@click="open = true"
>
{{ t('Export') }}
</UButton>
<USlideover
v-model:open="open"
side="bottom"
:title="t('Export')"
:description="t('Export')"
: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-0 pb-4 sm:p-0 sm:pb-4',
}"
>
<template #body>
<div role="menu" :aria-label="t('Export')" class="flex flex-col">
<template v-for="(entry, index) in entries" :key="entry.action">
<USeparator v-if="index > 0" />
<UButton
role="menuitem"
color="neutral"
variant="ghost"
block
:icon="entry.icon"
:disabled="entry.disabled"
class="min-h-16 items-start justify-start gap-3 rounded-none px-4 py-3 text-start text-sm text-highlighted disabled:text-muted disabled:opacity-100"
:ui="{ leadingIcon: 'mt-0.5 size-4' }"
@click="choose(entry.action)"
>
<span class="flex min-w-0 flex-col gap-0.5">
<span class="font-medium">{{ entry.label }}</span>
<span :class="[ITEM_HINT_CLASS, 'font-normal']">{{
entry.description
}}</span>
</span>
</UButton>
</template>
</div>
</template>
</USlideover>
</template>
</template>
+72 -10
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import type { ActivityFilters, ActivitySummary } from '@/lib/activity'
import {
barHeights,
@@ -41,6 +43,14 @@ const VALUE_TONES: Record<StripCard['tone'], string> = {
warning: 'text-warning',
calm: 'text-highlighted',
}
const PRESSED_TEXT = 'text-(--rc-on-sand)'
const PRESSED_BAR = 'bg-(--rc-on-sand)'
const hovered = ref<StripKey | null>(null)
function isToggle(key: StripKey) {
return key === 'failed' || key === 'security'
}
function pressed(key: StripKey) {
if (key === 'failed') return props.outcome === 'failed'
@@ -49,14 +59,18 @@ function pressed(key: StripKey) {
}
function rootClass(card: StripCard) {
const fill =
card.tone === 'error'
const isPressed = pressed(card.key)
const fill = isPressed
? 'bg-(--rc-sand)'
: card.tone === 'error'
? 'bg-(--rc-error-soft)'
: card.tone === 'warning'
? 'bg-(--rc-warning-soft)'
: 'bg-default'
const edge = pressed(card.key)
const edge = isPressed
? 'border-primary ring-1 ring-primary ring-inset'
: isToggle(card.key)
? 'border-default ring-0 hover:border-accented hover:bg-elevated'
: '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',
@@ -64,6 +78,44 @@ function rootClass(card: StripCard) {
edge,
]
}
function labelClass(card: StripCard) {
return pressed(card.key) ? PRESSED_TEXT : 'text-toned'
}
function valueClass(card: StripCard) {
return pressed(card.key) ? PRESSED_TEXT : VALUE_TONES[card.tone]
}
function barClass(card: StripCard) {
return pressed(card.key) ? PRESSED_BAR : BAR_TONES[card.tone]
}
function sublineClass(card: StripCard) {
if (pressed(card.key)) return PRESSED_TEXT
return card.tone === 'calm' ? 'text-success' : 'text-muted'
}
function hint(card: StripCard) {
if (!isToggle(card.key)) return null
if (pressed(card.key)) {
return hovered.value === card.key
? { text: t('Click to clear this filter'), icon: 'i-lucide-x' }
: { text: t('Filtering. Click to clear'), icon: 'i-lucide-filter' }
}
return hovered.value === card.key
? { text: t('Click to filter the list'), icon: 'i-lucide-filter' }
: null
}
function sublineText(card: StripCard) {
return hint(card)?.text ?? card.subline
}
function sublineIcon(card: StripCard) {
const shown = hint(card)
return shown ? shown.icon : card.icon
}
</script>
<template>
@@ -102,11 +154,15 @@ function rootClass(card: StripCard) {
root: 'overflow-visible rounded-none shadow-none md:min-w-0',
body: 'p-0 sm:p-0',
}"
@mouseenter="hovered = card.key"
@mouseleave="hovered = null"
@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 class="truncate font-medium" :class="labelClass(card)">{{
card.label
}}</span>
<span
aria-hidden="true"
data-testid="activity-bars"
@@ -117,22 +173,28 @@ function rootClass(card: StripCard) {
v-for="(height, index) in barHeights(card.series, barHeight)"
:key="index"
class="block w-1"
:class="BAR_TONES[card.tone]"
:class="barClass(card)"
:style="{ height: `${height}px` }"
/>
</span>
</span>
<span
class="text-stat-sm font-semibold md:text-stat"
:class="VALUE_TONES[card.tone]"
:class="valueClass(card)"
>{{ card.value }}</span
>
<span
class="flex items-center gap-1 truncate text-xs/5"
:class="card.tone === 'calm' ? 'text-success' : 'text-muted'"
:class="sublineClass(card)"
>
<UIcon v-if="card.icon" :name="card.icon" class="size-3 shrink-0" />
<span class="truncate">{{ card.subline }}</span>
<UIcon
v-if="sublineIcon(card)"
:name="sublineIcon(card)!"
class="size-3 shrink-0"
/>
<span class="truncate" data-testid="activity-strip-subline">{{
sublineText(card)
}}</span>
</span>
</span>
</UCard>
@@ -239,3 +239,30 @@ describe('getMe maps the account fields', () => {
expect(me.displayName).toBe('')
})
})
describe('useApi abort and blob handling', () => {
it('rethrows an AbortError without flagging the app offline', async () => {
fetchMock.mockRejectedValue(new DOMException('aborted', 'AbortError'))
await expect(useApi().request('/v1/x')).rejects.toMatchObject({ name: 'AbortError' })
expect(isOffline.value).toBe(false)
})
it('requestBlob returns the response blob', async () => {
const blob = new Blob(['a,b'])
fetchMock.mockResolvedValue({ ...mockResponse(200), blob: async () => blob })
await expect(useApi().requestBlob('/v1/activity/export')).resolves.toBe(blob)
})
it('requestBlob reuses the 401 handling and redirects to login', async () => {
fetchMock.mockResolvedValue(mockResponse(401))
await expectApiError(useApi().requestBlob('/v1/activity/export'), { status: 401 })
await settle()
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.reason).toBe('expired')
})
})
+19 -2
View File
@@ -1,6 +1,8 @@
import { useApi } from '@/composables/useApi'
import {
exportAllQuery,
exportQuery,
type ExportRange,
listQuery,
summaryQuery,
type ActivityFilters,
@@ -17,7 +19,6 @@ export interface PersonOptions {
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()
@@ -43,6 +44,15 @@ export function useActivity() {
return requestBlob(`/v1/activity/export?${exportQuery(filters)}`)
}
function exportAllActivity(
range: ExportRange,
from: string,
to: string,
signal: AbortSignal,
): Promise<Blob> {
return requestBlob(`/v1/activity/export?${exportAllQuery(range, from, to)}`, { signal })
}
async function listPeople(): Promise<PersonOptions> {
const [users, keys] = await Promise.all([
request<UserListItem[]>('/v1/user/'),
@@ -58,7 +68,14 @@ export function useActivity() {
}
}
return { listActivity, listRecentActivity, getSummary, exportActivity, listPeople }
return {
listActivity,
listRecentActivity,
getSummary,
exportActivity,
exportAllActivity,
listPeople,
}
}
export function downloadBlob(blob: Blob, fileName: string) {
+201
View File
@@ -0,0 +1,201 @@
import { computed, onBeforeUnmount, ref, toValue, type MaybeRefOrGetter } from 'vue'
import { t } from '@/i18n'
import { useLocalizedToast } from '@/i18n/presentation'
import { ApiError, isAbort } from '@/composables/useApi'
import { downloadBlob, useActivity } from '@/composables/useActivity'
import { useSessionStore } from '@/stores/session'
import {
allActivityFileName,
currentViewFileName,
isExportRangeValid,
isHeaderOnlyCsv,
readBlobText,
type ActivityFilters,
NOTHING_TO_EXPORT_COPY,
OWNERS_ONLY_COPY,
type ExportRange,
} from '@/lib/activity'
export type ExportStage = 'configure' | 'preparing' | 'started' | 'failed' | 'empty' | 'denied'
const HTTP_UNAUTHORIZED = 401
const HTTP_FORBIDDEN = 403
const HTTP_BAD_REQUEST = 400
const SERVER_ERROR_COPY =
'Something went wrong on our side. Nothing was changed. Try again in a moment.'
const BAD_REQUEST_COPY = "That request wasn't valid. Check the fields and try again."
export function useActivityExport(filters: MaybeRefOrGetter<ActivityFilters>) {
const session = useSessionStore()
const toast = useLocalizedToast()
const { exportActivity, exportAllActivity } = useActivity()
const forbidden = ref(false)
const currentBusy = ref(false)
const dialogOpen = ref(false)
const stage = ref<ExportStage>('configure')
const range = ref<ExportRange>('all')
const from = ref('')
const to = ref('')
const startedFileName = ref('')
let controller: AbortController | null = null
const canSubmit = computed(
() => stage.value === 'configure' && isExportRangeValid(range.value, from.value, to.value),
)
function notify(
color: 'success' | 'error' | 'neutral',
icon: string,
title: () => string,
description?: () => string,
) {
toast.add(() => ({
color,
icon,
get title() {
return title()
},
get description() {
return description?.()
},
}))
}
function notifyStarted(fileName: string) {
notify(
'success',
'i-lucide-circle-check',
() => t('Download started'),
() => t('{0} is downloading.', [fileName]),
)
}
function isGlobalFailure(error: unknown) {
if (!(error instanceof ApiError)) return false
if (error.status === HTTP_UNAUTHORIZED) return true
return error.status === HTTP_FORBIDDEN && session.session?.verified === false
}
async function hasRows(blob: Blob) {
return !isHeaderOnlyCsv(await readBlobText(blob))
}
async function exportCurrentView() {
if (currentBusy.value) return
currentBusy.value = true
try {
const blob = await exportActivity(toValue(filters))
if (!(await hasRows(blob))) {
notify(
'neutral',
'i-lucide-file-x',
() => t('Nothing to export'),
() => t(NOTHING_TO_EXPORT_COPY),
)
return
}
const fileName = currentViewFileName()
downloadBlob(blob, fileName)
notifyStarted(fileName)
} catch (error) {
failCurrentView(error)
} finally {
currentBusy.value = false
}
}
function failCurrentView(error: unknown) {
if (isGlobalFailure(error)) return
if (error instanceof ApiError && error.status === HTTP_FORBIDDEN) {
forbidden.value = true
notify(
'error',
'i-lucide-lock',
() => t("You can't export all activity"),
() => t(OWNERS_ONLY_COPY),
)
return
}
const copy =
error instanceof ApiError && error.status === HTTP_BAD_REQUEST
? BAD_REQUEST_COPY
: SERVER_ERROR_COPY
notify(
'error',
'i-lucide-circle-x',
() => t("Couldn't prepare the download"),
() => t(copy),
)
}
function openDialog() {
stage.value = 'configure'
range.value = 'all'
from.value = ''
to.value = ''
dialogOpen.value = true
}
async function submit() {
if (stage.value !== 'configure' && stage.value !== 'failed') return
if (!isExportRangeValid(range.value, from.value, to.value)) return
stage.value = 'preparing'
const current = new AbortController()
controller = current
try {
const blob = await exportAllActivity(range.value, from.value, to.value, current.signal)
if (current.signal.aborted) return
if (!(await hasRows(blob))) {
stage.value = 'empty'
return
}
startedFileName.value = allActivityFileName()
downloadBlob(blob, startedFileName.value)
stage.value = 'started'
} catch (error) {
if (current.signal.aborted || isAbort(error)) return
failDialog(error)
} finally {
if (controller === current) controller = null
}
}
function failDialog(error: unknown) {
if (isGlobalFailure(error)) {
dialogOpen.value = false
return
}
if (error instanceof ApiError && error.status === HTTP_FORBIDDEN) {
forbidden.value = true
stage.value = 'denied'
return
}
stage.value = 'failed'
}
function closeDialog() {
controller?.abort()
controller = null
if (stage.value === 'started') notifyStarted(startedFileName.value)
dialogOpen.value = false
}
onBeforeUnmount(() => controller?.abort())
return {
forbidden,
currentBusy,
dialogOpen,
stage,
range,
from,
to,
startedFileName,
canSubmit,
exportCurrentView,
openDialog,
submit,
closeDialog,
}
}
+14 -45
View File
@@ -1,13 +1,6 @@
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 { useActivity, type PersonOptions } from '@/composables/useActivity'
import { useSessionStore } from '@/stores/session'
import {
activeChips,
@@ -52,8 +45,7 @@ function delayedFlag() {
export function useActivityPage() {
const session = useSessionStore()
const toast = useLocalizedToast()
const { listActivity, getSummary, exportActivity, listPeople } = useActivity()
const { listActivity, getSummary, listPeople } = useActivity()
const filters = ref<ActivityFilters>(defaultFilters())
const page = ref(1)
@@ -64,7 +56,6 @@ export function useActivityPage() {
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()
@@ -85,10 +76,7 @@ export function useActivityPage() {
)
const isEmpty = computed(() => listState.value === 'ready' && total.value === 0)
const exportDisabled = computed(
() =>
exporting.value ||
listState.value === 'error' ||
(isEmpty.value && chips.value.length === 0),
() => 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))
@@ -190,6 +178,15 @@ export function useActivityPage() {
applyFilters(defaultFilters(), true)
}
function toggleFailed() {
const outcome = filters.value.outcome === 'failed' ? 'all' : 'failed'
applyFilters({ ...filters.value, outcome })
}
function toggleSecurity() {
applyFilters({ ...filters.value, security: !filters.value.security })
}
function goToPage(next: number) {
if (next === page.value || next < 1 || next > pageCount.value) return
fetchPage(next)
@@ -215,34 +212,6 @@ export function useActivityPage() {
}
}
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
}
@@ -284,7 +253,6 @@ export function useActivityPage() {
summary,
summaryState,
people,
exporting,
chips,
isFullView,
isEmpty,
@@ -303,11 +271,12 @@ export function useActivityPage() {
applyFilters,
removeChip,
clearAll,
toggleFailed,
toggleSecurity,
goToPage,
setSize,
retry,
ensurePeople,
runExport,
openDetails,
closeDetails,
previous: () => step(-1),
+8 -1
View File
@@ -40,6 +40,10 @@ export interface RequestOptions extends RequestInit {
onForbidden?: OnForbidden
}
export function isAbort(error: unknown) {
return error instanceof DOMException && error.name === 'AbortError'
}
export const isOffline = ref(false)
export const authRedirectPending = ref(false)
@@ -107,7 +111,10 @@ export function useApi() {
let response: Response
try {
response = await fetch(path, { credentials: 'include', ...init })
} catch {
} catch (error) {
if (isAbort(error)) {
throw error
}
isOffline.value = true
throw new NetworkError()
}
+46 -4
View File
@@ -1016,11 +1016,53 @@ export const messages = {
'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: 'Exporter',
'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.",
'Export current view': 'Exporter la vue actuelle',
'Export all activity…': 'Exporter toute l’activité…',
'CSV · {0} events matching your filters and sort, across all pages':
'CSV · {0} événements correspondant à vos filtres et à votre tri, sur toutes les pages',
'CSV · 1 event matching your filters and sort, across all pages':
'CSV · 1 événement correspondant à vos filtres et à votre tri, sur toutes les pages',
'CSV · every event, ignoring filters. Owners and admins only.':
'CSV · tous les événements, sans tenir compte des filtres. Réservé aux propriétaires et aux admins.',
'Nothing to export. No events match these filters.':
'Rien à exporter. Aucun événement ne correspond à ces filtres.',
'Only owners and admins can export all activity.':
'Seuls les propriétaires et les admins peuvent exporter toute l’activité.',
'Export all activity': 'Exporter toute l’activité',
'Downloads every event as a CSV file. Filters on the page are ignored.':
'Télécharge tous les événements dans un fichier CSV. Les filtres de la page sont ignorés.',
CSV: 'CSV',
'Date range': 'Période',
'All time': 'Toute la période',
'Default is all time. Filters on the page are ignored.':
'Par défaut, toute la période. Les filtres de la page sont ignorés.',
'Preparing download…': 'Préparation du téléchargement…',
'The file starts downloading as soon as it is ready. Keep this window open.':
'Le fichier se télécharge dès qu’il est prêt. Gardez cette fenêtre ouverte.',
'Preparing…': 'Préparation…',
'Download started': 'Téléchargement lancé',
"{0} is downloading. Check your browser's downloads if nothing appears.":
'{0} est en cours de téléchargement. Consultez les téléchargements de votre navigateur si rien n’apparaît.',
'{0} is downloading.': '{0} est en cours de téléchargement.',
"Couldn't prepare the download": 'Impossible de préparer le téléchargement',
'Nothing was downloaded. Try again in a moment.':
'Rien n’a été téléchargé. Réessayez dans un instant.',
'Nothing to export': 'Rien à exporter',
'There is no activity in this date range. Choose a wider range.':
'Il n’y a aucune activité sur cette période. Choisissez une période plus large.',
"You can't export all activity": 'Vous ne pouvez pas exporter toute l’activité',
'Only owners and admins can export all activity. Ask an owner if you need a copy.':
'Seuls les propriétaires et les admins peuvent exporter toute l’activité. Demandez à un propriétaire si vous avez besoin d’une copie.',
'Something went wrong on our side. Nothing was changed. Try again in a moment.':
'Un problème est survenu de notre côté. Rien n’a été modifié. Réessayez dans un instant.',
"That request wasn't valid. Check the fields and try again.":
'Cette requête n’était pas valide. Vérifiez les champs et réessayez.',
'Type: {0}': 'Type : {0}',
'Click to filter the list': 'Cliquez pour filtrer la liste',
'Filtering. Click to clear': 'Filtre actif. Cliquez pour l’effacer',
'Click to clear this filter': 'Cliquez pour effacer ce filtre',
'Total actions': 'Actions au total',
'Active people': 'Personnes actives',
'Failed actions': 'Actions en échec',
+1 -1
View File
@@ -131,7 +131,7 @@ describe('Chips', () => {
'Person: Marta Alves',
'Category: Authentication',
'Outcome: Failed',
'Security events',
'Type: Security events',
'Dates: 30 days',
])
})
+62 -12
View File
@@ -110,6 +110,15 @@ export function summaryRangeChanged(a: ActivityFilters, b: ActivityFilters) {
return a.range !== b.range || a.from !== b.from || a.to !== b.to
}
function rangeParams(range: ExportRange, from: string, to: string) {
const params = new URLSearchParams({ range })
if (range === 'custom') {
params.set('from', from)
params.set('to', to)
}
return params
}
function filterParams(filters: ActivityFilters) {
const params = new URLSearchParams()
if (filters.q) params.set('q', filters.q)
@@ -117,11 +126,9 @@ function filterParams(filters: ActivityFilters) {
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)
}
rangeParams(filters.range, filters.from, filters.to).forEach((value, key) =>
params.set(key, value),
)
return params
}
@@ -136,13 +143,55 @@ 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)
export const OWNERS_ONLY_COPY = 'Only owners and admins can export all activity.'
export const NOTHING_TO_EXPORT_COPY = 'Nothing to export. No events match these filters.'
export type ExportRange = 'all' | RangeKey
export const EXPORT_RANGE_KEYS: ExportRange[] = ['all', 'today', '7d', '30d', 'custom']
export function exportRangeLabel(range: ExportRange) {
return range === 'all' ? t('All time') : rangeLabel(range)
}
return params.toString()
export function exportAllQuery(range: ExportRange, from: string, to: string) {
return rangeParams(range, from, to).toString()
}
export function isExportRangeValid(range: ExportRange, from: string, to: string) {
return range !== 'custom' || (from !== '' && to !== '' && from <= to)
}
function localDateStamp() {
const now = new Date()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${now.getFullYear()}-${month}-${day}`
}
export function currentViewFileName() {
return `activity-${localDateStamp()}.csv`
}
export function allActivityFileName() {
return `activity-all-${localDateStamp()}.csv`
}
export function readBlobText(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(reader.error)
reader.readAsText(blob)
})
}
export function isHeaderOnlyCsv(text: string) {
return text.trim().split(/\r?\n/).length <= 1
}
export function summaryQuery(filters: ActivityFilters) {
return rangeParams(filters.range, filters.from, filters.to).toString()
}
export function categoryLabel(category: ActivityCategory) {
@@ -231,7 +280,8 @@ export function activeChips(filters: ActivityFilters): ActivityChip[] {
key: 'outcome',
label: t('Outcome: {0}', [outcomeFilterLabel(filters.outcome)]),
})
if (filters.security) chips.push({ key: 'security', label: t('Security events') })
if (filters.security)
chips.push({ key: 'security', label: t('Type: {0}', [t('Security events')]) })
if (filters.range !== DEFAULT_RANGE) {
const value =
filters.range === 'custom'
+30 -15
View File
@@ -8,15 +8,18 @@ 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 ActivityExportDialog from '@/components/ActivityExportDialog.vue'
import ActivityExportMenu from '@/components/ActivityExportMenu.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 { useActivityExport } from '@/composables/useActivityExport'
import { useActivityPage } from '@/composables/useActivityPage'
import { useSessionStore } from '@/stores/session'
import { defaultFilters, type ActivityFilters } from '@/lib/activity'
import type { ActivityFilters } from '@/lib/activity'
import type { StripKey } from '@/lib/activitySummary'
const DESKTOP_QUERY = '(min-width: 48rem)'
@@ -38,7 +41,6 @@ const {
chips,
isFullView,
isEmpty,
exporting,
exportDisabled,
showListSkeleton,
showSummarySkeleton,
@@ -49,6 +51,7 @@ const {
canNext,
} = activity
const exportFlow = useActivityExport(filters)
const sheetOpen = ref(false)
let started = false
let opener: HTMLElement | null = null
@@ -95,9 +98,9 @@ function onFilters(next: ActivityFilters) {
}
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 })
if (key === 'total') activity.clearAll()
else if (key === 'failed') activity.toggleFailed()
else if (key === 'security') activity.toggleSecurity()
}
function onOpen(item: { id: number }, element: HTMLElement) {
@@ -135,17 +138,16 @@ function goHome() {
<h1 class="text-xl font-semibold text-highlighted">{{ title }}</h1>
<p class="text-muted">{{ subtitle }}</p>
</div>
<UButton
<ActivityExportMenu
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>
:total="total"
:is-desktop="isDesktop"
:disabled="exportDisabled || exportFlow.currentBusy.value"
:nothing-to-export="isEmpty"
:forbidden="exportFlow.forbidden.value"
@current="exportFlow.exportCurrentView"
@all="exportFlow.openDialog"
/>
</div>
<ActivityEmptyPanel
@@ -268,6 +270,19 @@ function goHome() {
</template>
</div>
<ActivityExportDialog
v-if="isFullView && !forbidden"
v-model:open="exportFlow.dialogOpen.value"
v-model:range="exportFlow.range.value"
v-model:from="exportFlow.from.value"
v-model:to="exportFlow.to.value"
:stage="exportFlow.stage.value"
:can-submit="exportFlow.canSubmit.value"
:file-name="exportFlow.startedFileName.value"
@submit="exportFlow.submit"
@close="exportFlow.closeDialog"
/>
<ActivityDetailsPanel
:item="selectedItem"
:position="position"
@@ -0,0 +1,324 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import { initializeLocale, setLocale } from '@/i18n'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import {
LIST_PATH,
SUMMARY_PATH,
callsTo,
chipTexts,
click,
clickButton,
findButton,
harness,
lastListQuery,
mountActivity,
queryObject,
stripCards,
textOf,
unmountActivity,
} from './activityHarness'
import { makeItem, pageOf } 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' }
const FAILED = 2
const SECURITY = 3
const FAILED_CHIP = 'Outcome: Failed'
const SECURITY_CHIP = 'Type: Security events'
beforeEach(() => {
localStorage.clear()
initializeLocale()
})
afterEach(async () => {
unmountActivity()
vi.mocked(useApi).mockReset()
await router.push('/login')
})
function card(index: number) {
return stripCards()[index]!
}
function pressed(index: number) {
return card(index).getAttribute('aria-pressed')
}
function segmentSelection() {
const group = document.body.querySelector('[role="group"][aria-label="Outcome"]')!
return textOf(group.querySelector('[aria-pressed="true"]'))
}
async function clickSegment(text: string) {
const group = document.body.querySelector('[role="group"][aria-label="Outcome"]')!
await click(
Array.from(group.querySelectorAll('button')).find((button) => textOf(button) === text),
)
}
async function hover(element: Element) {
element.dispatchEvent(new MouseEvent('mouseenter', { bubbles: false }))
await flushPromises()
}
async function leave(element: Element) {
element.dispatchEvent(new MouseEvent('mouseleave', { bubbles: false }))
await flushPromises()
}
function hintOf(index: number) {
return textOf(card(index).querySelector('[data-testid="activity-strip-subline"]'))
}
describe('Cards: the toggle cycle keeps card, chip, segment and request in sync', () => {
it.each([
['Failed actions', FAILED, FAILED_CHIP, 'Failed', { outcome: 'failed' }],
['Security events', SECURITY, SECURITY_CHIP, 'All', { security: 'true' }],
])('%s', async (_, index, chip, segment, query) => {
await mountActivity()
const listCalls = callsTo(LIST_PATH).length
await click(card(index))
expect(pressed(index)).toBe('true')
expect(chipTexts()).toEqual([chip])
expect(findButton('Clear all')).toBeDefined()
expect(segmentSelection()).toBe(segment)
expect(callsTo(LIST_PATH)).toHaveLength(listCalls + 1)
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, ...query })
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
await click(card(index))
expect(pressed(index)).toBe('false')
expect(chipTexts()).toEqual([])
expect(findButton('Clear all')).toBeUndefined()
expect(segmentSelection()).toBe('All')
expect(queryObject(lastListQuery())).toEqual(DEFAULT_QUERY)
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
})
})
describe('Cards: every way of removing the filter returns all three controls to default', () => {
async function removeChip(label: string) {
await click(document.body.querySelector(`button[aria-label="Remove filter ${label}"]`))
}
it.each([
['Failed actions', FAILED, FAILED_CHIP, 'the x of the chip', () => removeChip(FAILED_CHIP)],
['Failed actions', FAILED, FAILED_CHIP, 'Clear all', () => clickButton('Clear all')],
['Failed actions', FAILED, FAILED_CHIP, 'All in the segment', () => clickSegment('All')],
[
'Failed actions',
FAILED,
FAILED_CHIP,
'Succeeded in the segment',
() => clickSegment('Succeeded'),
],
[
'Security events',
SECURITY,
SECURITY_CHIP,
'the x of the chip',
() => removeChip(SECURITY_CHIP),
],
['Security events', SECURITY, SECURITY_CHIP, 'Clear all', () => clickButton('Clear all')],
])('%s card removed with %s', async (_, index, chip, __, remove) => {
await mountActivity()
await click(card(index))
expect(chipTexts()).toEqual([chip])
await remove()
expect(pressed(index)).toBe('false')
expect(chipTexts().filter((text) => text === chip)).toEqual([])
if (chip === FAILED_CHIP && __ === 'Succeeded in the segment') {
expect(segmentSelection()).toBe('Succeeded')
expect(chipTexts()).toEqual(['Outcome: Succeeded'])
} else {
expect(segmentSelection()).toBe('All')
expect(findButton('Clear all')).toBeUndefined()
}
})
})
describe('Cards: shared state with the other controls', () => {
it('presses the Failed card when Failed is chosen in the segment', async () => {
await mountActivity()
await clickSegment('Failed')
expect(pressed(FAILED)).toBe('true')
expect(chipTexts()).toEqual([FAILED_CHIP])
})
it('presses both cards together', async () => {
await mountActivity()
await click(card(FAILED))
await click(card(SECURITY))
expect(pressed(FAILED)).toBe('true')
expect(pressed(SECURITY)).toBe('true')
expect(chipTexts()).toEqual([FAILED_CHIP, SECURITY_CHIP])
expect(queryObject(lastListQuery())).toEqual({
...DEFAULT_QUERY,
outcome: 'failed',
security: 'true',
})
})
it('removes only its own chip when the card is unpressed among other filters', async () => {
await mountActivity()
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'auth')
await flushPromises()
const group = document.body.querySelector('[role="group"][aria-label="Dates"]')!
await click(
Array.from(group.querySelectorAll('button')).find(
(button) => textOf(button) === '30 days',
),
)
await click(card(FAILED))
expect(chipTexts()).toEqual(['Category: Authentication', FAILED_CHIP, 'Dates: 30 days'])
expect(findButton('Clear all')).toBeDefined()
await click(card(FAILED))
expect(chipTexts()).toEqual(['Category: Authentication', 'Dates: 30 days'])
expect(queryObject(lastListQuery())).toEqual({
category: 'auth',
range: '30d',
page: '1',
size: '25',
})
})
it('unpresses the cards from Clear filters in the empty panel', async () => {
await mountActivity({
list: (query) =>
query.get('security') ? pageOf([], { total: 0 }) : pageOf([makeItem({ id: 1 })]),
})
await click(card(SECURITY))
expect(textOf(document.body)).toContain('No activity matches these filters')
await clickButton('Clear filters')
expect(pressed(SECURITY)).toBe('false')
expect(chipTexts()).toEqual([])
})
it('shares the state with the mobile Filters sheet', async () => {
await mountActivity({ desktop: false })
await click(card(FAILED))
expect(textOf(document.body.querySelector('[data-testid="activity-filter-count"]'))).toBe(
'1',
)
await click(
Array.from(document.body.querySelectorAll('button')).find((button) =>
textOf(button).startsWith('Filters'),
),
)
const group = document.body.querySelector('[role="group"][aria-label="Outcome"]')!
expect(textOf(group.querySelector('[aria-pressed="true"]'))).toBe('Failed')
await click(
Array.from(group.querySelectorAll('button')).find((button) => textOf(button) === 'All'),
)
expect(pressed(FAILED)).toBe('false')
})
})
describe('Cards: hint lines, hover and keyboard', () => {
it('words the hint for unpressed, hovered, pressed and pressed-hovered', async () => {
await mountActivity()
const failed = card(FAILED)
expect(hintOf(FAILED)).toBe('0.5% of all actions')
await hover(failed)
expect(hintOf(FAILED)).toBe('Click to filter the list')
expect(failed.className).toContain('hover:bg-elevated')
await leave(failed)
await click(failed)
expect(hintOf(FAILED)).toBe('Filtering. Click to clear')
await hover(failed)
expect(hintOf(FAILED)).toBe('Click to clear this filter')
expect(failed.className).toContain('bg-(--rc-sand)')
expect(failed.className).not.toContain('hover:bg-elevated')
expect(textOf(failed.querySelector('span:last-child'))).toContain(
'Click to clear this filter',
)
})
it('uses the sand tokens for a pressed card and the focus-visible outline', async () => {
await mountActivity()
await click(card(SECURITY))
expect(card(SECURITY).className).toContain('bg-(--rc-sand)')
expect(card(SECURITY).className).toContain('border-primary')
expect(card(SECURITY).querySelector('.text-\\(--rc-on-sand\\)')).not.toBeNull()
expect(card(SECURITY).className).toContain('focus-visible:outline-2')
expect(card(SECURITY).className).toContain('focus-visible:outline-offset-2')
expect(card(SECURITY).className).toContain('focus-visible:outline-primary')
})
it.each([['Enter'], [' ']])('toggles with the %j key on the focused card', async (key) => {
await mountActivity()
const failed = card(FAILED)
failed.focus()
expect(document.activeElement).toBe(failed)
failed.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
failed.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }))
await click(failed)
expect(pressed(FAILED)).toBe('true')
})
})
describe('Cards: Total actions and Active people are not toggles', () => {
it('clears everything from Total and sends nothing from Active people', async () => {
await mountActivity()
await click(card(FAILED))
await click(card(0))
expect(pressed(FAILED)).toBe('false')
expect(chipTexts()).toEqual([])
const before = harness.calls.length
await click(card(1))
expect(harness.calls).toHaveLength(before)
expect(card(0).hasAttribute('aria-pressed')).toBe(false)
expect(card(1).hasAttribute('aria-pressed')).toBe(false)
})
})
describe('Cards: French', () => {
it('words the chip and the hints in French', async () => {
setLocale('fr')
await mountActivity()
await click(card(SECURITY))
expect(chipTexts()).toEqual(['Type : Événements de sécurité'])
expect(hintOf(SECURITY)).toBe('Filtre actif. Cliquez pour l’effacer')
await hover(card(SECURITY))
expect(hintOf(SECURITY)).toBe('Cliquez pour effacer ce filtre')
})
})
@@ -0,0 +1,623 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import USelect from '@nuxt/ui/components/Select.vue'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { initializeLocale, setLocale } from '@/i18n'
import { messages } from '@/i18n/messages'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import {
CSV_HEADER_ONLY,
EXPORT_PATH,
callsTo,
click,
csvBlob,
dialogButton,
dialogElement,
exportButton,
findButton,
harness,
mountActivity,
menuItem,
menuItems,
openExportMenu,
queryObject,
stripCards,
textOf,
unmountActivity,
} from './activityHarness'
import { makeItem, pageOf } 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[] = []
const CURRENT_FILE = /^activity-\d{4}-\d{2}-\d{2}\.csv$/
const ALL_FILE = /^activity-all-\d{4}-\d{2}-\d{2}\.csv$/
const SERVER_COPY = 'Something went wrong on our side. Nothing was changed. Try again in a moment.'
const BAD_REQUEST_COPY = "That request wasn't valid. Check the fields and try again."
const OWNERS_ONLY = 'Only owners and admins can export all activity.'
const NOTHING_FOR_FILTERS = 'Nothing to export. No events match these filters.'
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
description?: string
}
return {
color: options.color,
icon: options.icon,
title: options.title,
description: options.description,
}
}
function deferred<T>() {
let resolve: (value: T) => void = () => undefined
let reject: (reason: unknown) => void = () => undefined
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, resolve, reject }
}
async function openDialog() {
await openExportMenu()
await click(menuItem('Export all activity…'))
await vi.waitFor(() => expect(dialogElement()).not.toBeNull())
}
async function submitDialog() {
await click(dialogButton('Export CSV'))
}
const RANGE_VALUES: Record<string, string> = {
'All time': 'all',
Today: 'today',
'7 days': '7d',
'30 days': '30d',
Custom: 'custom',
}
async function chooseRange(label: string) {
harness.wrapper!.findComponent(USelect).vm.$emit('update:modelValue', RANGE_VALUES[label])
await flushPromises()
}
function setDate(index: number, value: string) {
const field = document.body.querySelectorAll<HTMLInputElement>(
'[role="dialog"] input[type="date"]',
)[index]!
field.value = value
field.dispatchEvent(new Event('input', { bubbles: true }))
return flushPromises()
}
function footerLabels() {
return Array.from(
dialogElement()!.querySelectorAll<HTMLButtonElement>('[data-slot="footer"] button'),
).map(textOf)
}
describe('Export menu: structure', () => {
it('renders one outline button with the download icon, a label and a chevron', async () => {
await mountActivity()
const button = exportButton()!
expect(textOf(button)).toBe('Export')
expect(button.disabled).toBe(false)
expect(
button.querySelectorAll('svg').length +
button.querySelectorAll('span[class*="i-"]').length,
).toBeGreaterThanOrEqual(2)
})
it('lists both items with their sub-lines and total 1,284, and Escape closes it', async () => {
await mountActivity({ list: () => pageOf([makeItem({ id: 1 })], { total: 1284 }) })
await openExportMenu()
expect(menuItems().map(textOf)).toEqual([
'Export current view' +
'CSV · 1,284 events matching your filters and sort, across all pages',
'Export all activity…CSV · every event, ignoring filters. Owners and admins only.',
])
expect(menuItems().every((item) => item.getAttribute('aria-disabled') !== 'true')).toBe(
true,
)
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }),
)
await flushPromises()
await vi.waitFor(() => expect(menuItems()).toHaveLength(0))
await vi.waitFor(() => expect(document.activeElement).toBe(exportButton()))
})
it('states the filtered count and the singular form', async () => {
await mountActivity({
list: (query) =>
pageOf([makeItem({ id: 1 })], { total: query.get('outcome') ? 6 : 1284 }),
})
await click(stripCards()[2])
await openExportMenu()
expect(textOf(menuItem('Export current view'))).toContain(
'CSV · 6 events matching your filters and sort, across all pages',
)
})
it('uses the singular for one event', async () => {
await mountActivity({ list: () => pageOf([makeItem({ id: 1 })], { total: 1 }) })
await openExportMenu()
expect(textOf(menuItem('Export current view'))).toContain(
'CSV · 1 event matching your filters and sort, across all pages',
)
})
it('opens from the keyboard with Enter or ArrowDown on the button', async () => {
await mountActivity()
const button = exportButton()!
button.focus()
button.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
)
await flushPromises()
await vi.waitFor(() => expect(menuItems()).toHaveLength(2))
})
it('renders a bottom sheet below md', async () => {
await mountActivity({ desktop: false })
await click(exportButton())
await vi.waitFor(() => expect(menuItems()).toHaveLength(2))
const sheet = document.body.querySelector('[role="dialog"]')!
expect(textOf(sheet)).toContain('Export current view')
expect(textOf(sheet)).toContain('Export all activity…')
})
})
describe('Export menu: disabled states', () => {
it('disables the current view with its reason when nothing matches', async () => {
await mountActivity({
list: (query) =>
query.get('security') ? pageOf([], { total: 0 }) : pageOf([makeItem({ id: 1 })]),
})
await click(stripCards()[3])
await openExportMenu()
expect(exportButton()!.disabled).toBe(false)
expect(menuItem('Export current view')!.getAttribute('aria-disabled')).toBe('true')
expect(textOf(menuItem('Export current view'))).toContain(NOTHING_FOR_FILTERS)
expect(menuItem('Export all activity…')!.getAttribute('aria-disabled')).not.toBe('true')
})
it('disables the button for No activity yet and for the Error state', async () => {
await mountActivity({ list: () => pageOf([], { total: 0 }) })
expect(exportButton()!.disabled).toBe(true)
unmountActivity()
await mountActivity({
list: () => {
throw new ApiError(500)
},
})
expect(exportButton()!.disabled).toBe(true)
})
it('shows no Export control for other roles or the Forbidden panel', async () => {
await mountActivity({ role: 'editor' })
expect(exportButton()).toBeUndefined()
expect(callsTo(EXPORT_PATH)).toHaveLength(0)
unmountActivity()
await mountActivity({
list: () => {
throw new ApiError(403)
},
})
expect(exportButton()).toBeUndefined()
expect(dialogElement()).toBeNull()
})
})
describe('Export current view', () => {
it('sends the list filters without page, size or format, downloads and toasts', async () => {
await mountActivity()
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 openExportMenu()
await click(menuItem('Export current view'))
await vi.waitFor(() => expect(clicked).toHaveLength(1))
expect(callsTo(EXPORT_PATH)).toHaveLength(1)
expect(queryObject(callsTo(EXPORT_PATH)[0])).toEqual({ category: 'auth', range: '30d' })
expect(clicked[0]!.download).toMatch(CURRENT_FILE)
expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
expect(toastCall()).toEqual({
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Download started',
description: `${clicked[0]!.download} is downloading.`,
})
expect(menuItems()).toHaveLength(0)
expect(dialogElement()).toBeNull()
})
it.each([
['a 500 response', () => new ApiError(500), SERVER_COPY],
['a network failure', () => new NetworkError(), SERVER_COPY],
['a 400 response', () => new ApiError(400), BAD_REQUEST_COPY],
])('raises an error toast after %s', async (_, failure, body) => {
await mountActivity({
exportFile: () => {
throw failure()
},
})
await openExportMenu()
await click(menuItem('Export current view'))
expect(toastCall()).toEqual({
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't prepare the download",
description: body,
})
expect(clicked).toHaveLength(0)
expect(exportButton()!.disabled).toBe(false)
})
it('reports a lowered role on 403 and disables both items afterwards', async () => {
await mountActivity({
exportFile: () => {
throw new ApiError(403)
},
})
await openExportMenu()
await click(menuItem('Export current view'))
expect(toastCall().title).toBe("You can't export all activity")
expect(toastCall().color).toBe('error')
expect(clicked).toHaveLength(0)
await openExportMenu()
for (const item of menuItems()) {
expect(item.getAttribute('aria-disabled')).toBe('true')
expect(textOf(item)).toContain(OWNERS_ONLY)
}
})
it('downloads nothing and says so when the CSV has only the header row', async () => {
await mountActivity({ exportFile: () => csvBlob(CSV_HEADER_ONLY) })
await openExportMenu()
await click(menuItem('Export current view'))
await vi.waitFor(() => expect(toastAdd).toHaveBeenCalledTimes(1))
expect(clicked).toHaveLength(0)
expect(toastCall().title).toBe('Nothing to export')
})
})
describe('Export all: the dialog', () => {
it('opens configured with a read-only CSV format and no request', async () => {
await mountActivity()
await openDialog()
const dialog = dialogElement()!
expect(textOf(dialog)).toContain('Export all activity')
expect(textOf(dialog)).toContain('Format')
const format = dialog.querySelector<HTMLInputElement>('input[readonly]')!
expect(format.value).toBe('CSV')
expect(textOf(dialog)).toContain('Date range')
expect(textOf(dialog)).toContain('All time')
expect(textOf(dialog)).toContain('Default is all time. Filters on the page are ignored.')
expect(textOf(dialog)).not.toContain('JSON')
expect(footerLabels()).toEqual(['Cancel', 'Export CSV'])
expect(callsTo(EXPORT_PATH)).toHaveLength(0)
})
it.each([
['All time', { range: 'all' }],
['Today', { range: 'today' }],
['7 days', { range: '7d' }],
['30 days', { range: '30d' }],
])('sends only the dialog range for %s and ignores the page filters', async (label, query) => {
await mountActivity()
harness.wrapper!.findComponent(ActivityCategorySelect).vm.$emit('select', 'media')
await flushPromises()
await click(stripCards()[2])
await openDialog()
if (label !== 'All time') await chooseRange(label)
await submitDialog()
expect(callsTo(EXPORT_PATH)).toHaveLength(1)
expect(queryObject(callsTo(EXPORT_PATH)[0])).toEqual(query)
expect(callsTo(EXPORT_PATH)[0]!.has('format')).toBe(false)
await vi.waitFor(() => expect(clicked).toHaveLength(1))
})
it('sends the custom pair and keeps Export CSV disabled until it is valid', async () => {
await mountActivity()
await openDialog()
await chooseRange('Custom')
expect(dialogButton('Export CSV')!.disabled).toBe(true)
await setDate(0, '2026-09-30')
expect(dialogButton('Export CSV')!.disabled).toBe(true)
await setDate(1, '2026-09-01')
expect(dialogButton('Export CSV')!.disabled).toBe(true)
await submitDialog()
expect(callsTo(EXPORT_PATH)).toHaveLength(0)
await setDate(0, '2026-09-01')
await setDate(1, '2026-09-30')
expect(dialogButton('Export CSV')!.disabled).toBe(false)
await submitDialog()
expect(queryObject(callsTo(EXPORT_PATH)[0])).toEqual({
range: 'custom',
from: '2026-09-01',
to: '2026-09-30',
})
await vi.waitFor(() => expect(clicked).toHaveLength(1))
})
it('shows Preparing with an indeterminate bar, then Download started and a toast on Close', async () => {
const pending = deferred<Blob>()
await mountActivity({ exportFile: () => pending.promise })
await openDialog()
await submitDialog()
const dialog = dialogElement()!
expect(textOf(dialog)).toContain('Preparing download…')
expect(textOf(dialog)).toContain('Keep this window open.')
const bar = dialog.querySelector('[role="progressbar"]')!
expect(bar.hasAttribute('aria-valuenow')).toBe(false)
expect(dialogButton('Cancel')!.disabled).toBe(false)
expect(dialogButton('Preparing…')!.disabled).toBe(true)
expect(clicked).toHaveLength(0)
pending.resolve(csvBlob('when,actor\nrow,row\n'))
await vi.waitFor(() => expect(clicked).toHaveLength(1))
expect(clicked).toHaveLength(1)
expect(clicked[0]!.download).toMatch(ALL_FILE)
expect(textOf(dialogElement())).toContain('Download started')
expect(textOf(dialogElement())).toContain(
`${clicked[0]!.download} is downloading. Check your browser's downloads if nothing appears.`,
)
expect(footerLabels()).toEqual(['Close'])
expect(toastAdd).not.toHaveBeenCalled()
await click(dialogButton('Close'))
await vi.waitFor(() => expect(dialogElement()).toBeNull())
expect(toastCall()).toMatchObject({
color: 'success',
title: 'Download started',
description: `${clicked[0]!.download} is downloading.`,
})
})
it('aborts the request on Cancel while preparing, with no download and no toast', async () => {
await mountActivity({ exportFile: () => new Promise<Blob>(() => undefined) })
await openDialog()
await submitDialog()
const signal = harness.signals[0]!
expect(signal.aborted).toBe(false)
await click(dialogButton('Cancel'))
expect(signal.aborted).toBe(true)
await vi.waitFor(() => expect(dialogElement()).toBeNull())
expect(clicked).toHaveLength(0)
expect(toastAdd).not.toHaveBeenCalled()
})
it.each([
['a 500 response', () => new ApiError(500)],
['a network failure', () => new NetworkError()],
])('shows Retry after %s and resends the same request', async (_, failure) => {
let fail = true
await mountActivity({
exportFile: () => {
if (fail) throw failure()
return new Promise<Blob>(() => undefined)
},
})
await openDialog()
await chooseRange('30 days')
await submitDialog()
expect(textOf(dialogElement())).toContain("Couldn't prepare the download")
expect(textOf(dialogElement())).toContain('Nothing was downloaded. Try again in a moment.')
expect(footerLabels()).toEqual(['Cancel', 'Retry'])
fail = false
await click(dialogButton('Retry'))
expect(callsTo(EXPORT_PATH)).toHaveLength(2)
expect(queryObject(callsTo(EXPORT_PATH)[1])).toEqual(queryObject(callsTo(EXPORT_PATH)[0]))
expect(textOf(dialogElement())).toContain('Preparing download…')
})
it('says Nothing to export for a header-only CSV', async () => {
await mountActivity({ exportFile: () => csvBlob(CSV_HEADER_ONLY) })
await openDialog()
await submitDialog()
await vi.waitFor(() => expect(textOf(dialogElement())).toContain('Nothing to export'))
expect(clicked).toHaveLength(0)
expect(textOf(dialogElement())).toContain(
'There is no activity in this date range. Choose a wider range.',
)
expect(footerLabels()).toEqual(['Close'])
})
it('explains a 403 and disables both menu items afterwards', async () => {
await mountActivity({
exportFile: () => {
throw new ApiError(403)
},
})
await openDialog()
await submitDialog()
expect(clicked).toHaveLength(0)
expect(textOf(dialogElement())).toContain("You can't export all activity")
expect(textOf(dialogElement())).toContain(
'Only owners and admins can export all activity. Ask an owner if you need a copy.',
)
expect(footerLabels()).toEqual(['Close'])
await click(dialogButton('Close'))
await vi.waitFor(() => expect(dialogElement()).toBeNull())
await openExportMenu()
expect(menuItems().every((item) => item.getAttribute('aria-disabled') === 'true')).toBe(
true,
)
})
it('is a bottom sheet below md with the same fields and footer', async () => {
await mountActivity({ desktop: false })
await click(exportButton())
await vi.waitFor(() => expect(menuItems()).toHaveLength(2))
await click(menuItem('Export all activity…'))
await vi.waitFor(() => expect(textOf(document.body)).toContain('Date range'))
const dialog = Array.from(document.body.querySelectorAll('[role="dialog"]')).find(
(candidate) => textOf(candidate).includes('Date range'),
)!
expect(dialog.className).toContain('max-md:!bottom-0')
expect(textOf(dialog)).toContain('Format')
expect(textOf(dialog)).toContain('All time')
expect(
Array.from(
dialog.querySelectorAll<HTMLButtonElement>('[data-slot="footer"] button'),
).map(textOf),
).toEqual(['Cancel', 'Export CSV'])
})
})
describe('Export: no JSON, French', () => {
it('offers no JSON anywhere and never sends a format parameter', async () => {
await mountActivity()
await openExportMenu()
expect(textOf(document.body)).not.toContain('JSON')
await click(menuItem('Export current view'))
await vi.waitFor(() => expect(clicked).toHaveLength(1))
await vi.waitFor(() => expect(exportButton()!.disabled).toBe(false))
await openDialog()
await submitDialog()
expect(textOf(document.body)).not.toContain('JSON')
for (const query of callsTo(EXPORT_PATH)) expect(query.has('format')).toBe(false)
await vi.waitFor(() => expect(clicked).toHaveLength(2))
})
it('has a French entry for every new string', () => {
const keys = [
'Export',
'Export current view',
'Export all activity…',
'CSV · {0} events matching your filters and sort, across all pages',
'CSV · every event, ignoring filters. Owners and admins only.',
'Nothing to export. No events match these filters.',
OWNERS_ONLY,
'Export all activity',
'Date range',
'All time',
'Default is all time. Filters on the page are ignored.',
'Preparing download…',
'Preparing…',
'Download started',
'{0} is downloading.',
"Couldn't prepare the download",
'Nothing was downloaded. Try again in a moment.',
'Nothing to export',
"You can't export all activity",
SERVER_COPY,
BAD_REQUEST_COPY,
'Type: {0}',
'Click to filter the list',
'Filtering. Click to clear',
'Click to clear this filter',
]
for (const key of keys) {
expect(messages[key as keyof typeof messages], key).toBeTruthy()
}
})
it('words the menu, dialog and toast in French', async () => {
setLocale('fr')
await mountActivity()
expect(textOf(findButton('Exporter'))).toBe('Exporter')
await openExportMenu()
expect(textOf(menuItem('Exporter la vue actuelle'))).toContain(
'CSV · 1 284 événements correspondant à vos filtres et à votre tri, sur toutes les pages',
)
await click(menuItem('Exporter toute l’activité…'))
await vi.waitFor(() => expect(dialogElement()).not.toBeNull())
expect(textOf(dialogElement())).toContain('Période')
expect(textOf(dialogElement())).toContain('Toute la période')
await click(dialogButton('Exporter en CSV'))
await vi.waitFor(() => expect(textOf(dialogElement())).toContain('Téléchargement lancé'))
})
})
@@ -284,7 +284,7 @@ describe('Strip cards apply their filter', () => {
await click(stripCards()[3])
expect(queryObject(lastListQuery())).toEqual({ ...DEFAULT_QUERY, security: 'true' })
expect(chipTexts()).toEqual(['Security events'])
expect(chipTexts()).toEqual(['Type: Security events'])
expect(stripCards()[3]!.getAttribute('aria-pressed')).toBe('true')
})
+3 -86
View File
@@ -1,17 +1,15 @@
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 { useApi } from '@/composables/useApi'
import { initializeLocale, setLocale } from '@/i18n'
import {
EXPORT_PATH,
LIST_PATH,
SUMMARY_PATH,
callsTo,
click,
days,
findButton,
harness,
mountActivity,
queryObject,
rows,
@@ -19,7 +17,6 @@ import {
textOf,
unmountActivity,
} from './activityHarness'
import ActivityCategorySelect from '@/components/ActivityCategorySelect.vue'
import { activityItems, makeItem, pageOf, summaryDefault } from './activityFixtures'
vi.mock('@/composables/useApi', async (importOriginal) => {
@@ -34,24 +31,13 @@ vi.mock('@nuxt/ui/composables', async (importOriginal) => {
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()
@@ -59,75 +45,6 @@ afterEach(async () => {
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({
@@ -142,7 +59,7 @@ describe('My activity: a role that is not owner or admin sees its own events', (
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()
expect(findButton('Export')).toBeUndefined()
const headers = Array.from(document.body.querySelectorAll('[role="columnheader"]')).map(
textOf,
)
@@ -204,7 +121,7 @@ describe('The French interface', () => {
})
expect(textOf(document.body.querySelector('h1'))).toBe('Activité')
expect(textOf(findButton('Exporter en CSV'))).toBe('Exporter en CSV')
expect(textOf(findButton('Exporter'))).toBe('Exporter')
const strip = stripCards().map(textOf)
expect(strip[0]).toContain('Actions au total')
expect(strip[1]).toContain('Personnes actives')
+6 -6
View File
@@ -98,7 +98,7 @@ describe('Default: an owner sees the strip, the filter bar and a day-grouped tab
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(findButton('Export')?.disabled).toBe(false)
expect(stripTexts()).toEqual([
expect.stringContaining('Total actions'),
expect.stringContaining('Active people'),
@@ -410,7 +410,7 @@ describe('Loading: skeletons after 150 ms', () => {
await mountActivity({ list: () => never, summary: () => never })
expect(document.body.querySelector('h1')?.textContent).toBe('Activity')
expect(findButton('Export CSV')?.disabled).toBe(false)
expect(findButton('Export')?.disabled).toBe(false)
expect(
document.body
.querySelector('[data-testid="activity-filter-bar"] input')
@@ -536,7 +536,7 @@ describe('Empty states', () => {
'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(findButton('Export')?.disabled).toBe(true)
expect(document.body.querySelector('[role="table"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
const texts = stripTexts()
@@ -563,7 +563,7 @@ describe('Empty states', () => {
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(findButton('Export')?.disabled).toBe(false)
expect(callsTo(SUMMARY_PATH)).toHaveLength(1)
empty = false
@@ -601,7 +601,7 @@ describe('Error with retry', () => {
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(findButton('Export')?.disabled).toBe(true)
expect(document.body.querySelector('[role="table"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-pager"]')).toBeNull()
@@ -624,7 +624,7 @@ describe('Forbidden (403)', () => {
})
expect(textOf(document.body.querySelector('h1'))).toBe('Activity')
expect(findButton('Export CSV')).toBeUndefined()
expect(findButton('Export')).toBeUndefined()
expect(stripCards()).toHaveLength(0)
expect(document.body.querySelector('[data-testid="activity-filter-bar"]')).toBeNull()
expect(document.body.querySelector('[data-testid="activity-chips"]')).toBeNull()
+48 -2
View File
@@ -41,8 +41,17 @@ export const EXPORT_PATH = '/v1/activity/export'
export const USERS_PATH = '/v1/user/'
export const KEYS_PATH = '/v1/api-key/'
export const CSV_HEADER = 'when,actor,action,target,outcome,source,ip'
export const CSV_WITH_ROWS = `${CSV_HEADER}\n2026-10-06,Marta Alves,Published,Spring release,success,web,203.0.113.24\n2026-10-05,Ines Duarte,Deleted,old-banner.png,success,web,203.0.113.12\n`
export const CSV_HEADER_ONLY = `${CSV_HEADER}\n`
export function csvBlob(text: string) {
return new Blob([text], { type: 'text/csv' })
}
export const harness = {
calls: [] as RecordedCall[],
signals: [] as (AbortSignal | undefined)[],
wrapper: null as VueWrapper | null,
}
@@ -65,12 +74,13 @@ export async function mountActivity(options: MountOptions = {}) {
const api: ActivityApiMock = {
list: () => pageOf(activityItems),
summary: () => summaryDefault,
exportFile: () => new Blob(['when,actor'], { type: 'text/csv' }),
exportFile: () => csvBlob(CSV_WITH_ROWS),
users: () => [],
keys: () => [],
...options,
}
harness.calls = []
harness.signals = []
counters.clear()
setViewportWidth(desktop ? DESKTOP_WIDTH : MOBILE_WIDTH)
setActivePinia(createPinia())
@@ -105,7 +115,8 @@ export async function mountActivity(options: MountOptions = {}) {
throw new Error(`Unexpected request: ${path}`)
}
})
const requestBlob = vi.fn(async (path: string) => {
const requestBlob = vi.fn(async (path: string, options?: RequestInit) => {
harness.signals.push(options?.signal ?? undefined)
const url = record(path)
return respond(api.exportFile, url.searchParams, url.pathname) as Blob
})
@@ -204,3 +215,38 @@ export async function expectIcon(root: Element | null | undefined, name: string,
await new Promise((resolve) => setTimeout(resolve, 50))
expect(hasIcon(root, name)).toBe(false)
}
export function exportButton() {
return findButton('Export') ?? findButton('Exporter')
}
export function menuItems() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
}
export function menuItem(label: string) {
return menuItems().find((item) => textOf(item).startsWith(label))
}
export async function openExportMenu() {
await click(exportButton())
await vi.waitFor(() => expect(menuItems().length).toBe(2))
}
export function dialogElement() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')
}
export function dialogButton(label: string) {
return Array.from(dialogElement()?.querySelectorAll<HTMLButtonElement>('button') ?? []).find(
(button) => textOf(button) === label || button.getAttribute('aria-label') === label,
)
}
export function dialogButtons() {
return Array.from(
dialogElement()?.querySelectorAll<HTMLButtonElement>(
'footer button, [data-slot="footer"] button',
) ?? [],
).map(textOf)
}
+39
View File
@@ -53,6 +53,45 @@ const extraSources: Record<string, Source[]> = {
},
],
activity: [
{
board: officeBoard,
group: 'Activity: filter cards',
states: [
'Default, no filter',
'Card hover',
'Card focus-visible',
'Failed card active',
'Active card hover',
'Security card active',
'Chip × hover',
'Clear all hover',
'Clear all focus-visible',
'Card plus other filters',
],
alias: {},
},
{
board: officeBoard,
group: 'Activity: export',
states: [
'Menu open',
'Menu, item hover',
'Menu, filtered view',
'Menu, nothing to export',
'Menu, no permission',
'Export button focus-visible',
'Dialog: configure, CSV only',
'Dialog: configure, CSV or JSON (proposed)',
'Dialog: preparing download',
'Dialog: download started',
'Dialog: failed',
'Dialog: nothing to export',
'Dialog: not allowed (403)',
'Toast: current view',
'Toast: export all',
],
alias: {},
},
{
board: officeBoard,
group: 'Activity details',
+45
View File
@@ -3119,6 +3119,51 @@ async function filterActivityToNothing(page: Page) {
}
const activityStates: Record<string, VisualState> = {
'Default, no filter': activityClockSensitive,
'Card hover': activityClockSensitive,
'Card focus-visible': activityClockSensitive,
'Failed card active': activityClockSensitive,
'Active card hover': activityClockSensitive,
'Security card active': activityClockSensitive,
'Chip × hover': activityClockSensitive,
'Clear all hover': activityClockSensitive,
'Clear all focus-visible': activityClockSensitive,
'Card plus other filters': activityClockSensitive,
'Menu open': activityClockSensitive,
'Menu, item hover': activityClockSensitive,
'Menu, filtered view': activityClockSensitive,
'Menu, no permission': activityClockSensitive,
'Export button focus-visible': activityClockSensitive,
'Dialog: configure, CSV only': activityClockSensitive,
'Dialog: preparing download': activityClockSensitive,
'Dialog: download started': activityClockSensitive,
'Dialog: failed': activityClockSensitive,
'Dialog: nothing to export': activityClockSensitive,
'Dialog: not allowed (403)': activityClockSensitive,
'Toast: current view': activityClockSensitive,
'Toast: export all': activityClockSensitive,
'Dialog: configure, CSV or JSON (proposed)': { path: '/activity', skip: 'pending' },
'Menu, nothing to export': {
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: async (page) => {
await filterActivityToNothing(page)
await page.getByRole('button', { name: 'Export', exact: true }).click()
await page.getByRole('menuitem').first().waitFor()
await page.mouse.move(0, 0)
await page.waitForTimeout(300)
},
},
'Default, large dataset': activityClockSensitive,
Loading: {
path: '/activity',