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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user