Files
summercms/admin/tests/form/PreviewView.test.ts
Jakub Zych a714bb4629 fix(admin): name records by translatable (mltext) fields
The record heading, breadcrumb crumb and document title took the first
plain text field only, so records named by an mltext field fell back to
the generic update message. A shared recordDisplayName helper now takes
the first text or ML field and reads its default locale, then the first
non-empty one.
2026-10-07 19:05:47 +02:00

297 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 { recordCrumb } from '../../src/state/useBreadcrumbs'
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('heads the record by the default locale of an mltext first field, and publishes it as the crumb', async () => {
const { wrapper } = await mountApp(
PREVIEW,
routes({
[`GET ${LIST}/schema/form`]: schemaWith((schema) => {
schema.meta.enabledLocales = ['en', 'pl']
schema.fields.find((field) => field.name === 'name')!.type = 'mltext'
}),
[`GET ${RECORD}`]: recordWith((record) => {
record.data.name = { en: 'Ada Lovelace', pl: 'Ada Lovelace PL' }
}),
}),
)
expect(wrapper.find('h1').text()).toBe('Ada Lovelace')
expect(recordCrumb.value).toBe('Ada Lovelace')
})
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')
})
})