Files
summercms/admin/tests/smoke/preview.smoke.test.ts
Jakub Zych df5cace852 feat(12.1-02): writable foreign keys, locked relation options, invisible columns
- FieldRelationContract.WritableForeignKey makes a belongsTo field over a
  protected foreign key writable; the protected key list is unchanged
- cabana.RelationLockProvider names related ids an administrator may not add
  or remove: options and labels carry locked, and a create or update that
  changes the locked subset is 403 before any row is written
- columns.yaml invisible keeps a column searchable and out of the rows
- a controller implementing pact.FilterOptions serves a scope filter's
  choices before the model
- SPA: locked chips and options in RelationField, DataTable skips invisible
  columns
- README, docs, OpenAPI document, TS types and dist updated
2026-10-05 10:58:38 +02:00

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', '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<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)
})
})