- hooks and bulk, record, toolbar and widget actions may return it - 403 forbidden with the localized message and field details; the write's transaction is rolled back; other errors stay the opaque 500 - form shows a refused save as a persistent banner and keeps the values; a refused delete is a toast - smoke tests, OpenAPI notes, dist, README, docs
686 lines
30 KiB
TypeScript
686 lines
30 KiB
TypeScript
// Phase 12.1 framework actions, SPA half: the bulk actions menu of a list
|
|
// (UI-SPEC S1, D-09), the record action buttons (UI-SPEC S2, D-10) and the
|
|
// row state badges (UI-SPEC S4, D-12) and the forbidden save banner (UI-SPEC
|
|
// S6, D-27).
|
|
// Fixtures are neutral acme.roster.* data; no application
|
|
// names appear in framework tests.
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
|
import { setBundle } from '../../src/app/i18n'
|
|
import RecordActions from '../../src/components/form/RecordActions.vue'
|
|
import RowStateBadges from '../../src/components/list/RowStateBadges.vue'
|
|
import ToastHost from '../../src/components/ui/Toast.vue'
|
|
import {
|
|
clone,
|
|
formSchemaFixture,
|
|
langFixture,
|
|
recordFixture,
|
|
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`
|
|
|
|
// 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.' },
|
|
'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.',
|
|
},
|
|
'backend::lang.form.forbidden': { other: 'You do not have permission to make this change. Nothing was saved.' },
|
|
}
|
|
|
|
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)
|
|
})
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|
|
|
|
describe('row state (UI-SPEC S4, D-12)', () => {
|
|
const rows = (wrapper: VueWrapper) => wrapper.findAll('tbody tr')
|
|
const firstCell = (wrapper: VueWrapper, index: number) => rows(wrapper)[index]!.findAll('td:not([data-select])')[0]!
|
|
const badges = (wrapper: VueWrapper, index: number) =>
|
|
firstCell(wrapper, index)
|
|
.findAll('[data-row-state]')
|
|
.map((badge) => [badge.attributes('data-row-state'), badge.text()])
|
|
|
|
it('leaves a row without states unchanged', async () => {
|
|
const { wrapper } = await mountApp('/acme/roster/people', routes())
|
|
const row = rows(wrapper)[0]!
|
|
expect(row.attributes('data-row-states')).toBeUndefined()
|
|
expect(row.find('[data-row-state]').exists()).toBe(false)
|
|
const link = firstCell(wrapper, 0).find('a')
|
|
expect(link.classes()).toEqual(expect.arrayContaining(['text-text', 'no-underline']))
|
|
expect(link.attributes('href')).toBe('/acme/roster/people/1')
|
|
expect(firstCell(wrapper, 0).classes()).not.toContain('text-muted')
|
|
})
|
|
|
|
it('renders two states as text badges in the fixed order and combines their text styles', async () => {
|
|
const { wrapper } = await mountApp('/acme/roster/people', routes())
|
|
const row = rows(wrapper)[1]!
|
|
expect(row.attributes('data-row-states')).toBe('deleted negative')
|
|
expect(badges(wrapper, 1)).toEqual([
|
|
['deleted', 'Deleted'],
|
|
['negative', 'Blocked'],
|
|
])
|
|
// deleted + negative: the first-cell text is struck through and danger;
|
|
// every cell is muted; the badges sit outside the struck text.
|
|
const cell = firstCell(wrapper, 1)
|
|
const link = cell.find('a')
|
|
expect(link.classes()).toEqual(expect.arrayContaining(['line-through', 'text-danger', 'min-w-0', 'truncate']))
|
|
expect(link.find('[data-row-state]').exists()).toBe(false)
|
|
expect(link.attributes('href')).toBe('/acme/roster/people/2')
|
|
expect(cell.classes()).toEqual(expect.arrayContaining(['font-semibold', 'text-muted']))
|
|
expect(row.findAll('td:not([data-select])')[1]!.classes()).toContain('text-muted')
|
|
const deleted = cell.find('[data-row-state="deleted"]')
|
|
expect(deleted.classes()).toEqual(expect.arrayContaining(['border-border-strong', 'text-muted', 'whitespace-nowrap', 'shrink-0']))
|
|
expect(deleted.classes()).not.toContain('line-through')
|
|
expect(cell.find('[data-row-state="negative"]').classes()).toEqual(expect.arrayContaining(['bg-danger-soft', 'text-danger']))
|
|
// The row background is not a state: selection still tints it.
|
|
expect(row.classes()).not.toContain('bg-danger-soft')
|
|
})
|
|
|
|
it('ignores a state outside the fixed set: no badge, no class, no attribute value', async () => {
|
|
const { wrapper } = await mountApp('/acme/roster/people', routes())
|
|
const row = rows(wrapper)[2]!
|
|
expect(row.attributes('data-row-states')).toBe('disabled')
|
|
expect(badges(wrapper, 2)).toEqual([['disabled', 'Not active']])
|
|
expect(row.html()).not.toContain('starred')
|
|
const cell = firstCell(wrapper, 2)
|
|
expect(cell.classes()).toEqual(expect.arrayContaining(['font-semibold', 'text-muted']))
|
|
expect(cell.find('a').classes()).not.toContain('line-through')
|
|
expect(cell.find('[data-row-state="disabled"]').classes()).toEqual(expect.arrayContaining(['bg-subtle', 'text-muted']))
|
|
|
|
const only = clone(rosterListFixture)
|
|
only.meta.row_states = { '1': ['starred', '<b>x</b>'] }
|
|
const unknown = await mountApp('/acme/roster/people', routes({ [`GET ${LIST}`]: { body: only } }))
|
|
const first = rows(unknown.wrapper)[0]!
|
|
expect(first.attributes('data-row-states')).toBeUndefined()
|
|
expect(first.find('[data-row-state]').exists()).toBe(false)
|
|
expect(first.html()).not.toContain('starred')
|
|
expect(firstCell(unknown.wrapper, 0).find('a').classes()).toContain('text-text')
|
|
})
|
|
|
|
it('keeps selection and the row link working for a row with states', async () => {
|
|
const { wrapper } = await mountApp('/acme/roster/people', routes())
|
|
await rows(wrapper)[1]!.find('td[data-select] [role="checkbox"]').trigger('click')
|
|
expect(rows(wrapper)[1]!.classes()).toContain('bg-sel')
|
|
expect(rows(wrapper)[1]!.attributes('data-row-states')).toBe('deleted negative')
|
|
})
|
|
|
|
it('renders each known state once, in the fixed order, whatever the input order', () => {
|
|
const labels = { deleted: 'Deleted', negative: 'Blocked', disabled: 'Inactive' }
|
|
const wrapper = mount(RowStateBadges, { props: { states: ['disabled', 'bogus', 'deleted', 'negative'], labels } })
|
|
expect(wrapper.findAll('[data-row-state]').map((badge) => badge.text())).toEqual(['Deleted', 'Blocked', 'Inactive'])
|
|
const none = mount(RowStateBadges, { props: { states: ['bogus'], labels } })
|
|
expect(none.find('[data-row-state]').exists()).toBe(false)
|
|
expect(none.text()).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('forbidden save (UI-SPEC S6, D-27)', () => {
|
|
const WIDGETS = `${API}/acme/demo/widgets`
|
|
const RECORD = `${WIDGETS}/1`
|
|
|
|
function formRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
|
return {
|
|
[`GET ${WIDGETS}/schema/form`]: { body: formSchemaFixture },
|
|
[`GET ${RECORD}`]: { body: recordFixture },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const refused = (message: string, details: Record<string, string[]> = {}): Reply => ({
|
|
status: 403,
|
|
body: { error: { code: 'forbidden', message, details } },
|
|
})
|
|
|
|
const banner = (wrapper: VueWrapper) => wrapper.find('[data-forbidden-banner]')
|
|
const nameInput = (wrapper: VueWrapper) => wrapper.find<HTMLInputElement>('#field-name')
|
|
|
|
it('shows a persistent alert banner with the server message, keeps the values and marks the named field', async () => {
|
|
const long = 'You may not rename this widget because it belongs to a maker you cannot manage. '.repeat(3).trim()
|
|
const { wrapper, calls } = await mountApp(
|
|
'/acme/demo/widgets/1',
|
|
formRoutes({ [`PUT ${RECORD}`]: refused(long, { name: ['This name is reserved.'] }) }),
|
|
{ attach: true },
|
|
)
|
|
await nameInput(wrapper).setValue('Reserved')
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
|
|
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
|
expect(banner(wrapper).attributes('role')).toBe('alert')
|
|
expect(banner(wrapper).text()).toBe(long)
|
|
expect(banner(wrapper).find('svg').exists()).toBe(true)
|
|
// A long message wraps; it is never truncated.
|
|
expect(banner(wrapper).html()).not.toContain('truncate')
|
|
// It is a banner, not a toast, and the 422 banner does not show.
|
|
expect(wrapper.find('[data-tone]').exists()).toBe(false)
|
|
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
|
|
// Nothing was saved: the typed value and the dirty state stay.
|
|
expect(nameInput(wrapper).element.value).toBe('Reserved')
|
|
expect(wrapper.text()).toContain('This name is reserved.')
|
|
expect(nameInput(wrapper).attributes('aria-invalid')).toBe('true')
|
|
expect(document.activeElement).toBe(nameInput(wrapper).element)
|
|
|
|
// Leaving still asks: the form is dirty.
|
|
await wrapper.find('[data-action="cancel"]').trigger('click')
|
|
await flushPromises()
|
|
expect(dialog()).not.toBeNull()
|
|
await press('cancel')
|
|
expect(banner(wrapper).exists()).toBe(true)
|
|
})
|
|
|
|
it('falls back to the framework text when the server sends no message, without field marks', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes({ [`PUT ${RECORD}`]: refused('') }), {
|
|
attach: true,
|
|
})
|
|
await nameInput(wrapper).setValue('Other')
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
expect(banner(wrapper).text()).toBe('You do not have permission to make this change. Nothing was saved.')
|
|
expect(nameInput(wrapper).attributes('aria-invalid')).not.toBe('true')
|
|
expect(nameInput(wrapper).element.value).toBe('Other')
|
|
})
|
|
|
|
it('refuses a create the same way', async () => {
|
|
const { wrapper, router } = await mountApp(
|
|
'/acme/demo/widgets/create',
|
|
formRoutes({ [`POST ${WIDGETS}`]: refused('You may not add widgets here.') }),
|
|
{ attach: true },
|
|
)
|
|
await nameInput(wrapper).setValue('New widget')
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
expect(banner(wrapper).text()).toBe('You may not add widgets here.')
|
|
expect(nameInput(wrapper).element.value).toBe('New widget')
|
|
expect(router.currentRoute.value.name).toBe('create')
|
|
})
|
|
|
|
it('clears the banner when the next save attempt starts', async () => {
|
|
let attempt = 0
|
|
let release: ((reply: Reply) => void) | undefined
|
|
const { wrapper } = await mountApp(
|
|
'/acme/demo/widgets/1',
|
|
formRoutes({
|
|
[`PUT ${RECORD}`]: () => {
|
|
attempt += 1
|
|
if (attempt === 1) {
|
|
return refused('You may not rename this widget.')
|
|
}
|
|
return new Promise<Reply>((resolve) => {
|
|
release = resolve
|
|
})
|
|
},
|
|
}),
|
|
{ attach: true },
|
|
)
|
|
await nameInput(wrapper).setValue('Reserved')
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
expect(banner(wrapper).exists()).toBe(true)
|
|
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
// The second request is still running; the banner is already gone.
|
|
expect(release).toBeDefined()
|
|
expect(banner(wrapper).exists()).toBe(false)
|
|
release?.({ body: recordFixture })
|
|
await flushPromises()
|
|
expect(banner(wrapper).exists()).toBe(false)
|
|
})
|
|
|
|
it('toasts a refused delete with the server message or the fallback', async () => {
|
|
const { wrapper, router } = await mountApp(
|
|
'/acme/demo/widgets/1',
|
|
formRoutes({ [`DELETE ${RECORD}`]: refused('') }),
|
|
{ attach: true },
|
|
)
|
|
await wrapper.find('[data-action="delete"]').trigger('click')
|
|
await flushPromises()
|
|
await press('confirm')
|
|
expect(wrapper.find('[data-tone="danger"]').text()).toContain('You do not have permission to run this action.')
|
|
expect(banner(wrapper).exists()).toBe(false)
|
|
expect(router.currentRoute.value.name).toBe('record')
|
|
})
|
|
})
|