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
This commit is contained in:
108
admin/tests/list/BulkActionsMenu.test.ts
Normal file
108
admin/tests/list/BulkActionsMenu.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user