// 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 = {}): Record { 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('[data-confirm-dialog]') async function runAction(wrapper: VueWrapper, name: string): Promise { await wrapper.find(`[data-record-action="${name}"]`).trigger('click') await flushPromises() dialog()!.querySelector('[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(' { 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((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') }) })