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:
@@ -4,7 +4,7 @@ import { activateStyles, loadStyles } from '../../src/app/pluginAssets'
|
||||
import { createAdminRouter, routeControllerId, safeRedirect } from '../../src/app/router'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { navigationFixture } from '../fixtures/typed'
|
||||
import { resetState, routerAt, signIn } from '../helpers'
|
||||
import { mockApi, resetState, routerAt, signIn } from '../helpers'
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
@@ -149,3 +149,37 @@ describe('plugin stylesheets follow the route (D-16, T-10.1-16)', () => {
|
||||
expect(enabled()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview route (UI-SPEC S3, D-11)', () => {
|
||||
it('names the preview route for a numeric id only and keeps it inside the shell', async () => {
|
||||
await signIn()
|
||||
const preview = await routerAt('/acme/demo/widgets/12/preview')
|
||||
expect(preview.currentRoute.value.name).toBe('preview')
|
||||
expect(preview.currentRoute.value.params).toMatchObject({ vendor: 'acme', plugin: 'demo', controller: 'widgets', id: '12' })
|
||||
expect(preview.currentRoute.value.meta.shell).toBe(true)
|
||||
for (const path of ['/acme/demo/widgets/abc/preview', '/acme/demo/widgets/1e3/preview', '/acme/demo/widgets/12/preview/extra', '/acme/demo/widgets/-1/preview']) {
|
||||
expect((await routerAt(path)).currentRoute.value.name, path).toBe('not-found')
|
||||
}
|
||||
})
|
||||
|
||||
it('counts preview among the controller routes, so plugin assets follow it', async () => {
|
||||
await signIn()
|
||||
// CONTROLLER_ROUTES holds list, create, record and preview: each of them
|
||||
// answers its controller id, and no other route does.
|
||||
for (const path of ['/acme/demo/widgets', '/acme/demo/widgets/create', '/acme/demo/widgets/12', '/acme/demo/widgets/12/preview']) {
|
||||
expect(routeControllerId((await routerAt(path)).currentRoute.value), path).toBe('acme.demo.widgets')
|
||||
}
|
||||
for (const path of ['/settings', '/settings/mail', '/nowhere', '/acme/demo/widgets/abc/preview']) {
|
||||
expect(routeControllerId((await routerAt(path)).currentRoute.value), path).toBeFalsy()
|
||||
}
|
||||
})
|
||||
|
||||
it('requires a signed-in admin for the preview route and returns to it after login', async () => {
|
||||
mockApi({})
|
||||
const router = createAdminRouter(createMemoryHistory())
|
||||
await router.push('/acme/demo/widgets/12/preview')
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
expect(router.currentRoute.value.query.redirect).toBe('/acme/demo/widgets/12/preview')
|
||||
expect(safeRedirect(router.currentRoute.value.query.redirect)).toBe('/acme/demo/widgets/12/preview')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { mapWinterUrl } from '../../src/app/winterUrl'
|
||||
import { clone, rosterFormSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, resetState } from '../helpers'
|
||||
|
||||
const ID = 'acme.demo.widgets'
|
||||
const LIST = '/acme/demo/widgets'
|
||||
@@ -59,3 +62,68 @@ describe('mapWinterUrl', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets', 'acme.demo.wid gets')).toBe('/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview URLs (UI-SPEC S3, D-11, T-12.1-16)', () => {
|
||||
const PEOPLE = `${API}/acme/roster/people`
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('backstop: mapWinterUrl maps preview/:id to the preview route, and opening the preview route of a form without a preview replaces it with the record route', async () => {
|
||||
const id = 'acme.roster.people'
|
||||
expect(mapWinterUrl('acme/roster/people/preview/:id', id, 12)).toBe('/acme/roster/people/12/preview')
|
||||
expect(mapWinterUrl('acme/roster/people/preview/12', id)).toBe('/acme/roster/people/12/preview')
|
||||
expect(mapWinterUrl('/Acme/Roster/People/preview/:id/', id, '12')).toBe('/acme/roster/people/12/preview')
|
||||
|
||||
const plain = clone(rosterFormSchemaFixture)
|
||||
delete plain.data.preview
|
||||
const { wrapper, router, calls } = await mountApp('/acme/roster/people/1/preview', {
|
||||
[`GET ${PEOPLE}/schema/form`]: { body: plain },
|
||||
[`GET ${PEOPLE}/1`]: { body: rosterRecordFixture },
|
||||
})
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('record')
|
||||
expect(router.currentRoute.value.path).toBe('/acme/roster/people/1')
|
||||
// Replaced, not pushed: going back does not return to the preview URL.
|
||||
expect(wrapper.find('[data-preview]').exists()).toBe(false)
|
||||
expect(calls.some((request) => new URL(request.url).pathname.includes('/partials/'))).toBe(false)
|
||||
wrapper.unmount()
|
||||
|
||||
// With a preview block the same URL stays on the preview route.
|
||||
const kept = await mountApp('/acme/roster/people/1/preview', {
|
||||
[`GET ${PEOPLE}/schema/form`]: { body: rosterFormSchemaFixture },
|
||||
[`GET ${PEOPLE}/1`]: { body: rosterRecordFixture },
|
||||
[`GET ${PEOPLE}/partials/status`]: { body: { data: { nodes: [] }, meta: {} } },
|
||||
})
|
||||
await flushPromises()
|
||||
expect(kept.router.currentRoute.value.name).toBe('preview')
|
||||
expect(kept.wrapper.find('[data-preview]').exists()).toBe(true)
|
||||
kept.wrapper.unmount()
|
||||
})
|
||||
|
||||
it('rejects a foreign controller, a foreign plugin and a foreign vendor', () => {
|
||||
expect(mapWinterUrl('acme/demo/gadgets/preview/:id', ID, 1)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/other/widgets/preview/:id', ID, 1)).toBe(LIST)
|
||||
expect(mapWinterUrl('evil/demo/widgets/preview/:id', ID, 1)).toBe(LIST)
|
||||
expect(mapWinterUrl('https://evil.example/acme/demo/widgets/preview/1', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('//evil.example/acme/demo/widgets/preview/1', ID)).toBe(LIST)
|
||||
})
|
||||
|
||||
it.each([['abc'], ['1e3'], ['-1'], ['1.5'], ['0x10'], [' 1'], ['1/..'], ['../1'], ['%31'], ['']])('rejects the non-numeric preview id %j', (bad) => {
|
||||
expect(mapWinterUrl(`acme/demo/widgets/preview/${bad}`, ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/:id', ID, bad)).toBe(LIST)
|
||||
})
|
||||
|
||||
it('accepts no extra segment, no other case of the action and no missing id', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview', ID, 1)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/1/edit', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/Preview/1', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/:id', ID, undefined)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/1?next=//evil.example#x', ID)).toBe(`${LIST}/1/preview`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +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 FormView from '../../src/views/FormView.vue'
|
||||
import { recordCrumb } from '../../src/state/useBreadcrumbs'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { WIDGET_EVENT } from '../../src/components/form/formContext'
|
||||
import { clone, extensionFormSchemaFixture, formSchemaFixture, recordFixture } from '../fixtures/typed'
|
||||
import {
|
||||
clone,
|
||||
extensionFormSchemaFixture,
|
||||
formSchemaFixture,
|
||||
langFixture,
|
||||
recordFixture,
|
||||
rosterFormSchemaFixture,
|
||||
rosterRecordFixture,
|
||||
} from '../fixtures/typed'
|
||||
import { API, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
|
||||
const BASE = `${API}/acme/demo/widgets`
|
||||
@@ -288,3 +297,260 @@ describe('form view extension context (Phase 10.1, D-05, D-07, D-08, D-09, D-14)
|
||||
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('forbidden save on the form (UI-SPEC S6, D-27)', () => {
|
||||
const FALLBACK = 'You do not have permission to make this change. Nothing was saved.'
|
||||
const strings = {
|
||||
'backend::lang.form.forbidden': { other: FALLBACK },
|
||||
'backend::lang.form.error_title': { other: 'The record could not be saved.' },
|
||||
'backend::lang.form.error_fields': { one: 'Correct :count field.', other: 'Correct :count fields.' },
|
||||
}
|
||||
type Mounted = Awaited<ReturnType<typeof mountApp>>['wrapper']
|
||||
const refused = (message: string, details: Record<string, string[]> = {}): Reply => ({
|
||||
status: 403,
|
||||
body: { error: { code: 'forbidden', message, details } },
|
||||
})
|
||||
const banner = (wrapper: Mounted) => wrapper.find('[data-forbidden-banner]')
|
||||
const name = (wrapper: Mounted) => wrapper.find<HTMLInputElement>('#field-name')
|
||||
const code = (wrapper: Mounted) => wrapper.find<HTMLInputElement>('#field-code')
|
||||
async function save(wrapper: Mounted): Promise<void> {
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||
})
|
||||
|
||||
it('shows the banner with the server message, or the framework text without one', async () => {
|
||||
const withMessage = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('Only makers may rename widgets.') }))
|
||||
await name(withMessage.wrapper).setValue('Renamed')
|
||||
await save(withMessage.wrapper)
|
||||
expect(banner(withMessage.wrapper).attributes('role')).toBe('alert')
|
||||
expect(banner(withMessage.wrapper).text()).toBe('Only makers may rename widgets.')
|
||||
expect(toasts.value).toHaveLength(0)
|
||||
withMessage.wrapper.unmount()
|
||||
|
||||
const bare = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('') }))
|
||||
await name(bare.wrapper).setValue('Renamed')
|
||||
await save(bare.wrapper)
|
||||
expect(banner(bare.wrapper).text()).toBe(FALLBACK)
|
||||
expect(bare.wrapper.find('[aria-invalid="true"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('puts details on their fields, focuses the first one and shows no 422 banner', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('Refused.', { code: ['This code is reserved.'], name: ['This name is reserved.'] }) }),
|
||||
{ attach: true },
|
||||
)
|
||||
await code(wrapper).setValue('W-99')
|
||||
await save(wrapper)
|
||||
expect(banner(wrapper).text()).toBe('Refused.')
|
||||
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
|
||||
expect(name(wrapper).attributes('aria-invalid')).toBe('true')
|
||||
expect(code(wrapper).attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.text()).toContain('This name is reserved.')
|
||||
expect(wrapper.text()).toContain('This code is reserved.')
|
||||
// name comes first in the form, so it takes the focus.
|
||||
expect(document.activeElement).toBe(name(wrapper).element)
|
||||
})
|
||||
|
||||
it('keeps the typed values and the dirty state: nothing was saved', async () => {
|
||||
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('Refused.') }), { attach: true })
|
||||
await name(wrapper).setValue('Typed by hand')
|
||||
await save(wrapper)
|
||||
expect(name(wrapper).element.value).toBe('Typed by hand')
|
||||
expect(requestsTo(calls, 'GET', RECORD)).toHaveLength(1)
|
||||
// Leaving still asks, because the form is dirty.
|
||||
await wrapper.find('[data-action="cancel"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(document.body.querySelector('[data-confirm-dialog]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clears the banner when the next save starts, and shows nothing after a save that succeeds', async () => {
|
||||
let attempt = 0
|
||||
const saved = clone(recordFixture)
|
||||
saved.data.name = 'Second try'
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
routes({
|
||||
[`GET ${BASE}/schema/form`]: flatSchema(),
|
||||
[`PUT ${RECORD}`]: () => {
|
||||
attempt += 1
|
||||
return attempt === 1 ? refused('Refused.', { name: ['Reserved.'] }) : { body: saved }
|
||||
},
|
||||
}),
|
||||
)
|
||||
await name(wrapper).setValue('First try')
|
||||
await save(wrapper)
|
||||
expect(banner(wrapper).exists()).toBe(true)
|
||||
// Editing does not dismiss it: only the next save attempt does.
|
||||
await name(wrapper).setValue('Second try')
|
||||
expect(banner(wrapper).exists()).toBe(true)
|
||||
await save(wrapper)
|
||||
expect(attempt).toBe(2)
|
||||
expect(banner(wrapper).exists()).toBe(false)
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
expect(name(wrapper).attributes('aria-invalid')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replaces the banner with the 422 banner when the next save fails validation', async () => {
|
||||
let attempt = 0
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
routes({
|
||||
[`GET ${BASE}/schema/form`]: flatSchema(),
|
||||
[`PUT ${RECORD}`]: () => {
|
||||
attempt += 1
|
||||
return attempt === 1
|
||||
? refused('Refused.')
|
||||
: { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { name: ['Too short.'] } } } }
|
||||
},
|
||||
}),
|
||||
)
|
||||
await name(wrapper).setValue('x')
|
||||
await save(wrapper)
|
||||
expect(banner(wrapper).exists()).toBe(true)
|
||||
await save(wrapper)
|
||||
expect(banner(wrapper).exists()).toBe(false)
|
||||
expect(wrapper.find('[data-error-banner]').text()).toContain('Correct 1 field.')
|
||||
})
|
||||
|
||||
it('refuses a create the same way and stays on the create route', async () => {
|
||||
const { wrapper, router } = await mountApp('/acme/demo/widgets/create', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`POST ${BASE}`]: refused('You may not add widgets.') }))
|
||||
await name(wrapper).setValue('Brand new')
|
||||
await save(wrapper)
|
||||
expect(banner(wrapper).text()).toBe('You may not add widgets.')
|
||||
expect(router.currentRoute.value.name).toBe('create')
|
||||
expect(name(wrapper).element.value).toBe('Brand new')
|
||||
})
|
||||
|
||||
it('shows a refused delete as a danger toast, not as the banner, and stays on the record', async () => {
|
||||
const { wrapper, router } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`DELETE ${RECORD}`]: refused('This widget is in use.') }), { attach: true })
|
||||
await wrapper.find('[data-action="delete"]').trigger('click')
|
||||
await flushPromises()
|
||||
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['This widget is in use.', 'danger']])
|
||||
expect(banner(wrapper).exists()).toBe(false)
|
||||
expect(router.currentRoute.value.name).toBe('record')
|
||||
})
|
||||
})
|
||||
|
||||
describe('password and preset on the form (UI-SPEC S7, D-19, D-27 G7)', () => {
|
||||
const PEOPLE = `${API}/acme/roster/people`
|
||||
const PERSON = `${PEOPLE}/1`
|
||||
type Mounted = Awaited<ReturnType<typeof mountApp>>['wrapper']
|
||||
const field = (wrapper: Mounted, name: string) => wrapper.find<HTMLInputElement>(`#field-${name}`)
|
||||
function people(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${PEOPLE}/schema/form`]: { body: rosterFormSchemaFixture },
|
||||
[`GET ${PERSON}`]: { body: rosterRecordFixture },
|
||||
[`GET ${PEOPLE}/partials/status`]: { body: { data: { nodes: [] }, meta: {} } },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
async function save(wrapper: Mounted): Promise<void> {
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setBundle(
|
||||
{ ...langFixture.data, 'backend::lang.form.show_password': { other: 'Show password' }, 'backend::lang.form.hide_password': { other: 'Hide password' } },
|
||||
'en',
|
||||
)
|
||||
})
|
||||
|
||||
it('loads both password fields empty and masked, and puts no password into the page', async () => {
|
||||
// The guarantee is the server's: a record response never carries a
|
||||
// password (the framework test T-12.1-10). The form adds none of its own.
|
||||
const { wrapper } = await mountApp('/acme/roster/people/1', people())
|
||||
for (const name of ['password', 'password_confirmation']) {
|
||||
expect(field(wrapper, name).attributes('type')).toBe('password')
|
||||
expect(field(wrapper, name).element.value).toBe('')
|
||||
expect(field(wrapper, name).attributes('autocomplete')).toBe('new-password')
|
||||
}
|
||||
expect('password' in rosterRecordFixture.data).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves the password out of an update that does not type one', async () => {
|
||||
const { wrapper, calls } = await mountApp('/acme/roster/people/1', people({ [`PUT ${PERSON}`]: { body: rosterRecordFixture } }))
|
||||
await field(wrapper, 'name').setValue('Ada King')
|
||||
await save(wrapper)
|
||||
const body = (await requestsTo(calls, 'PUT', PERSON)[0]!.clone().json()) as Record<string, unknown>
|
||||
expect(body.name).toBe('Ada King')
|
||||
expect('password' in body).toBe(false)
|
||||
expect('password_confirmation' in body).toBe(false)
|
||||
})
|
||||
|
||||
it('sends the typed pair and clears and hides both fields after the save', async () => {
|
||||
const { wrapper, calls } = await mountApp('/acme/roster/people/1', people({ [`PUT ${PERSON}`]: { body: rosterRecordFixture } }))
|
||||
await field(wrapper, 'password').setValue('new-secret-1')
|
||||
await field(wrapper, 'password_confirmation').setValue('new-secret-1')
|
||||
await wrapper.find('[data-field="password"] [data-password-toggle]').trigger('click')
|
||||
expect(field(wrapper, 'password').attributes('type')).toBe('text')
|
||||
await save(wrapper)
|
||||
const body = (await requestsTo(calls, 'PUT', PERSON)[0]!.clone().json()) as Record<string, unknown>
|
||||
expect(body.password).toBe('new-secret-1')
|
||||
expect(body.password_confirmation).toBe('new-secret-1')
|
||||
for (const name of ['password', 'password_confirmation']) {
|
||||
expect(field(wrapper, name).element.value).toBe('')
|
||||
expect(field(wrapper, name).attributes('type')).toBe('password')
|
||||
}
|
||||
// A second save sends no password again.
|
||||
await field(wrapper, 'name').setValue('Ada King')
|
||||
await save(wrapper)
|
||||
const second = (await requestsTo(calls, 'PUT', PERSON)[1]!.clone().json()) as Record<string, unknown>
|
||||
expect('password' in second).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the typed password when the save is refused', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/roster/people/1',
|
||||
people({ [`PUT ${PERSON}`]: { status: 403, body: { error: { code: 'forbidden', message: 'Refused.', details: { password: ['Not allowed.'] } } } } }),
|
||||
)
|
||||
await field(wrapper, 'password').setValue('new-secret-1')
|
||||
await field(wrapper, 'password_confirmation').setValue('new-secret-1')
|
||||
await save(wrapper)
|
||||
expect(wrapper.find('[data-forbidden-banner]').text()).toBe('Refused.')
|
||||
expect(field(wrapper, 'password').element.value).toBe('new-secret-1')
|
||||
expect(field(wrapper, 'password').attributes('aria-invalid')).toBe('true')
|
||||
})
|
||||
|
||||
it('on create, sends a password that was typed and cleared as empty, and leaves an untouched one to the server rules', async () => {
|
||||
const invalid = { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { password: ['The password is required.'] } } } }
|
||||
const { wrapper, calls } = await mountApp('/acme/roster/people/create', people({ [`POST ${PEOPLE}`]: invalid }))
|
||||
await field(wrapper, 'name').setValue('Grace Hopper')
|
||||
await save(wrapper)
|
||||
const untouched = (await requestsTo(calls, 'POST', PEOPLE)[0]!.clone().json()) as Record<string, unknown>
|
||||
expect(untouched.password ?? '').toBe('')
|
||||
expect(wrapper.text()).toContain('The password is required.')
|
||||
expect(field(wrapper, 'password').attributes('aria-invalid')).toBe('true')
|
||||
|
||||
await field(wrapper, 'password').setValue('typed')
|
||||
await field(wrapper, 'password').setValue('')
|
||||
await save(wrapper)
|
||||
const cleared = (await requestsTo(calls, 'POST', PEOPLE)[1]!.clone().json()) as Record<string, unknown>
|
||||
expect(cleared.password).toBe('')
|
||||
})
|
||||
|
||||
it('fills a preset target on create only, and stops at the first manual edit', async () => {
|
||||
const create = await mountApp('/acme/roster/people/create', people())
|
||||
await field(create.wrapper, 'name').setValue('Zażółć gęślą')
|
||||
expect(field(create.wrapper, 'slug').element.value).toBe('za-g-l')
|
||||
await field(create.wrapper, 'slug').setValue('by-hand')
|
||||
await field(create.wrapper, 'name').setValue('Something Else')
|
||||
expect(field(create.wrapper, 'slug').element.value).toBe('by-hand')
|
||||
// Clearing the target by hand is an edit too: it stays empty.
|
||||
await field(create.wrapper, 'slug').setValue('')
|
||||
await field(create.wrapper, 'name').setValue('Third Name')
|
||||
expect(field(create.wrapper, 'slug').element.value).toBe('')
|
||||
create.wrapper.unmount()
|
||||
|
||||
const update = await mountApp('/acme/roster/people/1', people())
|
||||
await field(update.wrapper, 'name').setValue('Ada King')
|
||||
expect(field(update.wrapper, 'slug').element.value).toBe('ada-lovelace')
|
||||
})
|
||||
})
|
||||
|
||||
278
admin/tests/form/PreviewView.test.ts
Normal file
278
admin/tests/form/PreviewView.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
// The preview screen (UI-SPEC S3, D-11) with its footer (UI-SPEC S2, D-10).
|
||||
// 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 { toasts } from '../../src/state/useToasts'
|
||||
import PreviewView from '../../src/views/PreviewView.vue'
|
||||
import { clone, langFixture, rosterFormSchemaFixture, rosterRecordFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, wait, type Reply, type Route } from '../helpers'
|
||||
|
||||
const LIST = `${API}/acme/roster/people`
|
||||
const RECORD = `${LIST}/1`
|
||||
const HINT = `${LIST}/partials/status`
|
||||
const PREVIEW = '/acme/roster/people/1/preview'
|
||||
|
||||
const strings = {
|
||||
'backend::lang.form.return_to_list': { other: 'Back to list' },
|
||||
'backend::lang.form.load_failed': { other: 'The form could not be loaded.' },
|
||||
'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.' },
|
||||
'backend::lang.list.empty_value': { other: '—' },
|
||||
'backend::lang.extension.partial_failed': { other: 'This section could not be loaded.' },
|
||||
'backend::lang.permissioneditor.other': { other: 'Other' },
|
||||
}
|
||||
|
||||
const hint = (text: string) => ({
|
||||
data: { nodes: [{ tag: 'div', attrs: { class: 'summer-callout summer-callout--warning', role: 'status' }, children: [{ tag: 'p', attrs: { class: 'summer-callout__title' }, children: [{ text }] }] }] },
|
||||
meta: {},
|
||||
})
|
||||
const noHint = { data: { nodes: [] }, meta: {} }
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${LIST}/schema/form`]: { body: rosterFormSchemaFixture },
|
||||
[`GET ${RECORD}`]: { body: rosterRecordFixture },
|
||||
[`GET ${HINT}`]: { body: hint('This person is not active') },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function schemaWith(change: (schema: typeof rosterFormSchemaFixture.data) => void) {
|
||||
const envelope = clone(rosterFormSchemaFixture)
|
||||
change(envelope.data)
|
||||
return { body: envelope }
|
||||
}
|
||||
|
||||
function recordWith(change: (record: typeof rosterRecordFixture) => void) {
|
||||
const record = clone(rosterRecordFixture)
|
||||
change(record)
|
||||
return { body: record }
|
||||
}
|
||||
|
||||
const fields = (wrapper: VueWrapper) => wrapper.findAll('[data-preview-field]').map((item) => item.attributes('data-preview-field'))
|
||||
const footerButtons = (wrapper: VueWrapper) =>
|
||||
wrapper.findAll('[data-preview] footer button').map((item) => item.attributes('data-record-action') ?? item.attributes('data-action'))
|
||||
const dialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
|
||||
|
||||
async function runAction(wrapper: VueWrapper, name: string): Promise<void> {
|
||||
await wrapper.find(`[data-record-action="${name}"]`).trigger('click')
|
||||
await flushPromises()
|
||||
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
||||
await flushPromises()
|
||||
await wait(10)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('preview view (UI-SPEC S3, D-11)', () => {
|
||||
it('is the component of the preview route and renders values as a dl, with no input', async () => {
|
||||
const { wrapper } = await mountApp(PREVIEW, routes())
|
||||
expect(wrapper.findComponent(PreviewView).exists()).toBe(true)
|
||||
expect(wrapper.find('[data-preview] dl').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-preview] dl input, [data-preview] dl textarea, [data-preview] dl select').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="save"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the fields of the preview context: preview-only ones in, create and update ones out', async () => {
|
||||
const { wrapper } = await mountApp(PREVIEW, routes())
|
||||
const shown = fields(wrapper)
|
||||
expect(shown).toContain('joined_ip')
|
||||
expect(shown).toEqual(expect.arrayContaining(['name', 'email', 'team', 'tags', 'slug']))
|
||||
for (const hidden of ['password', 'password_confirmation', 'notify', 'permissions']) {
|
||||
expect(shown).not.toContain(hidden)
|
||||
}
|
||||
// A password is never rendered here, even if a response carried one.
|
||||
expect(wrapper.html()).not.toMatch(/type="password"/)
|
||||
})
|
||||
|
||||
it('hides a tab none of whose fields show on the preview, and shows it once one does', async () => {
|
||||
// The Permissions tab holds one update-only field: no such tab here.
|
||||
const { wrapper } = await mountApp(PREVIEW, routes())
|
||||
expect(wrapper.findAll('[role="tab"]').some((tab) => tab.text().includes('Permissions'))).toBe(false)
|
||||
expect(wrapper.find('[data-permission-editor]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
|
||||
const shownOnPreview = schemaWith((schema) => {
|
||||
const permissions = schema.fields.find((field) => field.name === 'permissions')!
|
||||
permissions.context = ['update', 'preview']
|
||||
})
|
||||
const withTab = await mountApp(PREVIEW, routes({ [`GET ${LIST}/schema/form`]: shownOnPreview }))
|
||||
const tab = withTab.wrapper.findAll('[role="tab"]').find((item) => item.text().includes('Permissions'))
|
||||
expect(tab).toBeDefined()
|
||||
await tab!.trigger('click')
|
||||
await flushPromises()
|
||||
// The editor is read-only on this screen.
|
||||
const editor = withTab.wrapper.find('[data-permission-editor]')
|
||||
expect(editor.exists()).toBe(true)
|
||||
expect(editor.findAll('[role="radiogroup"]').every((group) => group.attributes('aria-disabled') === 'true')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the muted dash for an empty value and the label of a relation, never its id', async () => {
|
||||
const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${RECORD}`]: recordWith((record) => (record.data.joined_ip = null)) }))
|
||||
expect(wrapper.find('[data-preview-field="joined_ip"] [data-empty]').text()).toBe('—')
|
||||
expect(wrapper.find('[data-preview-field="team"] [data-preview-value]').text()).toBe('Home')
|
||||
expect(wrapper.findAll('[data-preview-field="tags"] [data-chip]').map((chip) => chip.find('.font-semibold').text())).toEqual(['staff', 'news'])
|
||||
expect(wrapper.find('[data-preview-field="tags"] button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('asks for the status hint with the record id and renders it between the header and the card', async () => {
|
||||
const { wrapper, calls } = await mountApp(PREVIEW, routes())
|
||||
const [request] = requestsTo(calls, 'GET', HINT)
|
||||
expect(new URL(request!.url).searchParams.get('id')).toBe('1')
|
||||
const callout = wrapper.find('[data-preview] .summer-callout')
|
||||
expect(callout.text()).toBe('This person is not active')
|
||||
expect(callout.attributes('role')).toBe('status')
|
||||
// One callout, above the card with the fields.
|
||||
expect(wrapper.findAll('[data-preview] .summer-callout')).toHaveLength(1)
|
||||
const order = wrapper.find('[data-preview]').html()
|
||||
expect(order.indexOf('summer-callout')).toBeLessThan(order.indexOf('<dl'))
|
||||
})
|
||||
|
||||
it('asks for no hint when the preview block names no partial', async () => {
|
||||
const plain = schemaWith((schema) => (schema.preview = {}))
|
||||
const { wrapper, calls } = await mountApp(PREVIEW, routes({ [`GET ${LIST}/schema/form`]: plain }))
|
||||
expect(requestsTo(calls, 'GET', HINT)).toHaveLength(0)
|
||||
expect(wrapper.find('.summer-callout').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-preview] dl').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the previous hint on screen while it reloads after an action', async () => {
|
||||
let release: (reply: Reply) => void = () => undefined
|
||||
let hints = 0
|
||||
const { wrapper } = await mountApp(
|
||||
PREVIEW,
|
||||
routes({
|
||||
[`GET ${HINT}`]: () => {
|
||||
hints += 1
|
||||
if (hints === 1) {
|
||||
return { body: hint('This person is not active') }
|
||||
}
|
||||
return new Promise<Reply>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
},
|
||||
[`POST ${RECORD}/actions/activate`]: { body: { data: { message: '', fill: {} }, meta: {} } },
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await runAction(wrapper, 'activate')
|
||||
expect(hints).toBe(2)
|
||||
// Still the first hint, no skeleton in its place.
|
||||
expect(wrapper.find('.summer-callout').text()).toBe('This person is not active')
|
||||
release({ body: noHint })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.summer-callout').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the load failure, the back link and no footer when the record cannot be loaded', async () => {
|
||||
for (const status of [404, 403, 500]) {
|
||||
const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${RECORD}`]: { status, body: { error: { code: 'x', message: 'Hidden detail', details: {} } } } }))
|
||||
expect(wrapper.find('[role="alert"]').text()).toBe('The form could not be loaded.')
|
||||
expect(wrapper.text()).not.toContain('Hidden detail')
|
||||
expect(wrapper.find('[data-preview] dl').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="edit"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-record-action]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="back"]').attributes('href')).toBe('/acme/roster/people')
|
||||
wrapper.unmount()
|
||||
resetState()
|
||||
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the load failure when the form schema cannot be loaded', async () => {
|
||||
const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${LIST}/schema/form`]: { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } } }))
|
||||
expect(wrapper.find('[role="alert"]').text()).toBe('The form could not be loaded.')
|
||||
expect(wrapper.find('[data-action="edit"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('preview view footer (UI-SPEC S2, D-10)', () => {
|
||||
it('zero actions: the edit button alone', async () => {
|
||||
for (const change of [(record: typeof rosterRecordFixture) => delete record.meta.actions, (record: typeof rosterRecordFixture) => (record.meta.actions = [])]) {
|
||||
const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${RECORD}`]: recordWith(change) }))
|
||||
expect(footerButtons(wrapper)).toEqual(['edit'])
|
||||
wrapper.unmount()
|
||||
resetState()
|
||||
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||
}
|
||||
})
|
||||
|
||||
it('one action: it comes before the one primary edit button', async () => {
|
||||
const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${RECORD}`]: recordWith((record) => (record.meta.actions = [{ name: 'activate', label: 'Activate' }])) }))
|
||||
expect(footerButtons(wrapper)).toEqual(['activate', 'edit'])
|
||||
expect(wrapper.findAll('[data-preview] footer .bg-primary')).toHaveLength(1)
|
||||
expect(wrapper.find('[data-action="edit"]').classes()).toContain('bg-primary')
|
||||
})
|
||||
|
||||
it('several actions: in the order the server offers them, edit last', async () => {
|
||||
const offered = [
|
||||
{ name: 'reinstate', label: 'Reinstate' },
|
||||
{ name: 'activate', label: 'Activate' },
|
||||
{ name: 'resend', label: 'Send the invitation again' },
|
||||
]
|
||||
const { wrapper } = await mountApp(PREVIEW, routes({ [`GET ${RECORD}`]: recordWith((record) => (record.meta.actions = offered)) }))
|
||||
expect(footerButtons(wrapper)).toEqual(['reinstate', 'activate', 'resend', 'edit'])
|
||||
expect(wrapper.find('[data-preview] footer').classes()).toContain('flex-wrap')
|
||||
})
|
||||
|
||||
it('after an action: a success toast, then the record and the hint are read again', async () => {
|
||||
const after = recordWith((record) => (record.meta.actions = [{ name: 'reinstate', label: 'Reinstate' }]))
|
||||
let reads = 0
|
||||
const { wrapper, calls } = await mountApp(
|
||||
PREVIEW,
|
||||
routes({
|
||||
[`GET ${RECORD}`]: () => {
|
||||
reads += 1
|
||||
return reads === 1 ? { body: rosterRecordFixture } : after
|
||||
},
|
||||
[`POST ${RECORD}/actions/activate`]: { body: { data: { message: 'The person was activated.', fill: {} }, meta: {} } },
|
||||
}),
|
||||
{ attach: true },
|
||||
)
|
||||
await runAction(wrapper, 'activate')
|
||||
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['The person was activated.', 'success']])
|
||||
expect(reads).toBe(2)
|
||||
expect(requestsTo(calls, 'GET', HINT)).toHaveLength(2)
|
||||
// The action that no longer applies is gone from the footer.
|
||||
expect(footerButtons(wrapper)).toEqual(['reinstate', 'edit'])
|
||||
})
|
||||
|
||||
it('409: the record is read again; 404: the screen turns into the load failure', async () => {
|
||||
const stale = await mountApp(PREVIEW, routes({ [`POST ${RECORD}/actions/activate`]: { status: 409, body: { error: { code: 'conflict', message: 'x', details: {} } } } }), { attach: true })
|
||||
await runAction(stale.wrapper, 'activate')
|
||||
expect(requestsTo(stale.calls, 'GET', RECORD)).toHaveLength(2)
|
||||
expect(toasts.value.map((toast) => toast.tone)).toEqual(['danger'])
|
||||
expect(stale.wrapper.find('[data-preview] dl').exists()).toBe(true)
|
||||
stale.wrapper.unmount()
|
||||
document.body.innerHTML = ''
|
||||
resetState()
|
||||
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||
|
||||
const gone = await mountApp(PREVIEW, routes(), { attach: true })
|
||||
await runAction(gone.wrapper, 'activate')
|
||||
expect(gone.wrapper.find('[role="alert"]').text()).toBe('The form could not be loaded.')
|
||||
expect(gone.wrapper.find('[data-action="edit"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('edit leads to the record route of the same record', async () => {
|
||||
const { wrapper, router } = await mountApp(PREVIEW, routes())
|
||||
await wrapper.find('[data-action="edit"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.name).toBe('record')
|
||||
expect(router.currentRoute.value.path).toBe('/acme/roster/people/1')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
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 RelationField from '../../src/components/form/fields/RelationField.vue'
|
||||
import { formField, optionsFixture, recordFixture } from '../fixtures/typed'
|
||||
import { formField, langFixture, optionsFixture, recordFixture } from '../fixtures/typed'
|
||||
import { API, keydown, mockApi, queryOf, requestsTo, resetState, wait, type Route } from '../helpers'
|
||||
|
||||
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
|
||||
@@ -241,3 +242,123 @@ describe('multiple relation (D-18)', () => {
|
||||
expect(wrapper.find('[data-chip] .font-semibold').text()).toBe('99')
|
||||
})
|
||||
})
|
||||
|
||||
describe('locked relation options (UI-SPEC S6, D-07)', () => {
|
||||
const NOTE = 'Items marked with a lock need an additional permission to change.'
|
||||
const strings = {
|
||||
'backend::lang.form.locked_item': { other: 'Locked: :name' },
|
||||
'backend::lang.form.locked_note': { other: NOTE },
|
||||
'backend::lang.form.remove_item': { other: 'Remove: :name' },
|
||||
}
|
||||
// Round (3) is a locked value; Large (4) is a locked choice; Extra (7) is free.
|
||||
const lockedLabels = [
|
||||
{ value: 3, label: 'Round', locked: true },
|
||||
{ value: 1, label: 'Small' },
|
||||
]
|
||||
const lockedOptions = {
|
||||
data: [
|
||||
{ value: 1, label: 'Small' },
|
||||
{ value: 3, label: 'Round', locked: true },
|
||||
{ value: 4, label: 'Large', locked: true },
|
||||
{ value: 7, label: 'Extra' },
|
||||
],
|
||||
meta: { page: 1, per_page: 20, total: 4, last_page: 1 },
|
||||
}
|
||||
const chipNames = (wrapper: VueWrapper) => wrapper.findAll('[data-chip]').map((chip) => chip.find('.font-semibold').text())
|
||||
const option = (wrapper: VueWrapper, value: number) => wrapper.find(`[role="option"][data-value="${value}"]`)
|
||||
const emitted = (wrapper: VueWrapper) => (wrapper.emitted('update:modelValue') ?? []).map((args) => args[0])
|
||||
|
||||
function mountLocked(modelValue: unknown = [3, 1], labels = lockedLabels) {
|
||||
mockApi(routes({ [`GET ${TAGS}`]: { body: lockedOptions } }))
|
||||
return mountField('tags', modelValue, { labels })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||
})
|
||||
|
||||
it('shows a locked chip with a lock, its name for screen readers and no remove button', () => {
|
||||
const wrapper = mountLocked()
|
||||
const [round, small] = wrapper.findAll('[data-chip]')
|
||||
expect(round!.find('button').exists()).toBe(false)
|
||||
expect(round!.find('[data-chip-locked] svg').attributes('aria-hidden')).toBe('true')
|
||||
expect(round!.find('[data-chip-locked] .sr-only').text()).toBe('Locked: Round')
|
||||
expect(small!.find('[data-chip-locked]').exists()).toBe(false)
|
||||
expect(small!.find('button').attributes('aria-label')).toBe('Remove: Small')
|
||||
})
|
||||
|
||||
it('shows the note once a locked label or option was seen, and names it in aria-describedby', async () => {
|
||||
const wrapper = mountLocked()
|
||||
const note = wrapper.find('[data-locked-note]')
|
||||
expect(note.text()).toBe(NOTE)
|
||||
expect(combobox(wrapper).attributes('aria-describedby')).toContain(note.attributes('id'))
|
||||
|
||||
// No locked label: no note until an options page brings a locked choice.
|
||||
const plain = mountLocked([1], [{ value: 1, label: 'Small' }])
|
||||
expect(plain.find('[data-locked-note]').exists()).toBe(false)
|
||||
await openList(plain)
|
||||
expect(plain.find('[data-locked-note]').text()).toBe(NOTE)
|
||||
})
|
||||
|
||||
it('has no note and no lock without locked items', async () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('tags', [3, 1])
|
||||
await openList(wrapper)
|
||||
expect(wrapper.find('[data-locked-note]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-chip-locked]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('[role="option"]').every((item) => item.attributes('aria-disabled') === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('backstop: a locked option cannot be chosen by click, Enter or arrow keys, and a locked chip cannot be removed by click or Backspace', async () => {
|
||||
const wrapper = mountLocked()
|
||||
await openList(wrapper)
|
||||
|
||||
// The list offers the unselected choices: Large is locked, Extra is free.
|
||||
expect(optionTexts(wrapper)).toEqual(['Large', 'Extra'])
|
||||
const large = option(wrapper, 4)
|
||||
expect(large.attributes('aria-disabled')).toBe('true')
|
||||
expect(large.classes()).toEqual(expect.arrayContaining(['text-muted', 'cursor-not-allowed']))
|
||||
expect(large.find('svg').exists()).toBe(true)
|
||||
expect(option(wrapper, 7).attributes('aria-disabled')).toBeUndefined()
|
||||
|
||||
// By click.
|
||||
await large.trigger('mousedown')
|
||||
await large.trigger('click')
|
||||
expect(emitted(wrapper)).toEqual([])
|
||||
|
||||
// By the arrow keys: the active option is never the locked one, in
|
||||
// either direction and however often the key is pressed.
|
||||
const search = combobox(wrapper)
|
||||
for (const key of ['ArrowDown', 'ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowUp', 'Home', 'End']) {
|
||||
keydown(search.element, key)
|
||||
await flushPromises()
|
||||
expect(search.attributes('aria-activedescendant')).not.toBe(large.attributes('id'))
|
||||
}
|
||||
// By Enter: whatever is active, the locked id is not what gets added.
|
||||
keydown(search.element, 'Enter')
|
||||
await flushPromises()
|
||||
for (const value of emitted(wrapper)) {
|
||||
expect(value).not.toContain(4)
|
||||
}
|
||||
expect(emitted(wrapper)).toEqual([[3, 1, 7]])
|
||||
|
||||
// A locked chip has nothing to click, and Backspace removes only
|
||||
// unlocked chips: with the locked one left it does nothing.
|
||||
const chips = mountLocked([3, 1])
|
||||
expect(chips.findAll('[data-chip]')[0]!.find('button').exists()).toBe(false)
|
||||
await chips.findAll('[data-chip]')[0]!.trigger('click')
|
||||
expect(emitted(chips)).toEqual([])
|
||||
keydown(combobox(chips).element, 'Backspace')
|
||||
expect(emitted(chips)).toEqual([[3]])
|
||||
await chips.setProps({ modelValue: [3] })
|
||||
keydown(combobox(chips).element, 'Backspace')
|
||||
keydown(combobox(chips).element, 'Delete')
|
||||
expect(emitted(chips)).toEqual([[3]])
|
||||
expect(chipNames(chips)).toEqual(['Round'])
|
||||
|
||||
// A locked chip in the last position is skipped, not removed.
|
||||
const tail = mountLocked([1, 3])
|
||||
keydown(combobox(tail).element, 'Backspace')
|
||||
expect(emitted(tail)).toEqual([[3]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
focusField,
|
||||
initialValues,
|
||||
panelDomId,
|
||||
presetValue,
|
||||
snapshot,
|
||||
tabDomId,
|
||||
tabOf,
|
||||
} from '../../src/components/form/formState'
|
||||
import { extensionFormSchemaFixture, formField, formSchemaFixture } from '../fixtures/typed'
|
||||
import { extensionFormSchemaFixture, formField, formSchemaFixture, rosterFormSchemaFixture } from '../fixtures/typed'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
@@ -97,3 +98,83 @@ describe('form state helpers', () => {
|
||||
expect(focusField('field', 'missing')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('password and permission values in the save body (UI-SPEC S5 and S7)', () => {
|
||||
const fields = rosterFormSchemaFixture.data.fields
|
||||
|
||||
it('leaves an empty password out on update and sends it as entered on create', () => {
|
||||
const pair = fields.filter((field) => field.type === 'password')
|
||||
expect(pair.map((field) => field.name)).toEqual(['password', 'password_confirmation'])
|
||||
expect(editablePayload(pair, { password: '', password_confirmation: '' }, 'update')).toEqual({})
|
||||
expect(editablePayload(pair, { password: null, password_confirmation: null }, 'update')).toEqual({})
|
||||
expect(editablePayload(pair, { password: '', password_confirmation: '' }, 'create')).toEqual({ password: '', password_confirmation: '' })
|
||||
expect(editablePayload(pair, { password: 'new-secret', password_confirmation: 'new-secret' }, 'update')).toEqual({
|
||||
password: 'new-secret',
|
||||
password_confirmation: 'new-secret',
|
||||
})
|
||||
// A lone password is sent as entered: the server's confirmed rule answers.
|
||||
expect(editablePayload(pair, { password: 'new-secret', password_confirmation: '' }, 'update')).toEqual({ password: 'new-secret' })
|
||||
// The default mode is create.
|
||||
expect(editablePayload(pair, { password: '' })).toEqual({ password: '' })
|
||||
})
|
||||
|
||||
it('starts password fields empty, whatever the record holds', () => {
|
||||
const initial = initialValues(fields.filter((field) => field.type === 'password'))
|
||||
expect(initial.password ?? '').toBe('')
|
||||
expect(initial.password_confirmation ?? '').toBe('')
|
||||
})
|
||||
|
||||
it('shows a field with context preview on the preview only, and hides create and update fields there', () => {
|
||||
const byName = (name: string) => fields.find((field) => field.name === name)!
|
||||
expect(contextAllows(byName('joined_ip'), 'preview')).toBe(true)
|
||||
expect(contextAllows(byName('joined_ip'), 'update')).toBe(false)
|
||||
expect(contextAllows(byName('joined_ip'), 'create')).toBe(false)
|
||||
expect(contextAllows(byName('password'), 'preview')).toBe(false)
|
||||
expect(contextAllows(byName('notify'), 'preview')).toBe(false)
|
||||
expect(contextAllows(byName('permissions'), 'preview')).toBe(false)
|
||||
expect(contextAllows(byName('name'), 'preview')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('preset values (D-27 G7)', () => {
|
||||
// The same table as the user plugin's slug test (models/slug_test.go,
|
||||
// slugCases): the form and the model derive the same slug from a name.
|
||||
const slugCases: Array<[string, string]> = [
|
||||
['Acme Records', 'acme-records'],
|
||||
[' Acme -- Records ', 'acme-records'],
|
||||
['ACME_Records 2', 'acme-records-2'],
|
||||
['Zażółć gęślą', 'za-g-l'],
|
||||
['Rock & Roll!!!', 'rock-roll'],
|
||||
['...leading and trailing...', 'leading-and-trailing'],
|
||||
['tab\tand\nnewline', 'tab-and-newline'],
|
||||
['emoji 🎵 music', 'emoji-music'],
|
||||
['CamelCaseText', 'camelcasetext'],
|
||||
['9 lives', '9-lives'],
|
||||
['a', 'a'],
|
||||
['---', ''],
|
||||
[' ', ''],
|
||||
['', ''],
|
||||
['ąęść', ''],
|
||||
['Long Name '.repeat(40), 'long-name-'.repeat(40).replace(/-$/, '')],
|
||||
]
|
||||
|
||||
it.each(slugCases)('slug preset matches the server rule: %j', (text, want) => {
|
||||
expect(presetValue('slug', text)).toBe(want)
|
||||
// A slug is a fixed point of the rule.
|
||||
expect(presetValue('slug', want)).toBe(want)
|
||||
})
|
||||
|
||||
it('copies the text for type exact and for any type it does not know', () => {
|
||||
expect(presetValue('exact', ' Acme -- Records ')).toBe(' Acme -- Records ')
|
||||
expect(presetValue('camel', 'Acme Records')).toBe('Acme Records')
|
||||
})
|
||||
|
||||
it('reads a number as its text and anything else as empty', () => {
|
||||
expect(presetValue('slug', 2026)).toBe('2026')
|
||||
expect(presetValue('exact', 7)).toBe('7')
|
||||
for (const source of [null, undefined, true, { a: 1 }, ['x']]) {
|
||||
expect(presetValue('slug', source)).toBe('')
|
||||
expect(presetValue('exact', source)).toBe('')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,8 @@ import SwitchField from '../../src/components/form/fields/SwitchField.vue'
|
||||
import TextField from '../../src/components/form/fields/TextField.vue'
|
||||
import TextareaField from '../../src/components/form/fields/TextareaField.vue'
|
||||
import PartialField from '../../src/components/form/fields/PartialField.vue'
|
||||
import PasswordField from '../../src/components/form/fields/PasswordField.vue'
|
||||
import PermissionEditorField from '../../src/components/form/fields/PermissionEditorField.vue'
|
||||
import UnsupportedField from '../../src/components/form/fields/UnsupportedField.vue'
|
||||
import WidgetField from '../../src/components/form/fields/WidgetField.vue'
|
||||
import RelationManager from '../../src/components/relation/RelationManager.vue'
|
||||
@@ -148,3 +150,30 @@ describe('control module', () => {
|
||||
expect(toggleValue).toBe(control.toggleValue)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Phase 12.1 field types (UI-SPEC S5 and S7)', () => {
|
||||
it('registers password and permissioneditor as editable types with their own controls', () => {
|
||||
expect(rendererFor('password')).toBe(PasswordField)
|
||||
expect(rendererFor('permissioneditor')).toBe(PermissionEditorField)
|
||||
expect(isRegistered('password')).toBe(true)
|
||||
expect(isRegistered('permissioneditor')).toBe(true)
|
||||
// Both hold a form value: they are part of the save body.
|
||||
expect(needsRecord('password')).toBe(false)
|
||||
expect(needsRecord('permissioneditor')).toBe(false)
|
||||
})
|
||||
|
||||
it('labels a password as an input and a permission editor as a group', () => {
|
||||
expect(ownsLabel('password')).toBe(false)
|
||||
expect(ownsLabel('permissioneditor')).toBe(false)
|
||||
expect(groupLabelled('password')).toBe(false)
|
||||
expect(groupLabelled('permissioneditor')).toBe(true)
|
||||
})
|
||||
|
||||
it('still answers the unsupported control for a type it does not know', () => {
|
||||
for (const type of ['passwordeditor', 'Password', 'permission', '']) {
|
||||
expect(isRegistered(type), type).toBe(false)
|
||||
expect(rendererFor(type), type).not.toBe(PasswordField)
|
||||
expect(rendererFor(type), type).not.toBe(PermissionEditorField)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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