Files
summercms/admin/tests/list/BulkActionsMenu.test.ts
Jakub Zych 84efdc09e0 test(12.1-05): unit tests for the Phase 12.1 SPA components
- BulkActionsMenu, RowStateBadges, RecordActions, PreviewField, FormErrorBanner, PasswordField and PermissionEditorField, each mounted on its own
- backstop: a row state outside the fixed set renders no badge and no class
- backstop: the permission editor's emission rules per mode, the locked row and codes outside the options
2026-10-05 15:29:44 +02:00

109 lines
4.7 KiB
TypeScript

// Bulk actions menu (UI-SPEC S1, D-09), the component on its own. The whole
// flow with its confirmation is in tests/list/ListView.test.ts. Fixtures are
// neutral acme.* data; no application names appear in framework tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import BulkActionsMenu from '../../src/components/list/BulkActionsMenu.vue'
import { langFixture } from '../fixtures/typed'
import { resetState } from '../helpers'
const LONG = 'Move the selected people to the archive of the previous season and notify their team leads'
const actions = [
{ name: 'activate', label: 'Activate', confirm: 'Activate the selected people?' },
{ name: 'archive', label: LONG },
{ name: 'markup', label: '<img src=x onerror=alert(1)>' },
]
function mountMenu(props: { actions?: typeof actions; disabled?: boolean } = {}) {
return mount(BulkActionsMenu, {
props: { actions: props.actions ?? actions, disabled: props.disabled ?? false },
attachTo: document.body,
})
}
const trigger = (wrapper: VueWrapper) => wrapper.find('[data-action="bulk-actions"]')
const items = () => Array.from(document.body.querySelectorAll<HTMLElement>('[data-bulk-menu] [data-bulk-action]'))
async function open(wrapper: VueWrapper): Promise<void> {
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
await flushPromises()
}
beforeEach(() => {
resetState()
setBundle({ ...langFixture.data, 'backend::lang.list.bulk_actions': { other: 'Bulk actions' } }, 'en')
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('bulk actions menu component (UI-SPEC S1, D-09)', () => {
it('is one outline trigger with its label and a decorative chevron', () => {
const wrapper = mountMenu()
expect(trigger(wrapper).text()).toBe('Bulk actions')
expect(trigger(wrapper).classes()).toContain('border-border-strong')
expect(trigger(wrapper).attributes('aria-haspopup')).toBe('menu')
expect(trigger(wrapper).find('svg').attributes('aria-hidden')).toBe('true')
expect(document.body.querySelector('[data-bulk-menu]')).toBeNull()
})
it('keeps its label while disabled and does not open', async () => {
const wrapper = mountMenu({ disabled: true })
expect(trigger(wrapper).text()).toBe('Bulk actions')
expect(trigger(wrapper).attributes('disabled')).toBeDefined()
await open(wrapper)
expect(document.body.querySelector('[data-bulk-menu]')).toBeNull()
await wrapper.setProps({ disabled: false })
expect(trigger(wrapper).attributes('disabled')).toBeUndefined()
})
it('lists the actions in the given order as menu items', async () => {
const wrapper = mountMenu()
await open(wrapper)
const menu = document.body.querySelector('[data-bulk-menu]')!
expect(menu.getAttribute('role')).toBe('menu')
expect(items().map((item) => item.dataset.bulkAction)).toEqual(['activate', 'archive', 'markup'])
expect(items().every((item) => item.getAttribute('role') === 'menuitem')).toBe(true)
// Declared order, not alphabetical: a reordered list renders reordered.
await wrapper.setProps({ actions: [actions[1]!, actions[0]!] })
await flushPromises()
expect(items().map((item) => item.dataset.bulkAction)).toEqual(['archive', 'activate'])
})
it('lets a long label wrap inside the menu width and renders markup as text', async () => {
const wrapper = mountMenu()
await open(wrapper)
const menu = document.body.querySelector<HTMLElement>('[data-bulk-menu]')!
expect(Array.from(menu.classList)).toEqual(expect.arrayContaining(['w-[240px]', 'max-w-[320px]']))
const [, long, markup] = items()
expect(long!.textContent?.trim()).toBe(LONG)
expect(long!.className).not.toMatch(/truncate|whitespace-nowrap|line-clamp/)
expect(long!.classList.contains('min-h-10')).toBe(true)
expect(markup!.textContent?.trim()).toBe('<img src=x onerror=alert(1)>')
expect(markup!.querySelector('img')).toBeNull()
})
it('emits the chosen action name once and closes', async () => {
const wrapper = mountMenu()
await open(wrapper)
items()[1]!.click()
await flushPromises()
expect(wrapper.emitted('select')).toEqual([['archive']])
expect(document.body.querySelector('[data-bulk-menu]')).toBeNull()
})
it('exposes focus(), which moves focus to the trigger', () => {
const wrapper = mountMenu()
const other = document.createElement('button')
document.body.appendChild(other)
other.focus()
expect(document.activeElement).toBe(other)
;(wrapper.vm as unknown as { focus: () => void }).focus()
expect(document.activeElement).toBe(trigger(wrapper).element)
})
})