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

@@ -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')
})
})