- pact.FormVirtualFields lists form fields that are not model columns: never bound, filled or projected; their values reach the Form hooks through cabana.VirtualFieldsFromContext when the field's context allows the operation - type: password is a masked field that must be listed as virtual - pact.FormRules supplies the rule set per operation and replaces the model's Rules() for admin saves; a rule on a virtual field sees the submitted value - preset on a text field follows another text field on the create form - SPA: PasswordField, preset handling in FormView, empty password left out of an update - README, docs, OpenAPI document, TS types and dist updated
370 lines
17 KiB
TypeScript
370 lines
17 KiB
TypeScript
// Phase 12.1 preview screen (UI-SPEC S3, D-11) with the record actions in
|
|
// its footer (UI-SPEC S2, D-10): the read-only record route, the status hint,
|
|
// the dl grid, the preview-only field, the URL mapping and the update form's
|
|
// way back. 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 { routeControllerId } from '../../src/app/router'
|
|
import { mapWinterUrl } from '../../src/app/winterUrl'
|
|
import {
|
|
clone,
|
|
langFixture,
|
|
rosterFormSchemaFixture,
|
|
rosterListFixture,
|
|
rosterListSchemaFixture,
|
|
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 PREVIEW = '/acme/roster/people/1/preview'
|
|
|
|
// The framework strings this screen uses, as GET /lang serves them in en.
|
|
const strings = {
|
|
'backend::lang.form.return_to_list': { other: 'Back to list' },
|
|
'backend::lang.form.return_to_preview': { other: 'Back to preview' },
|
|
'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. The page has been refreshed.',
|
|
},
|
|
'backend::lang.form.cancel': { other: 'Cancel' },
|
|
'backend::lang.list.empty_value': { other: '—' },
|
|
'backend::lang.list.column_switch_true': { other: 'Yes' },
|
|
'backend::lang.list.column_switch_false': { other: 'No' },
|
|
'backend::lang.extension.partial_failed': { other: 'This section could not be loaded.' },
|
|
}
|
|
|
|
const callout = (title: string, 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: title }] },
|
|
{ tag: 'p', attrs: { class: 'summer-callout__text' }, children: [{ text }] },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
meta: {},
|
|
})
|
|
|
|
function recordWith(change: (record: typeof rosterRecordFixture) => void): typeof rosterRecordFixture {
|
|
const record = clone(rosterRecordFixture)
|
|
change(record)
|
|
return record
|
|
}
|
|
|
|
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
|
return {
|
|
[`GET ${LIST}/schema/form`]: { body: rosterFormSchemaFixture },
|
|
[`GET ${LIST}/schema/list`]: { body: rosterListSchemaFixture },
|
|
[`GET ${LIST}`]: { body: rosterListFixture },
|
|
[`GET ${RECORD}`]: { body: rosterRecordFixture },
|
|
[`GET ${LIST}/partials/status`]: { body: callout('This person is not active', 'Activate the person to let them sign in.') },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
const dialog = () => document.body.querySelector<HTMLElement>('[data-confirm-dialog]')
|
|
const footer = (wrapper: VueWrapper) => wrapper.find('[data-preview] footer')
|
|
const value = (wrapper: VueWrapper, name: string) => wrapper.find(`[data-preview-field="${name}"] dd`)
|
|
|
|
async function settle(): Promise<void> {
|
|
await wait(10)
|
|
await flushPromises()
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetState()
|
|
setBundle({ ...langFixture.data, ...strings }, 'en')
|
|
})
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
enableAutoUnmount(afterEach)
|
|
|
|
describe('preview screen (UI-SPEC S3, D-11)', () => {
|
|
it('renders the title, the subtitle and the fields as a dl grid of text', async () => {
|
|
const { wrapper, router } = await mountApp(PREVIEW, routes())
|
|
expect(router.currentRoute.value.name).toBe('preview')
|
|
expect(routeControllerId(router.currentRoute.value)).toBe('acme.roster.people')
|
|
expect(wrapper.find('h1').text()).toBe('Ada Lovelace')
|
|
expect(wrapper.find('header p').text()).toBe('Person details')
|
|
|
|
const grid = wrapper.find('[data-preview] dl')
|
|
expect(grid.exists()).toBe(true)
|
|
expect(grid.findAll('dt').map((item) => item.text())).toEqual(['Name', 'Email', 'Slug', 'Joined from IP address'])
|
|
expect(value(wrapper, 'name').text()).toBe('Ada Lovelace')
|
|
expect(value(wrapper, 'email').text()).toBe('ada@example.test')
|
|
// Values wrap and are never truncated; labels carry no required mark.
|
|
expect(value(wrapper, 'name').find('[data-preview-value]').classes()).toContain('[overflow-wrap:anywhere]')
|
|
expect(grid.text()).not.toContain('*')
|
|
// Nothing on the screen can be edited or saved.
|
|
expect(wrapper.find('[data-preview] input, [data-preview] textarea, [data-preview] select').exists()).toBe(false)
|
|
expect(wrapper.find('[data-action="save"], [data-action="delete"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('shows a preview-only field here and not on the update form', async () => {
|
|
const { wrapper, router } = await mountApp(PREVIEW, routes())
|
|
expect(value(wrapper, 'joined_ip').text()).toBe('203.0.113.7')
|
|
expect(wrapper.find('[data-preview-field="joined_ip"] input').exists()).toBe(false)
|
|
|
|
await router.push('/acme/roster/people/1')
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-field="name"]').exists()).toBe(true)
|
|
expect(wrapper.find('[data-field="joined_ip"]').exists()).toBe(false)
|
|
})
|
|
|
|
it('shows the muted dash for an empty value', async () => {
|
|
const { wrapper } = await mountApp(
|
|
PREVIEW,
|
|
routes({ [`GET ${RECORD}`]: { body: recordWith((record) => (record.data.email = '')) } }),
|
|
)
|
|
const empty = value(wrapper, 'email').find('[data-empty]')
|
|
expect(empty.text()).toBe('—')
|
|
expect(empty.classes()).toContain('text-muted')
|
|
})
|
|
|
|
it('renders the status hint between the header and the card with the record id', async () => {
|
|
const { wrapper, calls } = await mountApp(PREVIEW, routes())
|
|
const hint = wrapper.find('[data-partial="header"]')
|
|
expect(hint.find('.summer-callout--warning[role="status"]').exists()).toBe(true)
|
|
expect(hint.text()).toContain('This person is not active')
|
|
const [request] = requestsTo(calls, 'GET', `${LIST}/partials/status`)
|
|
expect(new URL(request!.url).searchParams.get('id')).toBe('1')
|
|
// The hint sits above the card, outside the dl.
|
|
expect(wrapper.find('[data-preview] dl [data-partial]').exists()).toBe(false)
|
|
})
|
|
|
|
it('renders nothing for a hint with zero nodes and the failure box for a failed one', async () => {
|
|
const none = await mountApp(PREVIEW, routes({ [`GET ${LIST}/partials/status`]: { body: { data: { nodes: [] }, meta: {} } } }))
|
|
expect(none.wrapper.find('[data-partial], [data-partial-loading], [data-partial-failed]').exists()).toBe(false)
|
|
expect(none.wrapper.find('[data-preview] dl').exists()).toBe(true)
|
|
none.wrapper.unmount()
|
|
|
|
const broken = await mountApp(
|
|
PREVIEW,
|
|
routes({ [`GET ${LIST}/partials/status`]: { status: 500, body: { error: { code: 'error', message: 'x', details: {} } } } }),
|
|
)
|
|
expect(broken.wrapper.find('[data-partial-failed]').exists()).toBe(true)
|
|
expect(broken.wrapper.find('[data-preview] dl').exists()).toBe(true)
|
|
expect(broken.wrapper.find('[data-action="edit"]').exists()).toBe(true)
|
|
})
|
|
|
|
it('shows one 68px skeleton while the hint first loads', async () => {
|
|
let release: ((reply: Reply) => void) | undefined
|
|
const { wrapper } = await mountApp(
|
|
PREVIEW,
|
|
routes({
|
|
[`GET ${LIST}/partials/status`]: () =>
|
|
new Promise<Reply>((resolve) => {
|
|
release = resolve
|
|
}),
|
|
}),
|
|
)
|
|
const skeleton = wrapper.find('[data-partial-skeleton]')
|
|
expect(skeleton.classes()).toEqual(expect.arrayContaining(['h-[68px]', 'rounded-inner', 'bg-skel']))
|
|
release?.({ body: callout('a', 'b') })
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-partial-skeleton]').exists()).toBe(false)
|
|
})
|
|
|
|
it('replaces the route with the record route when the form has no preview', async () => {
|
|
const plain = clone(rosterFormSchemaFixture)
|
|
delete plain.data.preview
|
|
const { router } = await mountApp(PREVIEW, routes({ [`GET ${LIST}/schema/form`]: { body: plain } }))
|
|
await flushPromises()
|
|
expect(router.currentRoute.value.name).toBe('record')
|
|
expect(router.currentRoute.value.path).toBe('/acme/roster/people/1')
|
|
})
|
|
|
|
it('shows the load failure and keeps the back button when the record is missing', async () => {
|
|
const { wrapper } = await mountApp(
|
|
PREVIEW,
|
|
routes({ [`GET ${RECORD}`]: { status: 404, body: { error: { code: 'not_found', message: 'Not found', details: {} } } } }),
|
|
)
|
|
expect(wrapper.find('[role="alert"]').text()).toBe('The form could not be loaded.')
|
|
expect(wrapper.find('[data-preview] dl').exists()).toBe(false)
|
|
expect(wrapper.find('[data-action="edit"]').exists()).toBe(false)
|
|
const back = wrapper.find('[data-action="back"]')
|
|
expect(back.attributes('href')).toBe('/acme/roster/people')
|
|
expect(back.attributes('aria-label')).toBe('Back to list')
|
|
})
|
|
})
|
|
|
|
describe('preview footer (UI-SPEC S2, D-10)', () => {
|
|
it('shows the offered record actions in order before the one primary edit button', async () => {
|
|
const { wrapper, router } = await mountApp(PREVIEW, routes())
|
|
const buttons = footer(wrapper).findAll('button')
|
|
expect(buttons.map((item) => item.attributes('data-record-action') ?? item.attributes('data-action'))).toEqual([
|
|
'activate',
|
|
'reinstate',
|
|
'edit',
|
|
])
|
|
const edit = wrapper.find('[data-action="edit"]')
|
|
expect(edit.text()).toBe('Edit person')
|
|
expect(edit.classes()).toContain('bg-primary')
|
|
expect(edit.find('svg').exists()).toBe(true)
|
|
expect(footer(wrapper).findAll('.bg-primary')).toHaveLength(1)
|
|
// The footer and its right cluster wrap; buttons never shrink or truncate.
|
|
expect(footer(wrapper).classes()).toContain('flex-wrap')
|
|
expect(footer(wrapper).find('div').classes()).toEqual(expect.arrayContaining(['ml-auto', 'flex-wrap']))
|
|
expect(buttons.every((item) => item.classes().includes('whitespace-nowrap') && item.classes().includes('shrink-0'))).toBe(true)
|
|
|
|
await edit.trigger('click')
|
|
await flushPromises()
|
|
expect(router.currentRoute.value.path).toBe('/acme/roster/people/1')
|
|
})
|
|
|
|
it('shows only the edit button when no record action is offered', async () => {
|
|
const { wrapper } = await mountApp(
|
|
PREVIEW,
|
|
routes({ [`GET ${RECORD}`]: { body: recordWith((record) => delete record.meta.actions) } }),
|
|
)
|
|
expect(wrapper.find('[data-record-action]').exists()).toBe(false)
|
|
expect(footer(wrapper).findAll('button').map((item) => item.attributes('data-action'))).toEqual(['edit'])
|
|
})
|
|
|
|
it('after an action: a success toast, the record and the hint reloaded in place', async () => {
|
|
let shows = 0
|
|
const after = recordWith((record) => {
|
|
record.data.email = 'ada@new.example.test'
|
|
record.meta.actions = [{ name: 'reinstate', label: 'Reinstate' }]
|
|
})
|
|
const { wrapper, calls } = await mountApp(
|
|
PREVIEW,
|
|
routes({
|
|
[`GET ${RECORD}`]: () => ({ body: shows++ === 0 ? rosterRecordFixture : after }),
|
|
[`POST ${RECORD}/actions/activate`]: { body: { data: { message: 'The person was activated.', fill: {} }, meta: {} } },
|
|
}),
|
|
{ attach: true },
|
|
)
|
|
await wrapper.find('[data-record-action="activate"]').trigger('click')
|
|
await flushPromises()
|
|
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
|
await flushPromises()
|
|
// The previous content stays while the record reloads: no skeleton flash.
|
|
expect(wrapper.find('[data-preview] dl').exists()).toBe(true)
|
|
expect(wrapper.find('[data-partial-skeleton]').exists()).toBe(false)
|
|
await settle()
|
|
|
|
expect(document.body.textContent).toContain(
|
|
'The person was activated.',
|
|
)
|
|
expect(value(wrapper, 'email').text()).toBe('ada@new.example.test')
|
|
expect(wrapper.findAll('[data-record-action]').map((item) => item.attributes('data-record-action'))).toEqual(['reinstate'])
|
|
expect(requestsTo(calls, 'GET', RECORD)).toHaveLength(2)
|
|
expect(requestsTo(calls, 'GET', `${LIST}/partials/status`)).toHaveLength(2)
|
|
})
|
|
|
|
it('disables every footer button while an action runs', async () => {
|
|
let release: ((reply: Reply) => void) | undefined
|
|
const { wrapper } = await mountApp(
|
|
PREVIEW,
|
|
routes({
|
|
[`POST ${RECORD}/actions/activate`]: () =>
|
|
new Promise<Reply>((resolve) => {
|
|
release = resolve
|
|
}),
|
|
}),
|
|
{ attach: true },
|
|
)
|
|
await wrapper.find('[data-record-action="activate"]').trigger('click')
|
|
await flushPromises()
|
|
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
|
await flushPromises()
|
|
expect(footer(wrapper).findAll('button').every((item) => item.attributes('disabled') !== undefined)).toBe(true)
|
|
release?.({ body: { data: { message: '', fill: {} }, meta: {} } })
|
|
await settle()
|
|
expect(wrapper.find('[data-action="edit"]').attributes('disabled')).toBeUndefined()
|
|
})
|
|
|
|
it('reloads the record and the hint when an action no longer applies', async () => {
|
|
const { wrapper, calls } = await mountApp(
|
|
PREVIEW,
|
|
routes({
|
|
[`POST ${RECORD}/actions/activate`]: { status: 409, body: { error: { code: 'conflict', message: 'Conflict', details: {} } } },
|
|
}),
|
|
{ attach: true },
|
|
)
|
|
await wrapper.find('[data-record-action="activate"]').trigger('click')
|
|
await flushPromises()
|
|
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
|
await settle()
|
|
expect(requestsTo(calls, 'GET', RECORD)).toHaveLength(2)
|
|
expect(requestsTo(calls, 'GET', `${LIST}/partials/status`)).toHaveLength(2)
|
|
expect(document.body.textContent).toContain('This action no longer applies to this record.')
|
|
})
|
|
|
|
it('turns into the load failure when the record is gone', async () => {
|
|
const { wrapper } = await mountApp(
|
|
PREVIEW,
|
|
routes({
|
|
[`POST ${RECORD}/actions/activate`]: { status: 404, body: { error: { code: 'not_found', message: 'Not found', details: {} } } },
|
|
}),
|
|
{ attach: true },
|
|
)
|
|
await wrapper.find('[data-record-action="activate"]').trigger('click')
|
|
await flushPromises()
|
|
dialog()!.querySelector<HTMLButtonElement>('[data-action="confirm"]')!.click()
|
|
await settle()
|
|
expect(wrapper.find('[role="alert"]').text()).toBe('The form could not be loaded.')
|
|
expect(wrapper.find('[data-record-action], [data-action="edit"]').exists()).toBe(false)
|
|
expect(wrapper.find('[data-action="back"]').exists()).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('preview URLs (T-12.1-16)', () => {
|
|
const id = 'acme.roster.people'
|
|
|
|
it('maps preview/:id onto the preview route of the current controller only', () => {
|
|
expect(mapWinterUrl('acme/roster/people/preview/:id', id, 42)).toBe('/acme/roster/people/42/preview')
|
|
expect(mapWinterUrl('acme/roster/people/preview/7', id)).toBe('/acme/roster/people/7/preview')
|
|
expect(mapWinterUrl('acme/roster/people/preview/:id', id, null)).toBe('/acme/roster/people')
|
|
expect(mapWinterUrl('acme/roster/people/preview/1e3', id)).toBe('/acme/roster/people')
|
|
expect(mapWinterUrl('acme/roster/people/preview/3/extra', id)).toBe('/acme/roster/people')
|
|
expect(mapWinterUrl('acme/other/people/preview/:id', id, 1)).toBe('/acme/roster/people')
|
|
expect(mapWinterUrl('https://evil.example/acme/roster/people/preview/1', id)).toBe('/acme/roster/people')
|
|
})
|
|
|
|
it('opens the preview from a list row whose recordUrl points at it', async () => {
|
|
const { wrapper, router } = await mountApp('/acme/roster/people', routes())
|
|
const link = wrapper.find('tbody a')
|
|
expect(link.attributes('href')).toMatch(/^\/acme\/roster\/people\/\d+\/preview$/)
|
|
await link.trigger('click')
|
|
await flushPromises()
|
|
expect(router.currentRoute.value.name).toBe('preview')
|
|
})
|
|
|
|
it('leads back to the preview from the update form of a form with a preview', async () => {
|
|
const { wrapper, router } = await mountApp('/acme/roster/people/1', routes())
|
|
const back = wrapper.find('[data-action="back"]')
|
|
expect(back.attributes('aria-label')).toBe('Back to preview')
|
|
await back.trigger('click')
|
|
await flushPromises()
|
|
expect(router.currentRoute.value.path).toBe(PREVIEW)
|
|
|
|
await router.push('/acme/roster/people/1')
|
|
await flushPromises()
|
|
await wrapper.find('[data-action="cancel"]').trigger('click')
|
|
await flushPromises()
|
|
expect(router.currentRoute.value.path).toBe(PREVIEW)
|
|
})
|
|
|
|
it('follows update.redirectClose to the preview after save and close', async () => {
|
|
const { wrapper, router } = await mountApp('/acme/roster/people/1', routes({ [`PUT ${RECORD}`]: { body: rosterRecordFixture } }))
|
|
await wrapper.find('[data-action="save-close"]').trigger('click')
|
|
await flushPromises()
|
|
expect(router.currentRoute.value.path).toBe(PREVIEW)
|
|
})
|
|
})
|