feat(activity): update summary cards to toggles and introduce export menu
This commit is contained in:
1 parent
73f4b539fe
commit
03b39e64f2
30 files changed
+2376
-238
No files matched your search
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user