test(12.1-05): unit tests for the list, form, preview and routing behaviours of Phase 12.1 in the SPA

- list: the bulk menu in the toolbar and on the list view with its three failure rows, row states and invisible columns in the table
- form: the forbidden banner, password and preset on the form view, locked relation options, the save body of password and permission fields
- preview view: context fields, hidden tabs, the status hint, the footer with zero, one and several actions, load failures
- routing: the preview route and mapWinterUrl
- backstops: focus returns to the bulk menu trigger; preview URL mapping and the route replacement; locked relation options
- the slug preset runs on the table the user plugin's slug test uses
This commit is contained in:
Jakub Zych
2026-10-05 15:36:00 +02:00
parent 84efdc09e0
commit aced6c7df3
10 changed files with 1251 additions and 8 deletions

View File

@@ -1,9 +1,19 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import ListView from '../../src/views/ListView.vue'
import { toasts } from '../../src/state/useToasts'
import { OWNER_ATTRIBUTE } from '../../src/app/pluginAssets'
import { clone, extensionListSchemaFixture, extensionPartialFixture, listFixture, listSchemaFixture } from '../fixtures/typed'
import {
clone,
extensionListSchemaFixture,
extensionPartialFixture,
langFixture,
listFixture,
listSchemaFixture,
rosterListFixture,
rosterListSchemaFixture,
} from '../fixtures/typed'
import { API, mountApp, queryOf, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
@@ -298,3 +308,187 @@ describe('list view extension points (Phase 10.1, D-03, D-11, D-12, D-14)', () =
expect(seen.links[0]!.disabled).toBe(false)
})
})
describe('bulk actions on the list (UI-SPEC S1, D-09)', () => {
const PEOPLE = `${API}/acme/roster/people`
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.' },
'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.' },
}
type Mounted = Awaited<ReturnType<typeof mountApp>>['wrapper']
function people(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${PEOPLE}/schema/list`]: { body: rosterListSchemaFixture },
[`GET ${PEOPLE}`]: { body: rosterListFixture },
...overrides,
}
}
const ran = (affected: number, message = ''): Reply => ({ body: { data: { message, affected }, meta: {} } })
const refused = (status: number, message: string): Reply => ({ status, body: { error: { code: 'x', message, details: {} } } })
const trigger = (wrapper: Mounted) => wrapper.find('[data-action="bulk-actions"]')
const dialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
const listGets = (calls: Request[]) => requestsTo(calls, 'GET', PEOPLE).length
async function select(wrapper: Mounted, count: number): Promise<void> {
for (const box of wrapper.findAll('td[data-select] [role="checkbox"]').slice(0, count)) {
await box.trigger('click')
}
}
async function choose(wrapper: Mounted, name: string): Promise<void> {
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
await flushPromises()
document.body.querySelector<HTMLElement>(`[data-bulk-menu] [data-bulk-action="${name}"]`)!.click()
await flushPromises()
}
async function answer(action: 'confirm' | 'cancel'): Promise<void> {
dialog()!.querySelector<HTMLButtonElement>(`[data-action="${action}"]`)!.click()
await flushPromises()
await wait(10)
await flushPromises()
}
beforeEach(() => {
setBundle({ ...langFixture.data, ...strings }, 'en')
})
it('offers the declared actions without the built-in delete, and no menu when none is permitted', async () => {
const { wrapper } = await mountApp('/acme/roster/people', people(), { attach: true })
await select(wrapper, 1)
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
await flushPromises()
const names = Array.from(document.body.querySelectorAll<HTMLElement>('[data-bulk-menu] [data-bulk-action]')).map((item) => item.dataset.bulkAction)
expect(names).toEqual(['activate', 'archive'])
wrapper.unmount()
document.body.innerHTML = ''
const only = clone(rosterListSchemaFixture)
only.data.bulkActions = [{ name: 'delete', label: 'Delete selected' }]
const none = await mountApp('/acme/roster/people', people({ [`GET ${PEOPLE}/schema/list`]: { body: only } }), { attach: true })
await select(none.wrapper, 1)
expect(trigger(none.wrapper).exists()).toBe(false)
expect(none.wrapper.find('[data-action="delete"]').exists()).toBe(true)
})
it('asks with the action text, or the default naming the action and the count', async () => {
const { wrapper } = await mountApp('/acme/roster/people', people(), { attach: true })
await select(wrapper, 2)
await choose(wrapper, 'activate')
expect(dialog()!.textContent).toContain('Activate the selected people?')
await answer('cancel')
await choose(wrapper, 'archive')
expect(dialog()!.textContent).toContain('Run “Archive” on the selected (2)?')
expect(dialog()!.querySelector('[data-action="confirm"]')!.textContent?.trim()).toBe('Archive')
})
it('posts the selected ids once, toasts the outcome, clears the selection and reloads', async () => {
const { wrapper, calls } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: ran(2) }), { attach: true })
const before = listGets(calls)
await select(wrapper, 2)
await choose(wrapper, 'archive')
await answer('confirm')
const posts = requestsTo(calls, 'POST', `${PEOPLE}/bulk/archive`)
expect(posts).toHaveLength(1)
expect(await posts[0]!.json()).toEqual({ ids: [1, 2] })
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['Action completed for 2 records.', 'success']])
expect(wrapper.find('[data-selected-pill]').exists()).toBe(false)
expect(listGets(calls)).toBe(before + 1)
})
it('shows the server message when the action sends one', async () => {
const { wrapper } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: ran(1, 'One person was archived.') }), { attach: true })
await select(wrapper, 1)
await choose(wrapper, 'archive')
await answer('confirm')
expect(toasts.value.map((toast) => toast.text)).toEqual(['One person was archived.'])
})
it('keeps the dialog busy and the trigger disabled until the request settles', async () => {
let release: (reply: Reply) => void = () => undefined
const pending = new Promise<Reply>((resolve) => {
release = resolve
})
const { wrapper } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: () => pending }), { attach: true })
await select(wrapper, 1)
await choose(wrapper, 'archive')
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
await flushPromises()
expect(dialog()).not.toBeNull()
expect(dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.disabled).toBe(true)
expect(trigger(wrapper).attributes('disabled')).toBeDefined()
expect(toasts.value).toHaveLength(0)
release(ran(1))
await flushPromises()
await wait(10)
await flushPromises()
expect(dialog()).toBeNull()
expect(toasts.value).toHaveLength(1)
})
it.each([
[409, 'Conflict', 'Some of the selected records are no longer available.', false, 1],
[403, 'Only team leads may archive people.', 'Only team leads may archive people.', true, 0],
[403, '', 'You do not have permission to run this action.', true, 0],
[500, '', 'The action could not be completed.', true, 0],
])('on %i (%j): toast %j, selection kept %s, reloads %i', async (status, message, text, kept, reloads) => {
const { wrapper, calls } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: refused(status, message) }), { attach: true })
const before = listGets(calls)
await select(wrapper, 2)
await choose(wrapper, 'archive')
await answer('confirm')
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([[text, 'danger']])
expect(wrapper.find('[data-selected-pill]').exists()).toBe(kept)
expect(listGets(calls)).toBe(before + reloads)
})
it('backstop: focus returns to the bulk menu trigger after the confirm dialog closes, by confirm and by cancel', async () => {
const { wrapper, calls } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: refused(403, 'Refused.') }), { attach: true })
await select(wrapper, 1)
// By cancel: nothing is sent and the trigger has focus again.
await choose(wrapper, 'archive')
expect(dialog()).not.toBeNull()
expect(document.activeElement).not.toBe(trigger(wrapper).element)
await answer('cancel')
expect(dialog()).toBeNull()
expect(requestsTo(calls, 'POST', `${PEOPLE}/bulk/archive`)).toHaveLength(0)
expect(document.activeElement).toBe(trigger(wrapper).element)
// By Escape, which is a cancel.
await choose(wrapper, 'archive')
dialog()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))
await flushPromises()
await wait(10)
await flushPromises()
if (dialog()) {
await answer('cancel')
}
expect(document.activeElement).toBe(trigger(wrapper).element)
// By confirm: the request ran and the trigger has focus again.
;(document.activeElement as HTMLElement).blur()
await choose(wrapper, 'archive')
await answer('confirm')
expect(dialog()).toBeNull()
expect(requestsTo(calls, 'POST', `${PEOPLE}/bulk/archive`)).toHaveLength(1)
expect(document.activeElement).toBe(trigger(wrapper).element)
// The trigger is enabled, so the focus is on a control that can take it.
expect(trigger(wrapper).attributes('disabled')).toBeUndefined()
})
it('returns focus to the trigger before a successful run clears the selection', async () => {
const { wrapper } = await mountApp('/acme/roster/people', people({ [`POST ${PEOPLE}/bulk/archive`]: ran(1) }), { attach: true })
await select(wrapper, 1)
const focused: boolean[] = []
trigger(wrapper).element.addEventListener('focus', () => focused.push(trigger(wrapper).attributes('disabled') === undefined))
await choose(wrapper, 'archive')
await answer('confirm')
// Focus reached the trigger while it was still enabled.
expect(focused).toContain(true)
expect(wrapper.find('[data-selected-pill]').exists()).toBe(false)
})
})