- 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
279 lines
14 KiB
TypeScript
279 lines
14 KiB
TypeScript
// 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')
|
|
})
|
|
})
|