feat(12.1-02): read-only preview screen with a status hint and record actions
- config_form.yaml preview block (optional headerPartial), reported in the form schema as preview
- fields with context: preview show only on the preview screen and are never written
- form messages preview and edit; recordActions without a preview block stops boot
- SPA route {id}/preview, PreviewView and PreviewField, record actions in the footer
- mapWinterUrl maps preview/:id; the update form returns to the preview
- summer-callout partial style classes for status hints
- README, docs, OpenAPI document, TS types and the embedded build updated
This commit is contained in:
@@ -24,6 +24,14 @@ describe('mapWinterUrl', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/9', ID)).toBe(`${LIST}/9`)
|
||||
})
|
||||
|
||||
it('substitutes :id in preview/:id and falls back to the list without a usable id (D-11)', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/:id', ID, 42)).toBe(`${LIST}/42/preview`)
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/9', ID)).toBe(`${LIST}/9/preview`)
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/:id', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/abc', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/gadgets/preview/:id', ID, 1)).toBe(LIST)
|
||||
})
|
||||
|
||||
it('falls back to the list when update has no usable id', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', ID, null)).toBe(LIST)
|
||||
@@ -41,7 +49,7 @@ describe('mapWinterUrl', () => {
|
||||
it('ignores the query and hash, matches the owner case-insensitively and rejects unknown actions', () => {
|
||||
expect(mapWinterUrl('acme/demo/widgets/create?x=1#top', ID)).toBe(`${LIST}/create`)
|
||||
expect(mapWinterUrl('Acme/Demo/Widgets/update/:id', ID, 3)).toBe(`${LIST}/3`)
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/3', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/history/3', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/3/extra', ID)).toBe(LIST)
|
||||
expect(mapWinterUrl('acme/demo/widgets/CREATE', ID)).toBe(LIST)
|
||||
})
|
||||
|
||||
@@ -62,6 +62,12 @@
|
||||
},
|
||||
"deleted": {
|
||||
"other": "Gadget deleted"
|
||||
},
|
||||
"preview": {
|
||||
"other": "Record preview"
|
||||
},
|
||||
"edit": {
|
||||
"other": "Edit record"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
},
|
||||
"deleted": {
|
||||
"other": "Widget deleted"
|
||||
},
|
||||
"preview": {
|
||||
"other": "Record preview"
|
||||
},
|
||||
"edit": {
|
||||
"other": "Edit record"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
|
||||
29
admin/tests/fixtures/roster.form-schema.json
vendored
Normal file
29
admin/tests/fixtures/roster.form-schema.json
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"data": {
|
||||
"name": "Person",
|
||||
"modelClass": "Person",
|
||||
"fields": [
|
||||
{ "name": "name", "type": "text", "label": "Name", "span": "left" },
|
||||
{ "name": "email", "type": "text", "label": "Email", "span": "right" },
|
||||
{ "name": "joined_ip", "type": "text", "label": "Joined from IP address", "context": "preview" }
|
||||
],
|
||||
"preview": { "headerPartial": "status" },
|
||||
"messages": {
|
||||
"create": { "other": "Person" },
|
||||
"update": { "other": "Edit record" },
|
||||
"saved": { "other": "Saved" },
|
||||
"deleteConfirm": { "other": "Delete this record?" },
|
||||
"deleted": { "other": "Record deleted" },
|
||||
"preview": { "other": "Person details" },
|
||||
"edit": { "other": "Edit person" }
|
||||
},
|
||||
"redirects": {
|
||||
"default": "acme/roster/people",
|
||||
"create": { "redirect": "acme/roster/people/preview/:id", "redirectClose": "acme/roster/people" },
|
||||
"update": { "redirect": "acme/roster/people", "redirectClose": "acme/roster/people/preview/:id" }
|
||||
},
|
||||
"meta": { "locale": "en" },
|
||||
"assets": { "scripts": [], "styles": [] }
|
||||
},
|
||||
"meta": { "locale": "en" }
|
||||
}
|
||||
2
admin/tests/fixtures/roster.list-schema.json
vendored
2
admin/tests/fixtures/roster.list-schema.json
vendored
@@ -8,7 +8,7 @@
|
||||
"showCheckboxes": true,
|
||||
"showSorting": true,
|
||||
"searchTerm": "search",
|
||||
"recordUrl": "acme/roster/people/update/:id",
|
||||
"recordUrl": "acme/roster/people/preview/:id",
|
||||
"toolbarButtons": ["create", "delete"],
|
||||
"toolbarActions": [],
|
||||
"assets": { "scripts": [], "styles": [] },
|
||||
|
||||
2
admin/tests/fixtures/roster.record.json
vendored
2
admin/tests/fixtures/roster.record.json
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test" },
|
||||
"data": { "id": 1, "name": "Ada Lovelace", "email": "ada@example.test", "joined_ip": "203.0.113.7" },
|
||||
"meta": {
|
||||
"labels": {},
|
||||
"actions": [
|
||||
|
||||
6
admin/tests/fixtures/settings.json
vendored
6
admin/tests/fixtures/settings.json
vendored
@@ -70,6 +70,12 @@
|
||||
},
|
||||
"deleted": {
|
||||
"other": "Deleted"
|
||||
},
|
||||
"preview": {
|
||||
"other": "Record preview"
|
||||
},
|
||||
"edit": {
|
||||
"other": "Edit record"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
|
||||
3
admin/tests/fixtures/typed.ts
vendored
3
admin/tests/fixtures/typed.ts
vendored
@@ -18,6 +18,7 @@ import listJson from './widgets.list.json'
|
||||
import listSchemaJson from './widgets.list-schema.json'
|
||||
import optionsJson from './widgets.options.json'
|
||||
import rosterListJson from './roster.list.json'
|
||||
import rosterFormSchemaJson from './roster.form-schema.json'
|
||||
import rosterListSchemaJson from './roster.list-schema.json'
|
||||
import rosterRecordJson from './roster.record.json'
|
||||
import recordJson from './widgets.record.json'
|
||||
@@ -59,6 +60,8 @@ export const extensionPartialFixture = extensionPartialJson as S['cabana.Envelop
|
||||
export const listFixture: Rows = listJson
|
||||
/** A people list with declared bulk actions (Phase 12.1). */
|
||||
export const rosterListSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = rosterListSchemaJson
|
||||
/** The people form: a preview block with a status hint and a preview-only field (Phase 12.1). */
|
||||
export const rosterFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = rosterFormSchemaJson
|
||||
/** One person with two offered record actions (Phase 12.1). */
|
||||
export const rosterRecordFixture: S['cabana.RecordEnvelope'] = rosterRecordJson
|
||||
/** The people of the roster list (Phase 12.1). */
|
||||
|
||||
@@ -138,6 +138,12 @@
|
||||
},
|
||||
"deleted": {
|
||||
"other": "Widget deleted"
|
||||
},
|
||||
"preview": {
|
||||
"other": "Record preview"
|
||||
},
|
||||
"edit": {
|
||||
"other": "Edit record"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
|
||||
@@ -489,7 +489,7 @@ describe('row state (UI-SPEC S4, D-12)', () => {
|
||||
expect(row.find('[data-row-state]').exists()).toBe(false)
|
||||
const link = firstCell(wrapper, 0).find('a')
|
||||
expect(link.classes()).toEqual(expect.arrayContaining(['text-text', 'no-underline']))
|
||||
expect(link.attributes('href')).toBe('/acme/roster/people/1')
|
||||
expect(link.attributes('href')).toBe('/acme/roster/people/1/preview')
|
||||
expect(firstCell(wrapper, 0).classes()).not.toContain('text-muted')
|
||||
})
|
||||
|
||||
@@ -507,7 +507,7 @@ describe('row state (UI-SPEC S4, D-12)', () => {
|
||||
const link = cell.find('a')
|
||||
expect(link.classes()).toEqual(expect.arrayContaining(['line-through', 'text-danger', 'min-w-0', 'truncate']))
|
||||
expect(link.find('[data-row-state]').exists()).toBe(false)
|
||||
expect(link.attributes('href')).toBe('/acme/roster/people/2')
|
||||
expect(link.attributes('href')).toBe('/acme/roster/people/2/preview')
|
||||
expect(cell.classes()).toEqual(expect.arrayContaining(['font-semibold', 'text-muted']))
|
||||
expect(row.findAll('td:not([data-select])')[1]!.classes()).toContain('text-muted')
|
||||
const deleted = cell.find('[data-row-state="deleted"]')
|
||||
|
||||
@@ -68,7 +68,8 @@ describe('winter URLs', () => {
|
||||
expect(mapWinterUrl('/acme/demo/widgets/create', id)).toBe('/acme/demo/widgets/create')
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', id, 42)).toBe('/acme/demo/widgets/42')
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', id, null)).toBe('/acme/demo/widgets')
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/:id', id, 42)).toBe('/acme/demo/widgets')
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/:id', id, 42)).toBe('/acme/demo/widgets/42/preview')
|
||||
expect(mapWinterUrl('acme/demo/widgets/history/:id', id, 42)).toBe('/acme/demo/widgets')
|
||||
expect(mapWinterUrl('https://evil.example/acme/demo/widgets', id, 1)).toBe('/acme/demo/widgets')
|
||||
expect(mapWinterUrl('acme/other/things/update/:id', id, 1)).toBe('/acme/demo/widgets')
|
||||
expect(mapWinterUrl('', id)).toBe('/acme/demo/widgets')
|
||||
|
||||
369
admin/tests/smoke/preview.smoke.test.ts
Normal file
369
admin/tests/smoke/preview.smoke.test.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
// 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', '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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user