From 03b39e64f2bbb2aa31b1b8dc81ed417eed8ce9e2 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Mon, 12 Oct 2026 05:13:25 +0200 Subject: [PATCH] feat(activity): update summary cards to toggles and introduce export menu --- api/handlers/activity/activityHandler.go | 2 +- docs/api.md | 2 +- features/activity-log-backend.feature | 7 + .../activity_filter_cards_and_export.feature | 228 +++++++ .../activity_filter_cards_and_export.qa.md | 63 ++ features/activity_screen.feature | 43 +- features/activity_screen.qa.md | 24 +- .../infrastructure/useCases/activityQuery.go | 3 + ...ivity_filter_cards_and_export_http_test.go | 77 +++ web/src/components/ActivityChips.vue | 4 +- web/src/components/ActivityExportDialog.vue | 248 +++++++ web/src/components/ActivityExportMenu.vue | 157 +++++ web/src/components/ActivitySummaryStrip.vue | 84 ++- web/src/composables/__tests__/useApi.spec.ts | 27 + web/src/composables/useActivity.ts | 21 +- web/src/composables/useActivityExport.ts | 201 ++++++ web/src/composables/useActivityPage.ts | 59 +- web/src/composables/useApi.ts | 9 +- web/src/i18n/messages.ts | 50 +- web/src/lib/__tests__/activity.spec.ts | 2 +- web/src/lib/activity.ts | 74 ++- web/src/views/ActivityView.vue | 45 +- web/src/views/__tests__/ActivityCards.spec.ts | 324 +++++++++ .../views/__tests__/ActivityExport.spec.ts | 623 ++++++++++++++++++ .../views/__tests__/ActivityFilters.spec.ts | 2 +- web/src/views/__tests__/ActivityRoles.spec.ts | 89 +-- web/src/views/__tests__/ActivityView.spec.ts | 12 +- web/src/views/__tests__/activityHarness.ts | 50 +- web/visual/check.ts | 39 ++ web/visual/states.ts | 45 ++ 30 files changed, 2376 insertions(+), 238 deletions(-) create mode 100644 features/activity_filter_cards_and_export.feature create mode 100644 features/activity_filter_cards_and_export.qa.md create mode 100644 test/activity_filter_cards_and_export_http_test.go create mode 100644 web/src/components/ActivityExportDialog.vue create mode 100644 web/src/components/ActivityExportMenu.vue create mode 100644 web/src/composables/useActivityExport.ts create mode 100644 web/src/views/__tests__/ActivityCards.spec.ts create mode 100644 web/src/views/__tests__/ActivityExport.spec.ts diff --git a/api/handlers/activity/activityHandler.go b/api/handlers/activity/activityHandler.go index 707d313..1764e58 100644 --- a/api/handlers/activity/activityHandler.go +++ b/api/handlers/activity/activityHandler.go @@ -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"` } diff --git a/docs/api.md b/docs/api.md index 08c06a1..6b5d267 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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`). diff --git a/features/activity-log-backend.feature b/features/activity-log-backend.feature index 2d59087..b0b1264 100644 --- a/features/activity-log-backend.feature +++ b/features/activity-log-backend.feature @@ -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 diff --git a/features/activity_filter_cards_and_export.feature b/features/activity_filter_cards_and_export.feature new file mode 100644 index 0000000..ebec12d --- /dev/null +++ b/features/activity_filter_cards_and_export.feature @@ -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 + Then the card has aria-pressed="true", is shown with "Clear all", and + And GET /v1/activity is sent once with , page=1 and size=25 + And GET /v1/activity/summary is not requested again + When the user activates the "" 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 is pressed with shown + When the user + 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 + Then an error toast titled "Couldn't prepare the download" appears with the 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 "" in the dialog and activates "Export CSV" + Then GET /v1/activity/export is sent with 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 + 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 diff --git a/features/activity_filter_cards_and_export.qa.md b/features/activity_filter_cards_and_export.qa.md new file mode 100644 index 0000000..035d91a --- /dev/null +++ b/features/activity_filter_cards_and_export.qa.md @@ -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. diff --git a/features/activity_screen.feature b/features/activity_screen.feature index 8d46337..765242a 100644 --- a/features/activity_screen.feature +++ b/features/activity_screen.feature @@ -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 - 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 diff --git a/features/activity_screen.qa.md b/features/activity_screen.qa.md index 66c5673..f5c78bb 100644 --- a/features/activity_screen.qa.md +++ b/features/activity_screen.qa.md @@ -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. diff --git a/internal/infrastructure/useCases/activityQuery.go b/internal/infrastructure/useCases/activityQuery.go index 2039b6b..7458d82 100644 --- a/internal/infrastructure/useCases/activityQuery.go +++ b/internal/infrastructure/useCases/activityQuery.go @@ -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: diff --git a/test/activity_filter_cards_and_export_http_test.go b/test/activity_filter_cards_and_export_http_test.go new file mode 100644 index 0000000..0706cf4 --- /dev/null +++ b/test/activity_filter_cards_and_export_http_test.go @@ -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) + }) +} diff --git a/web/src/components/ActivityChips.vue b/web/src/components/ActivityChips.vue index 0c9b834..2d5eed7 100644 --- a/web/src/components/ActivityChips.vue +++ b/web/src/components/ActivityChips.vue @@ -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<{ {{ t('Clear all') }} diff --git a/web/src/components/ActivityExportDialog.vue b/web/src/components/ActivityExportDialog.vue new file mode 100644 index 0000000..e5e28d0 --- /dev/null +++ b/web/src/components/ActivityExportDialog.vue @@ -0,0 +1,248 @@ + + + diff --git a/web/src/components/ActivityExportMenu.vue b/web/src/components/ActivityExportMenu.vue new file mode 100644 index 0000000..62c4d62 --- /dev/null +++ b/web/src/components/ActivityExportMenu.vue @@ -0,0 +1,157 @@ + + + diff --git a/web/src/components/ActivitySummaryStrip.vue b/web/src/components/ActivitySummaryStrip.vue index 05b39bf..b715753 100644 --- a/web/src/components/ActivitySummaryStrip.vue +++ b/web/src/components/ActivitySummaryStrip.vue @@ -1,5 +1,7 @@