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
+2376 -238

No files matched your search

+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)
}