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:
@@ -141,3 +141,105 @@ describe('data table', () => {
|
||||
expect(wrapper.findAll('[data-skeleton]')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('row states in the table (UI-SPEC S4, D-12)', () => {
|
||||
const stateLabels = { deleted: 'Deleted', negative: 'Blocked', disabled: 'Not active' }
|
||||
const rows = (wrapper: Awaited<ReturnType<typeof mountTable>>['wrapper']) => wrapper.findAll('tbody tr')
|
||||
const firstCell = (wrapper: Awaited<ReturnType<typeof mountTable>>['wrapper'], index: number) => rows(wrapper)[index]!.findAll('td:not([data-select])')[0]!
|
||||
|
||||
it('leaves every row unchanged without states', async () => {
|
||||
const { wrapper } = await mountTable({ rowLink, stateLabels })
|
||||
for (const row of rows(wrapper)) {
|
||||
expect(row.attributes('data-row-states')).toBeUndefined()
|
||||
expect(row.find('[data-row-state]').exists()).toBe(false)
|
||||
}
|
||||
expect(firstCell(wrapper, 0).find('a').classes()).toEqual(expect.arrayContaining(['text-text', 'no-underline']))
|
||||
const empty = await mountTable({ rowLink, stateLabels, rowStates: { '1': [] } })
|
||||
expect(rows(empty.wrapper)[0]!.attributes('data-row-states')).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['deleted', ['line-through', 'text-muted'], ['text-danger']],
|
||||
['negative', ['no-underline', 'text-danger'], ['line-through']],
|
||||
['disabled', ['no-underline', 'text-muted'], ['line-through', 'text-danger']],
|
||||
])('styles the first-cell text of a %s row and says the state in a badge', async (state, present, absent) => {
|
||||
const { wrapper } = await mountTable({ rowLink, stateLabels, rowStates: { '1': [state] } })
|
||||
const row = rows(wrapper)[0]!
|
||||
expect(row.attributes('data-row-states')).toBe(state)
|
||||
const link = firstCell(wrapper, 0).find('a')
|
||||
expect(link.classes()).toEqual(expect.arrayContaining(present))
|
||||
for (const name of absent) {
|
||||
expect(link.classes()).not.toContain(name)
|
||||
}
|
||||
const badge = firstCell(wrapper, 0).find(`[data-row-state="${state}"]`)
|
||||
expect(badge.text()).toBe(stateLabels[state as keyof typeof stateLabels])
|
||||
// The badge sits beside the link, never inside the struck text.
|
||||
expect(link.find('[data-row-state]').exists()).toBe(false)
|
||||
// The other row is untouched.
|
||||
expect(rows(wrapper)[1]!.attributes('data-row-states')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('combines states: the attribute lists them and danger wins over muted', async () => {
|
||||
const { wrapper } = await mountTable({ rowLink, stateLabels, rowStates: { '1': ['deleted', 'negative', 'disabled'] } })
|
||||
expect(rows(wrapper)[0]!.attributes('data-row-states')).toBe('deleted negative disabled')
|
||||
const link = firstCell(wrapper, 0).find('a')
|
||||
expect(link.classes()).toEqual(expect.arrayContaining(['line-through', 'text-danger']))
|
||||
expect(firstCell(wrapper, 0).findAll('[data-row-state]').map((badge) => badge.attributes('data-row-state'))).toEqual(['deleted', 'negative', 'disabled'])
|
||||
// Every other cell of a row with a state is muted.
|
||||
expect(rows(wrapper)[0]!.findAll('td:not([data-select])')[1]!.classes()).toContain('text-muted')
|
||||
})
|
||||
|
||||
it('styles plain text the same way when the row has no link', async () => {
|
||||
const { wrapper } = await mountTable({ stateLabels, rowStates: { '1': ['deleted'] } })
|
||||
const cell = firstCell(wrapper, 0)
|
||||
expect(cell.find('a').exists()).toBe(false)
|
||||
expect(cell.find('span.line-through').classes()).toEqual(expect.arrayContaining(['line-through', 'text-muted', 'truncate']))
|
||||
expect(cell.find('[data-row-state="deleted"]').text()).toBe('Deleted')
|
||||
})
|
||||
|
||||
it('keys states by the row id as text and ignores ids that are not on the page', async () => {
|
||||
const { wrapper } = await mountTable({ rowLink, stateLabels, rowStates: { '2': ['disabled'], '99': ['deleted'], abc: ['negative'] } })
|
||||
expect(rows(wrapper)[0]!.attributes('data-row-states')).toBeUndefined()
|
||||
expect(rows(wrapper)[1]!.attributes('data-row-states')).toBe('disabled')
|
||||
expect(wrapper.findAll('[data-row-state]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('backstop: a row state outside the fixed set renders no badge and no class', async () => {
|
||||
const hostile = ['starred', 'bg-danger-soft', 'line-through', '<b>x</b>', 'DELETED', 'deleted negative']
|
||||
const { wrapper } = await mountTable({ rowLink, stateLabels: { ...stateLabels, starred: 'Starred' }, rowStates: { '1': hostile } })
|
||||
const row = rows(wrapper)[0]!
|
||||
expect(row.attributes('data-row-states')).toBeUndefined()
|
||||
expect(row.find('[data-row-state]').exists()).toBe(false)
|
||||
expect(row.html()).not.toMatch(/starred|Starred|bg-danger-soft|<b>x<\/b>|DELETED/)
|
||||
const link = firstCell(wrapper, 0).find('a')
|
||||
expect(link.classes()).toEqual(expect.arrayContaining(['text-text', 'no-underline']))
|
||||
expect(link.classes()).not.toContain('line-through')
|
||||
expect(firstCell(wrapper, 0).classes()).not.toContain('text-muted')
|
||||
|
||||
// Beside a known state, only the known one counts.
|
||||
const mixed = await mountTable({ rowLink, stateLabels, rowStates: { '1': ['starred', 'disabled', '<b>x</b>'] } })
|
||||
expect(rows(mixed.wrapper)[0]!.attributes('data-row-states')).toBe('disabled')
|
||||
expect(rows(mixed.wrapper)[0]!.findAll('[data-row-state]').map((badge) => badge.text())).toEqual(['Not active'])
|
||||
expect(rows(mixed.wrapper)[0]!.html()).not.toContain('starred')
|
||||
})
|
||||
})
|
||||
|
||||
describe('invisible columns (D-27 G6)', () => {
|
||||
it('renders no header and no cell for an invisible column, and keeps the first visible column first', async () => {
|
||||
const hidden = [{ ...listColumn('name'), invisible: true }, listColumn('code'), { ...listColumn('tags'), invisible: true }]
|
||||
const { wrapper } = await mountTable({ rowLink, columns: hidden })
|
||||
expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Code'])
|
||||
const cells = wrapper.findAll('tbody tr').map((tr) => tr.findAll('td').map((td) => td.text()))
|
||||
expect(cells).toEqual([['W-01'], ['W-02']])
|
||||
// The value of an invisible column is nowhere in the markup.
|
||||
expect(wrapper.html()).not.toContain('Blue widget')
|
||||
// The first visible column carries the row link.
|
||||
expect(wrapper.find('tbody tr td a').text()).toBe('W-01')
|
||||
})
|
||||
|
||||
it('treats invisible false and a missing flag as visible', async () => {
|
||||
const shown = [{ ...listColumn('name'), invisible: false }, listColumn('code')]
|
||||
const { wrapper } = await mountTable({ columns: shown })
|
||||
expect(wrapper.findAll('th').map((th) => th.text())).toEqual(['Name', 'Code'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import ListToolbar from '../../src/components/list/ListToolbar.vue'
|
||||
import { langFixture } from '../fixtures/typed'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
const base = {
|
||||
@@ -108,3 +110,71 @@ describe('registered toolbar actions (D-12, UI-SPEC S4)', () => {
|
||||
expect(bare.find('[data-action]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bulk actions in the toolbar (UI-SPEC S1, D-09)', () => {
|
||||
const bulkActions = [
|
||||
{ name: 'activate', label: 'Activate' },
|
||||
{ name: 'archive', label: 'Archive' },
|
||||
]
|
||||
const trigger = (wrapper: ReturnType<typeof mount>) => wrapper.find('[data-action="bulk-actions"]')
|
||||
|
||||
beforeEach(() => {
|
||||
setBundle({ ...langFixture.data, 'backend::lang.list.bulk_actions': { other: 'Bulk actions' } }, 'en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('renders no menu with zero permitted actions', () => {
|
||||
const none = mount(ListToolbar, { props: { ...base, selectedCount: 2, selectedLabel: 'Selected 2' } })
|
||||
expect(trigger(none).exists()).toBe(false)
|
||||
const empty = mount(ListToolbar, { props: { ...base, selectedCount: 2, selectedLabel: 'Selected 2', bulkActions: [] } })
|
||||
expect(trigger(empty).exists()).toBe(false)
|
||||
// The built-in delete is not a menu item: it stays the toolbar button.
|
||||
expect(empty.find('[data-action="delete"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the trigger labelled and disabled until rows are selected, and while a bulk action runs', async () => {
|
||||
const wrapper = mount(ListToolbar, { props: { ...base, bulkActions } })
|
||||
expect(trigger(wrapper).text()).toBe('Bulk actions')
|
||||
expect(trigger(wrapper).attributes('disabled')).toBeDefined()
|
||||
await wrapper.setProps({ selectedCount: 1, selectedLabel: 'Selected 1' })
|
||||
expect(trigger(wrapper).attributes('disabled')).toBeUndefined()
|
||||
await wrapper.setProps({ bulkBusy: true })
|
||||
expect(trigger(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(trigger(wrapper).text()).toBe('Bulk actions')
|
||||
})
|
||||
|
||||
it('places the menu after the selection pill and before delete and the registered actions', () => {
|
||||
const wrapper = mount(ListToolbar, {
|
||||
props: { ...base, buttons: ['delete', 'recount'], actions: [{ name: 'recount', label: 'Recount' }], bulkActions, selectedCount: 1, selectedLabel: 'Selected 1' },
|
||||
})
|
||||
const order = wrapper.findAll('[data-selected-pill], [data-action]').map((node) => node.attributes('data-action') ?? 'pill')
|
||||
expect(order).toEqual(['pill', 'bulk-actions', 'delete', 'recount'])
|
||||
})
|
||||
|
||||
it('emits bulk with the chosen name and nothing else', async () => {
|
||||
const wrapper = mount(ListToolbar, { props: { ...base, bulkActions, selectedCount: 2, selectedLabel: 'Selected 2' }, attachTo: document.body })
|
||||
await trigger(wrapper).trigger('keydown', { key: 'Enter' })
|
||||
await flushPromises()
|
||||
const items = Array.from(document.body.querySelectorAll<HTMLElement>('[data-bulk-menu] [data-bulk-action]'))
|
||||
expect(items.map((item) => item.dataset.bulkAction)).toEqual(['activate', 'archive'])
|
||||
items[1]!.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('bulk')).toEqual([['archive']])
|
||||
expect(wrapper.emitted('action')).toBeUndefined()
|
||||
expect(wrapper.emitted('delete')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('exposes focusBulk(), which focuses the menu trigger and is harmless without a menu', () => {
|
||||
const wrapper = mount(ListToolbar, { props: { ...base, bulkActions, selectedCount: 1, selectedLabel: 'Selected 1' }, attachTo: document.body })
|
||||
;(wrapper.vm as unknown as { focusBulk: () => void }).focusBulk()
|
||||
expect(document.activeElement).toBe(trigger(wrapper).element)
|
||||
wrapper.unmount()
|
||||
const bare = mount(ListToolbar, { props: base, attachTo: document.body })
|
||||
expect(() => (bare.vm as unknown as { focusBulk: () => void }).focusBulk()).not.toThrow()
|
||||
bare.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user