Files
summercms/admin/tests/form/RecordActions.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

187 lines
8.9 KiB
TypeScript

// Record actions (UI-SPEC S2, D-10), the component on its own. Fixtures are
// neutral acme.roster.* 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 RecordActions from '../../src/components/form/RecordActions.vue'
import { toasts } from '../../src/state/useToasts'
import { langFixture } from '../fixtures/typed'
import { API, mockApi, requestsTo, resetState, type Reply, type Route } from '../helpers'
const RECORD = `${API}/acme/roster/people/4`
const source = { vendor: 'acme', plugin: 'roster', controller: 'people' }
const actions = [
{ name: 'reinstate', label: 'Reinstate', confirm: 'Lift the ban on this person?' },
{ name: 'activate', label: 'Activate' },
{ name: 'markup', label: '<b>Bold</b>' },
]
const strings = {
'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' },
'backend::lang.form.action_done': { other: 'Action completed.' },
'backend::lang.form.action_stale': { other: 'This action no longer applies to this record.' },
'backend::lang.list.action_forbidden': { other: 'You do not have permission to run this action.' },
'backend::lang.extension.action_failed': { other: 'The action could not be completed.' },
}
const done = (message: string): Reply => ({ body: { data: { message, fill: {} }, meta: {} } })
const failure = (status: number, message: string): Reply => ({ status, body: { error: { code: 'x', message, details: {} } } })
function mountActions(routes: Record<string, Route>, props: { actions?: typeof actions; disabled?: boolean } = {}) {
const calls = mockApi(routes)
const wrapper = mount(RecordActions, {
props: { source, recordId: 4, actions: props.actions ?? actions, disabled: props.disabled ?? false },
attachTo: document.body,
})
return { wrapper, calls }
}
const button = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-record-action="${name}"]`)
const dialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
async function run(wrapper: VueWrapper, name: string, answer: 'confirm' | 'cancel' = 'confirm'): Promise<void> {
await button(wrapper, name).trigger('click')
await flushPromises()
dialog()!.querySelector<HTMLButtonElement>(`[data-action="${answer}"]`)!.click()
await flushPromises()
}
beforeEach(() => {
resetState()
setBundle({ ...langFixture.data, ...strings }, 'en')
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('record actions component (UI-SPEC S2, D-10)', () => {
it('renders the offered actions in the given order, labels as text', () => {
const { wrapper } = mountActions({})
const buttons = wrapper.findAll('[data-record-action]')
expect(buttons.map((item) => item.attributes('data-record-action'))).toEqual(['reinstate', 'activate', 'markup'])
expect(buttons[2]!.text()).toBe('<b>Bold</b>')
expect(buttons[2]!.find('b').exists()).toBe(false)
expect(buttons.every((item) => item.classes().includes('border-border-strong'))).toBe(true)
// No primary button here: the host owns the one primary action.
expect(wrapper.find('.bg-primary').exists()).toBe(false)
})
it('renders nothing, not even a dialog host, without actions', () => {
const { wrapper } = mountActions({}, { actions: [] })
expect(wrapper.find('[data-record-action]').exists()).toBe(false)
expect(wrapper.html()).not.toContain('button')
})
it('asks with the action confirm text, or the default naming the action', async () => {
const { wrapper } = mountActions({})
await button(wrapper, 'reinstate').trigger('click')
await flushPromises()
expect(dialog()!.textContent).toContain('Lift the ban on this person?')
expect(dialog()!.querySelector('[data-action="confirm"]')!.textContent?.trim()).toBe('Reinstate')
dialog()!.querySelector<HTMLButtonElement>('[data-action="cancel"]')!.click()
await flushPromises()
await button(wrapper, 'activate').trigger('click')
await flushPromises()
expect(dialog()!.textContent).toContain('Run “Activate” on this record?')
})
it('posts an empty object to the action route of the record and emits done with the server message', async () => {
const { wrapper, calls } = mountActions({ [`POST ${RECORD}/actions/activate`]: done('The person was activated.') })
await run(wrapper, 'activate')
const posts = requestsTo(calls, 'POST', `${RECORD}/actions/activate`)
expect(posts).toHaveLength(1)
expect(await posts[0]!.json()).toEqual({})
expect(wrapper.emitted('done')).toEqual([['The person was activated.']])
expect(wrapper.emitted('busy')).toEqual([[true], [false]])
expect(toasts.value).toHaveLength(0)
})
it('emits done with the default text when the server sends no message', async () => {
const { wrapper } = mountActions({ [`POST ${RECORD}/actions/activate`]: done('') })
await run(wrapper, 'activate')
expect(wrapper.emitted('done')).toEqual([['Action completed.']])
})
it('sends nothing and emits nothing on cancel', async () => {
const { wrapper, calls } = mountActions({ [`POST ${RECORD}/actions/activate`]: done('x') })
await run(wrapper, 'activate', 'cancel')
expect(calls).toHaveLength(0)
expect(wrapper.emitted()).toEqual({})
expect(dialog()).toBeNull()
})
it('keeps the dialog open and busy and every button disabled until the request settles', async () => {
let release: (reply: Reply) => void = () => undefined
const pending = new Promise<Reply>((resolve) => {
release = resolve
})
const { wrapper } = mountActions({ [`POST ${RECORD}/actions/activate`]: () => pending })
await button(wrapper, 'activate').trigger('click')
await flushPromises()
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
await flushPromises()
expect(dialog()).not.toBeNull()
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.disabled).toBe(true)
expect(wrapper.findAll('[data-record-action]').every((item) => item.attributes('disabled') !== undefined)).toBe(true)
expect(wrapper.emitted('busy')).toEqual([[true]])
expect(wrapper.emitted('done')).toBeUndefined()
release(done('Done.'))
await flushPromises()
expect(dialog()).toBeNull()
expect(wrapper.findAll('[data-record-action]').every((item) => item.attributes('disabled') === undefined)).toBe(true)
expect(wrapper.emitted('busy')).toEqual([[true], [false]])
expect(wrapper.emitted('done')).toEqual([['Done.']])
})
it('409: a danger toast and the stale event, no done', async () => {
const { wrapper } = mountActions({ [`POST ${RECORD}/actions/activate`]: failure(409, 'Conflict') })
await run(wrapper, 'activate')
expect(wrapper.emitted('stale')).toHaveLength(1)
expect(wrapper.emitted('done')).toBeUndefined()
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['This action no longer applies to this record.', 'danger']])
})
it('404: the gone event and no toast', async () => {
const { wrapper } = mountActions({})
await run(wrapper, 'activate')
expect(wrapper.emitted('gone')).toHaveLength(1)
expect(wrapper.emitted('stale')).toBeUndefined()
expect(toasts.value).toHaveLength(0)
})
it('403: a danger toast with the server message or the fallback, and no event', async () => {
const withMessage = mountActions({ [`POST ${RECORD}/actions/activate`]: failure(403, 'Only team leads may activate people.') })
await run(withMessage.wrapper, 'activate')
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['Only team leads may activate people.', 'danger']])
expect(Object.keys(withMessage.wrapper.emitted())).toEqual(['busy'])
withMessage.wrapper.unmount()
resetState()
setBundle({ ...langFixture.data, ...strings }, 'en')
const bare = mountActions({ [`POST ${RECORD}/actions/activate`]: failure(403, '') })
await run(bare.wrapper, 'activate')
expect(toasts.value.map((toast) => toast.text)).toEqual(['You do not have permission to run this action.'])
})
it('any other failure: a danger toast with the server message or the fallback', async () => {
const { wrapper } = mountActions({ [`POST ${RECORD}/actions/activate`]: failure(500, '') })
await run(wrapper, 'activate')
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['The action could not be completed.', 'danger']])
expect(wrapper.emitted('done')).toBeUndefined()
})
it('does nothing while the host disables it', async () => {
const { wrapper, calls } = mountActions({}, { disabled: true })
expect(wrapper.findAll('[data-record-action]').every((item) => item.attributes('disabled') !== undefined)).toBe(true)
await button(wrapper, 'activate').trigger('click')
await flushPromises()
expect(dialog()).toBeNull()
expect(calls).toHaveLength(0)
})
})