feat(12.1-01): declared record actions with an applicability rule

- pact.HasAdminRecordActions with AdminRecordAction (Applies, Run)
- config_form.yaml recordActions, compiled fail-loud
- show response meta.actions lists the permitted actions that apply
- POST .../{controller}/{id}/actions/{action}: record loaded and locked
  through the form scope; 404 out of scope, 409 when it does not apply
- RecordActions.vue with confirm and request flow (mounted by plan 02)
- roster fixture, smoke tests, OpenAPI, TS types, READMEs, docs
This commit is contained in:
Jakub Zych
2026-10-04 23:37:30 +02:00
parent a879d6388c
commit e0ccced76a
34 changed files with 1350 additions and 24 deletions

View File

@@ -1,11 +1,14 @@
// Phase 12.1 framework actions, SPA half: the bulk actions menu of a list
// (UI-SPEC S1, D-09). Fixtures are neutral acme.roster.* data; no application
// (UI-SPEC S1, D-09) and the record action buttons (UI-SPEC S2, D-10).
// Fixtures are neutral acme.roster.* data; no application
// names appear in framework tests.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import { clone, langFixture, rosterListFixture, rosterListSchemaFixture } from '../fixtures/typed'
import { API, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
import RecordActions from '../../src/components/form/RecordActions.vue'
import ToastHost from '../../src/components/ui/Toast.vue'
import { clone, langFixture, rosterListFixture, rosterListSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
import { API, mockApi, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
const LIST = `${API}/acme/roster/people`
@@ -22,6 +25,11 @@ const strings = {
},
'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. Please try again.' },
'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. The page has been refreshed.',
},
}
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
@@ -315,3 +323,141 @@ describe('bulk actions menu (UI-SPEC S1, D-09)', () => {
expect(dialog()!.querySelector('[data-action="confirm"]')!.textContent?.trim()).toBe(label)
})
})
describe('record actions (UI-SPEC S2, D-10)', () => {
const RECORD = `${LIST}/1`
const source = { vendor: 'acme', plugin: 'roster', controller: 'people' }
const offered = rosterRecordFixture.meta.actions ?? []
const done = (message: string): Reply => ({ body: { data: { message, fill: {} }, meta: {} } })
function mountActions(routes: Record<string, Route>, props: { actions?: typeof offered; disabled?: boolean } = {}) {
const calls = mockApi(routes)
const wrapper = mount(
{
components: { RecordActions, ToastHost },
props: ['actions', 'disabled'],
emits: ['busy', 'done', 'stale', 'gone'],
template: `<div>
<RecordActions :source="source" :record-id="1" :actions="actions" :disabled="disabled"
@busy="(v) => $emit('busy', v)" @done="(v) => $emit('done', v)" @stale="$emit('stale')" @gone="$emit('gone')" />
<ToastHost />
</div>`,
setup: () => ({ source }),
},
{ props: { actions: props.actions ?? offered, disabled: props.disabled ?? false }, attachTo: document.body },
)
return { wrapper, calls }
}
const button = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-record-action="${name}"]`)
it('renders one outline button per offered action, in order, and nothing without actions', () => {
const { wrapper } = mountActions({})
const buttons = wrapper.findAll('[data-record-action]')
expect(buttons.map((item) => item.attributes('data-record-action'))).toEqual(['activate', 'reinstate'])
expect(buttons.map((item) => item.text())).toEqual(['Activate', 'Reinstate'])
expect(buttons.every((item) => item.classes().includes('border-border-strong') && item.classes().includes('whitespace-nowrap'))).toBe(true)
const empty = mountActions({}, { actions: [] })
expect(empty.wrapper.find('[data-record-action]').exists()).toBe(false)
})
it('confirms, posts {} to the action route and emits done with the server message', async () => {
const { wrapper, calls } = mountActions({ [`POST ${RECORD}/actions/activate`]: done('The person was activated.') })
await button(wrapper, 'activate').trigger('click')
await flushPromises()
expect(dialog()!.textContent).toContain('Run “Activate” on this record?')
const confirm = dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!
expect(confirm.textContent?.trim()).toBe('Activate')
expect(confirm.classList.contains('bg-primary')).toBe(true)
await press('confirm')
const [post] = requestsTo(calls, 'POST', `${RECORD}/actions/activate`)
expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(await post!.json()).toEqual({})
expect(wrapper.emitted('done')).toEqual([['The person was activated.']])
expect(wrapper.emitted('busy')).toEqual([[true], [false]])
expect(dialog()).toBeNull()
})
it('uses the action confirm text and the default done text, and sends nothing on cancel', async () => {
const { wrapper, calls } = mountActions({ [`POST ${RECORD}/actions/reinstate`]: done('') })
await button(wrapper, 'reinstate').trigger('click')
await flushPromises()
expect(dialog()!.textContent).toContain('Lift the ban on this person?')
await press('cancel')
expect(requestsTo(calls, 'POST', `${RECORD}/actions/reinstate`)).toHaveLength(0)
expect(wrapper.emitted('done')).toBeUndefined()
await button(wrapper, 'reinstate').trigger('click')
await flushPromises()
await press('confirm')
expect(wrapper.emitted('done')).toEqual([['Action completed.']])
})
it('disables every button and keeps the dialog busy while an action runs', async () => {
let release: ((reply: Reply) => void) | undefined
const { wrapper } = mountActions({
[`POST ${RECORD}/actions/activate`]: () =>
new Promise<Reply>((resolve) => {
release = resolve
}),
})
await button(wrapper, 'activate').trigger('click')
await flushPromises()
await press('confirm')
expect(wrapper.findAll('[data-record-action]').every((item) => item.attributes('disabled') !== undefined)).toBe(true)
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.getAttribute('aria-busy')).toBe('true')
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="cancel"]')!.disabled).toBe(true)
expect(wrapper.emitted('busy')).toEqual([[true]])
release?.(done(''))
await flushPromises()
expect(wrapper.findAll('[data-record-action]').every((item) => item.attributes('disabled') === undefined)).toBe(true)
expect(dialog()).toBeNull()
})
it('emits stale with a toast on 409 and gone on 404', async () => {
const { wrapper } = mountActions({
[`POST ${RECORD}/actions/activate`]: error(409, 'conflict', 'Conflict'),
[`POST ${RECORD}/actions/reinstate`]: error(404, 'not_found', 'Not found'),
})
await button(wrapper, 'activate').trigger('click')
await flushPromises()
await press('confirm')
expect(wrapper.emitted('stale')).toHaveLength(1)
expect(wrapper.find('[data-tone="danger"]').text()).toContain('This action no longer applies to this record.')
await button(wrapper, 'reinstate').trigger('click')
await flushPromises()
await press('confirm')
expect(wrapper.emitted('gone')).toHaveLength(1)
expect(wrapper.emitted('done')).toBeUndefined()
})
it('toasts a 403 and any other failure with the server message or the fallback', async () => {
const { wrapper } = mountActions({
[`POST ${RECORD}/actions/activate`]: error(403, 'forbidden', ''),
[`POST ${RECORD}/actions/reinstate`]: error(500, 'error', 'The roster is locked.'),
})
await button(wrapper, 'activate').trigger('click')
await flushPromises()
await press('confirm')
expect(wrapper.text()).toContain('You do not have permission to run this action.')
await button(wrapper, 'reinstate').trigger('click')
await flushPromises()
await press('confirm')
expect(wrapper.text()).toContain('The roster is locked.')
expect(wrapper.emitted('done')).toBeUndefined()
expect(wrapper.emitted('stale')).toBeUndefined()
})
it('does nothing while the host disables it', async () => {
const { wrapper } = mountActions({}, { disabled: true })
expect(button(wrapper, 'activate').attributes('disabled')).toBeDefined()
await button(wrapper, 'activate').trigger('click')
await flushPromises()
expect(dialog()).toBeNull()
})
})