feat(12.1-01): declared bulk actions on admin lists
- pact.HasAdminBulkActions with AdminBulkAction, its input and result
- config_list.yaml bulkActions, compiled fail-loud, needs showCheckboxes
- POST .../{controller}/bulk/{action}: ids resolved and locked through the
list scope in one transaction; partial selection is 409
- list schema offers declared actions per principal, with confirm text
- admin SPA bulk actions menu with confirm, busy state and failure toasts
- acme.roster fixture, tracer test, OpenAPI, TS types, dist, READMEs, docs
This commit is contained in:
317
admin/tests/smoke/actions.smoke.test.ts
Normal file
317
admin/tests/smoke/actions.smoke.test.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
// 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
|
||||
// names appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, 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'
|
||||
|
||||
const LIST = `${API}/acme/roster/people`
|
||||
|
||||
// The framework strings these screens use, as GET /lang serves them in en.
|
||||
const strings = {
|
||||
'backend::lang.list.bulk_actions': { other: 'Bulk actions' },
|
||||
'backend::lang.list.bulk_confirm': { other: 'Run “:action” on the selected (:count)?' },
|
||||
'backend::lang.list.bulk_done': {
|
||||
one: 'Action completed for :count record.',
|
||||
other: 'Action completed for :count records.',
|
||||
},
|
||||
'backend::lang.list.bulk_stale': {
|
||||
other: 'Some of the selected records are no longer available. The list has been refreshed; select the records again.',
|
||||
},
|
||||
'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.' },
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${LIST}/schema/list`]: { body: rosterListSchemaFixture },
|
||||
[`GET ${LIST}`]: { body: rosterListFixture },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function schemaWith(bulkActions: (typeof rosterListSchemaFixture)['data']['bulkActions']) {
|
||||
const schema = clone(rosterListSchemaFixture)
|
||||
schema.data.bulkActions = bulkActions
|
||||
return schema
|
||||
}
|
||||
|
||||
const error = (status: number, code: string, message: string): Reply => ({
|
||||
status,
|
||||
body: { error: { code, message, details: {} } },
|
||||
})
|
||||
|
||||
const trigger = (wrapper: VueWrapper) => wrapper.find('[data-action="bulk-actions"]')
|
||||
const dialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
|
||||
const pill = (wrapper: VueWrapper) => wrapper.find('[data-selected-pill]')
|
||||
const listGets = (calls: Request[]) => requestsTo(calls, 'GET', LIST).length
|
||||
|
||||
async function selectRows(wrapper: VueWrapper, count: number): Promise<void> {
|
||||
const boxes = wrapper.findAll('td[data-select] [role="checkbox"]')
|
||||
for (const box of boxes.slice(0, count)) {
|
||||
await box.trigger('click')
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the menu and chooses one action; the confirmation is then open. */
|
||||
async function choose(wrapper: VueWrapper, name: string): Promise<void> {
|
||||
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
const item = document.body.querySelector<HTMLElement>(`[data-bulk-menu] [data-bulk-action="${name}"]`)
|
||||
expect(item).not.toBeNull()
|
||||
item!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function press(action: 'confirm' | 'cancel'): Promise<void> {
|
||||
dialog()!.querySelector<HTMLButtonElement>(`[data-action="${action}"]`)!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
/** Lets the dialog close and the outcome (toast, reload) settle. */
|
||||
async function settle(): Promise<void> {
|
||||
await wait(10)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('bulk actions menu (UI-SPEC S1, D-09)', () => {
|
||||
it('keeps a labelled, disabled trigger until rows are selected', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people', routes(), { attach: true })
|
||||
expect(trigger(wrapper).text()).toBe('Bulk actions')
|
||||
expect(trigger(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(trigger(wrapper).find('svg').exists()).toBe(true)
|
||||
// The menu sits directly after the selection pill and before delete.
|
||||
await selectRows(wrapper, 1)
|
||||
expect(trigger(wrapper).attributes('disabled')).toBeUndefined()
|
||||
const order = wrapper.findAll('[data-selected-pill], [data-action="bulk-actions"], [data-action="delete"]')
|
||||
expect(order.map((node) => node.attributes('data-action') ?? 'pill')).toEqual(['pill', 'bulk-actions', 'delete'])
|
||||
})
|
||||
|
||||
it('lists the declared actions in order, without delete, icons or separators', async () => {
|
||||
const { wrapper } = await mountApp('/acme/roster/people', routes(), { attach: true })
|
||||
await selectRows(wrapper, 2)
|
||||
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
const menu = document.body.querySelector('[data-bulk-menu]')!
|
||||
expect(menu.getAttribute('role')).toBe('menu')
|
||||
const items = Array.from(menu.querySelectorAll<HTMLElement>('[data-bulk-action]'))
|
||||
expect(items.map((item) => item.dataset.bulkAction)).toEqual(['activate', 'archive'])
|
||||
expect(items.map((item) => item.textContent?.trim())).toEqual(['Activate', 'Archive'])
|
||||
expect(items.every((item) => item.classList.contains('min-h-10') && !item.classList.contains('truncate'))).toBe(true)
|
||||
expect(menu.querySelector('svg, [role="separator"], [role="group"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('confirms with the action text, posts the selected ids and clears the selection', async () => {
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({ [`POST ${LIST}/bulk/activate`]: { body: { data: { message: '', affected: 2 }, meta: {} } } }),
|
||||
{ attach: true },
|
||||
)
|
||||
const gets = listGets(calls)
|
||||
await selectRows(wrapper, 2)
|
||||
await choose(wrapper, 'activate')
|
||||
|
||||
expect(dialog()!.textContent).toContain('Activate the selected people?')
|
||||
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')
|
||||
await settle()
|
||||
|
||||
const [post] = requestsTo(calls, 'POST', `${LIST}/bulk/activate`)
|
||||
expect(new URL(post!.url).pathname.endsWith('/bulk/activate')).toBe(true)
|
||||
expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
expect(await post!.json()).toEqual({ ids: [1, 2] })
|
||||
expect(wrapper.find('[role="status"]').text()).toContain('Action completed for 2 records.')
|
||||
expect(pill(wrapper).exists()).toBe(false)
|
||||
expect(listGets(calls)).toBe(gets + 1)
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the default confirm text and shows the server message and the singular count', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({
|
||||
[`POST ${LIST}/bulk/archive`]: { body: { data: { message: 'The selected people were archived.', affected: 2 }, meta: {} } },
|
||||
[`POST ${LIST}/bulk/activate`]: { body: { data: { message: '', affected: 1 }, meta: {} } },
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await selectRows(wrapper, 2)
|
||||
await choose(wrapper, 'archive')
|
||||
expect(dialog()!.textContent).toContain('Run “Archive” on the selected (2)?')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(wrapper.find('[role="status"]').text()).toContain('The selected people were archived.')
|
||||
|
||||
// The server's affected count may be lower than the selection.
|
||||
await selectRows(wrapper, 2)
|
||||
await choose(wrapper, 'activate')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(wrapper.text()).toContain('Action completed for 1 record.')
|
||||
})
|
||||
|
||||
it('keeps the dialog open and busy and the trigger disabled while the request runs', async () => {
|
||||
let release: ((reply: Reply) => void) | undefined
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({
|
||||
[`POST ${LIST}/bulk/activate`]: () =>
|
||||
new Promise<Reply>((resolve) => {
|
||||
release = resolve
|
||||
}),
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await selectRows(wrapper, 1)
|
||||
await choose(wrapper, 'activate')
|
||||
await press('confirm')
|
||||
|
||||
const confirm = dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!
|
||||
const cancel = dialog()!.querySelector<HTMLButtonElement>('[data-action="cancel"]')!
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect(cancel.disabled).toBe(true)
|
||||
expect(confirm.getAttribute('aria-busy')).toBe('true')
|
||||
expect(confirm.querySelector('svg')).not.toBeNull()
|
||||
expect(trigger(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(trigger(wrapper).text()).toBe('Bulk actions')
|
||||
|
||||
release?.({ body: { data: { message: '', affected: 1 }, meta: {} } })
|
||||
await flushPromises()
|
||||
await settle()
|
||||
expect(dialog()).toBeNull()
|
||||
})
|
||||
|
||||
it('sends nothing on cancel and returns focus to the trigger', async () => {
|
||||
const { wrapper, calls } = await mountApp('/acme/roster/people', routes(), { attach: true })
|
||||
await selectRows(wrapper, 1)
|
||||
await choose(wrapper, 'activate')
|
||||
await press('cancel')
|
||||
await settle()
|
||||
expect(requestsTo(calls, 'POST', `${LIST}/bulk/activate`)).toHaveLength(0)
|
||||
expect(pill(wrapper).exists()).toBe(true)
|
||||
expect(document.activeElement).toBe(trigger(wrapper).element)
|
||||
})
|
||||
|
||||
it('returns focus to the trigger after a confirmed run', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({ [`POST ${LIST}/bulk/activate`]: error(500, 'error', 'The roster is locked.') }),
|
||||
{ attach: true },
|
||||
)
|
||||
await selectRows(wrapper, 1)
|
||||
await choose(wrapper, 'activate')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(document.activeElement).toBe(trigger(wrapper).element)
|
||||
})
|
||||
|
||||
it('toasts a 409, clears the selection and reloads the list', async () => {
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({ [`POST ${LIST}/bulk/activate`]: error(409, 'conflict', 'Conflict') }),
|
||||
{ attach: true },
|
||||
)
|
||||
const gets = listGets(calls)
|
||||
await selectRows(wrapper, 2)
|
||||
await choose(wrapper, 'activate')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(wrapper.find('[data-tone="danger"]').text()).toContain('Some of the selected records are no longer available.')
|
||||
expect(pill(wrapper).exists()).toBe(false)
|
||||
expect(listGets(calls)).toBe(gets + 1)
|
||||
})
|
||||
|
||||
it('toasts a 403 with the server message or the fallback and keeps the selection', async () => {
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({
|
||||
[`POST ${LIST}/bulk/activate`]: error(403, 'forbidden', ''),
|
||||
[`POST ${LIST}/bulk/archive`]: error(403, 'forbidden', 'Only a manager may archive people.'),
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const gets = listGets(calls)
|
||||
await selectRows(wrapper, 2)
|
||||
await choose(wrapper, 'activate')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(wrapper.find('[data-tone="danger"]').text()).toContain('You do not have permission to run this action.')
|
||||
expect(pill(wrapper).exists()).toBe(true)
|
||||
|
||||
await choose(wrapper, 'archive')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(wrapper.text()).toContain('Only a manager may archive people.')
|
||||
expect(pill(wrapper).exists()).toBe(true)
|
||||
expect(listGets(calls)).toBe(gets)
|
||||
})
|
||||
|
||||
it('toasts any other failure, keeps the selection and reloads nothing', async () => {
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({
|
||||
[`POST ${LIST}/bulk/activate`]: error(500, 'error', ''),
|
||||
[`POST ${LIST}/bulk/archive`]: error(422, 'validation_failed', 'One of the people cannot be archived.'),
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
const gets = listGets(calls)
|
||||
await selectRows(wrapper, 2)
|
||||
await choose(wrapper, 'activate')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(wrapper.find('[data-tone="danger"]').text()).toContain('The action could not be completed. Please try again.')
|
||||
|
||||
await choose(wrapper, 'archive')
|
||||
await press('confirm')
|
||||
await settle()
|
||||
expect(wrapper.text()).toContain('One of the people cannot be archived.')
|
||||
expect(pill(wrapper).exists()).toBe(true)
|
||||
expect(listGets(calls)).toBe(gets)
|
||||
})
|
||||
|
||||
it('renders no menu when the admin may run no declared action', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({ [`GET ${LIST}/schema/list`]: { body: schemaWith([{ name: 'delete', label: 'Delete selected' }]) } }),
|
||||
{ attach: true },
|
||||
)
|
||||
expect(trigger(wrapper).exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders a label with markup characters as text', async () => {
|
||||
const label = '<b onclick="x()">Activate</b> & a label long enough to wrap inside the menu'
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/roster/people',
|
||||
routes({ [`GET ${LIST}/schema/list`]: { body: schemaWith([{ name: 'activate', label }]) } }),
|
||||
{ attach: true },
|
||||
)
|
||||
await selectRows(wrapper, 1)
|
||||
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
const menuItem = document.body.querySelector<HTMLElement>('[data-bulk-action="activate"]')!
|
||||
expect(menuItem.querySelector('b')).toBeNull()
|
||||
expect(menuItem.textContent?.trim()).toBe(label)
|
||||
menuItem.click()
|
||||
await flushPromises()
|
||||
expect(dialog()!.querySelector('b')).toBeNull()
|
||||
expect(dialog()!.textContent).toContain(`Run “${label}” on the selected (1)?`)
|
||||
expect(dialog()!.querySelector('[data-action="confirm"]')!.textContent?.trim()).toBe(label)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user