// 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 = {}): Record { 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('[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 { 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 { await trigger(wrapper).trigger('keydown', { key: 'Enter' }) await flushPromises() const item = document.body.querySelector(`[data-bulk-menu] [data-bulk-action="${name}"]`) expect(item).not.toBeNull() item!.click() await flushPromises() } async function press(action: 'confirm' | 'cancel'): Promise { dialog()!.querySelector(`[data-action="${action}"]`)!.click() await flushPromises() } /** Lets the dialog close and the outcome (toast, reload) settle. */ async function settle(): Promise { 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('[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('[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((resolve) => { release = resolve }), }), { attach: true }, ) await selectRows(wrapper, 1) await choose(wrapper, 'activate') await press('confirm') const confirm = dialog()!.querySelector('[data-action="confirm"]')! const cancel = dialog()!.querySelector('[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 = 'Activate & 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('[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) }) })