// 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 = {}): Record { 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('[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 { 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', 'Team', 'Tags', '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((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('[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((resolve) => { release = resolve }), }), { attach: true }, ) await wrapper.find('[data-record-action="activate"]').trigger('click') await flushPromises() dialog()!.querySelector('[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('[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('[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) }) })