// 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: 'Bold' }, ] 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, 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('[data-confirm-dialog]') async function run(wrapper: VueWrapper, name: string, answer: 'confirm' | 'cancel' = 'confirm'): Promise { await button(wrapper, name).trigger('click') await flushPromises() dialog()!.querySelector(`[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('Bold') 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('[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((resolve) => { release = resolve }) const { wrapper } = mountActions({ [`POST ${RECORD}/actions/activate`]: () => pending }) await button(wrapper, 'activate').trigger('click') await flushPromises() dialog()!.querySelector('[data-action="confirm"]')!.click() await flushPromises() expect(dialog()).not.toBeNull() expect(dialog()!.querySelector('[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) }) })