// Prompted record actions (phase 19.7, D-34, planner decision P-01): the // confirm dialog asks for one text value and posts it as `payload`; an // action without a prompt posts an empty body as before. import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import RecordActions from '../../src/components/form/RecordActions.vue' import { setBundle } from '../../src/app/i18n' import { toasts } from '../../src/state/useToasts' import type { RecordAction } from '../../src/api/types' import { langFixture } from '../fixtures/typed' import { API, mockApi, requestsTo, resetState, type Route } from '../helpers' const RECORD = `${API}/acme/roster/people/1` const LINK = `${RECORD}/actions/link` const PING = `${RECORD}/actions/ping` const strings = { 'backend::lang.form.action_confirm': { other: 'Run “:action” on this record?' }, 'backend::lang.form.action_done': { other: 'Action completed.' }, } const prompted: RecordAction = { name: 'link', label: 'Link site account', prompt: { label: 'Site login', placeholder: 'login', comment: 'The login of the site account.', max_length: 32 }, } const plain: RecordAction = { name: 'ping', label: 'Ping' } const ok = { body: { data: { message: 'Linked.', fill: {} }, meta: {} } } const dialog = () => document.body.querySelector('[data-confirm-dialog]') const promptInput = () => dialog()?.querySelector('[data-confirm-prompt] input') ?? null const confirmButton = () => dialog()!.querySelector('[data-action="confirm"]')! function mountActions(routes: Record, actions: RecordAction[] = [prompted, plain]) { const calls = mockApi(routes) const done: string[] = [] const wrapper = mount(RecordActions, { props: { source: { vendor: 'acme', plugin: 'roster', controller: 'people' }, recordId: 1, actions, onDone: (text: string) => done.push(text) }, attachTo: document.body, }) return { wrapper, calls, done } } async function click(wrapper: VueWrapper, name: string): Promise { await wrapper.find(`[data-record-action="${name}"]`).trigger('click') await flushPromises() } async function type(value: string): Promise { const input = promptInput()! input.value = value input.dispatchEvent(new Event('input', { bubbles: true })) await flushPromises() } async function bodyOf(request: Request): Promise { return request.clone().json() } beforeEach(() => { resetState() setBundle({ ...langFixture.data, ...strings }, 'en') }) afterEach(() => { document.body.innerHTML = '' }) enableAutoUnmount(afterEach) describe('prompted record actions', () => { it('renders the input with its label, placeholder, comment and maxlength, and keeps confirm disabled until a value is typed', async () => { const { wrapper, calls } = mountActions({ [`POST ${LINK}`]: ok }) await click(wrapper, 'link') const input = promptInput() expect(input).not.toBeNull() expect(input!.getAttribute('maxlength')).toBe('32') expect(input!.getAttribute('placeholder')).toBe('login') expect(input!.getAttribute('autocomplete')).toBe('off') expect(dialog()!.querySelector(`label[for="${input!.id}"]`)?.textContent).toBe('Site login') expect(document.getElementById(input!.getAttribute('aria-describedby')!)?.textContent).toBe('The login of the site account.') expect(confirmButton().disabled).toBe(true) await type(' ') expect(confirmButton().disabled).toBe(true) await type('acme') expect(confirmButton().disabled).toBe(false) expect(requestsTo(calls, 'POST', LINK)).toHaveLength(0) }) it('posts the trimmed value as payload and emits done', async () => { const { wrapper, calls, done } = mountActions({ [`POST ${LINK}`]: ok }) await click(wrapper, 'link') await type(' acme ') confirmButton().click() await flushPromises() const posts = requestsTo(calls, 'POST', LINK) expect(posts).toHaveLength(1) expect(await bodyOf(posts[0]!)).toEqual({ payload: 'acme' }) expect(done).toEqual(['Linked.']) expect(dialog()).toBeNull() }) it('Enter in the input confirms', async () => { const { wrapper, calls } = mountActions({ [`POST ${LINK}`]: ok }) await click(wrapper, 'link') await type('acme') promptInput()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })) await flushPromises() expect(requestsTo(calls, 'POST', LINK)).toHaveLength(1) }) it('an action without a prompt renders no input and posts an empty body', async () => { const { wrapper, calls } = mountActions({ [`POST ${PING}`]: ok }) await click(wrapper, 'ping') expect(promptInput()).toBeNull() expect(confirmButton().disabled).toBe(false) confirmButton().click() await flushPromises() const posts = requestsTo(calls, 'POST', PING) expect(posts).toHaveLength(1) expect(await bodyOf(posts[0]!)).toEqual({}) }) it("a 422 on payload shows the server's detail as a danger toast and leaves the dialog closed", async () => { const { wrapper, done } = mountActions({ [`POST ${LINK}`]: { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { payload: ['The value may not be longer than 32 characters.'] } } }, }, }) await click(wrapper, 'link') await type('acme') confirmButton().click() await flushPromises() expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['The value may not be longer than 32 characters.', 'danger']]) expect(dialog()).toBeNull() expect(done).toEqual([]) }) })