- AccountContributions.vue on My account: one card per contributed section
with status and outline buttons; confirm or danger actions go through the
shared ConfirmDialog; a returned URL opens in a new tab (noopener) and stays
as a visible link; 409 reloads with the stale toast
- ConfirmRequest.prompt and useConfirm value; ConfirmDialog renders one
labelled text input (maxlength, autocomplete off, aria-describedby comment,
autofocus), confirm disabled until a value is typed, Enter confirms
- RecordActions posts {payload} for a prompted action and {} otherwise; a 422
on payload shows the server's detail as a danger toast
- types.ts exports RecordActionPrompt, AccountContribution,
AccountContributionAction, AccountActionResult
- vitest smokes AccountContributions.test.ts and RecordActionsPrompt.test.ts
141 lines
5.6 KiB
TypeScript
141 lines
5.6 KiB
TypeScript
// 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<HTMLElement>('[data-confirm-dialog]')
|
|
const promptInput = () => dialog()?.querySelector<HTMLInputElement>('[data-confirm-prompt] input') ?? null
|
|
const confirmButton = () => dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!
|
|
|
|
function mountActions(routes: Record<string, Route>, 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<void> {
|
|
await wrapper.find(`[data-record-action="${name}"]`).trigger('click')
|
|
await flushPromises()
|
|
}
|
|
|
|
async function type(value: string): Promise<void> {
|
|
const input = promptInput()!
|
|
input.value = value
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
await flushPromises()
|
|
}
|
|
|
|
async function bodyOf(request: Request): Promise<unknown> {
|
|
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([])
|
|
})
|
|
})
|