Files
summercms/admin/tests/form/FormView.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

665 lines
31 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import FormView from '../../src/views/FormView.vue'
import { recordCrumb } from '../../src/state/useBreadcrumbs'
import { toasts } from '../../src/state/useToasts'
import { WIDGET_EVENT } from '../../src/components/form/formContext'
import {
clone,
extensionFormSchemaFixture,
formSchemaFixture,
langFixture,
recordFixture,
rosterFormSchemaFixture,
rosterRecordFixture,
} from '../fixtures/typed'
import { API, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const RECORD = `${BASE}/1`
const failure = (status: number, message: string) => ({ status, body: { error: { code: 'x', message, details: {} } } })
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/form`]: { body: formSchemaFixture },
[`GET ${RECORD}`]: { body: recordFixture },
...overrides,
}
}
/** A form schema of plain, untabbed fields. */
function flatSchema() {
const envelope = clone(formSchemaFixture)
envelope.data.fields = envelope.data.fields.filter((field) => ['name', 'code', 'weight'].includes(field.name))
return { body: envelope }
}
beforeEach(() => {
resetState()
})
afterEach(() => {
document.body.innerHTML = ''
})
enableAutoUnmount(afterEach)
describe('form view', () => {
it('is the component of the record and create routes', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
expect(wrapper.findComponent(FormView).exists()).toBe(true)
})
it('titles a record by its first text field, with the update message as subtitle, and publishes the crumb', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
expect(wrapper.find('h1').text()).toBe('Blue widget')
expect(wrapper.find('header p').text()).toBe('Editing widget')
expect(recordCrumb.value).toBe('Blue widget')
wrapper.unmount()
expect(recordCrumb.value).toBe('')
})
it('titles a new record by the create message and offers no delete', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/create', routes())
expect(wrapper.find('h1').text()).toBe('New widget')
expect(wrapper.find('header p').exists()).toBe(false)
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
expect(recordCrumb.value).toBe('New widget')
})
it('makes the form the tab panel of the active tab', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
const form = wrapper.find('form')
expect(form.attributes('role')).toBe('tabpanel')
expect(form.attributes('id')).toBe('field-panel-0')
expect(form.attributes('aria-labelledby')).toBe('field-tab-0')
expect(wrapper.find('#field-tab-0').attributes('aria-controls')).toBe('field-panel-0')
})
it('has no tabs and no tab panel role for an untabbed form', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema() }))
expect(wrapper.find('[role="tablist"]').exists()).toBe(false)
expect(wrapper.find('form').attributes('role')).toBeUndefined()
expect(wrapper.findAll('[data-field]').map((field) => field.attributes('data-field'))).toEqual(['name', 'code', 'weight'])
})
it.each([
['the record', { [`GET ${RECORD}`]: failure(404, 'Not found') }],
['the schema', { [`GET ${BASE}/schema/form`]: failure(500, 'Boom') }],
])('announces a failed load of %s and disables saving', async (_label, override) => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes(override))
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać formularza.')
expect(wrapper.find('form').exists()).toBe(false)
if (_label === 'the schema') {
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeDefined()
}
})
it('toasts the generic error when the save cannot reach the server and re-enables saving', async () => {
const { wrapper, calls } = await mountApp(
'/acme/demo/widgets/1',
routes({
[`PUT ${RECORD}`]: () => {
throw new TypeError('offline')
},
}),
)
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeUndefined()
expect(wrapper.find('[data-action="save"]').text()).toBe('Zapisz')
})
it('toasts the server message when a delete is refused and stays on the record', async () => {
const { wrapper, router } = await mountApp(
'/acme/demo/widgets/1',
routes({ [`DELETE ${RECORD}`]: failure(409, 'Widget is in use') }),
{ attach: true },
)
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
await flushPromises()
expect(toasts.value[0]).toMatchObject({ text: 'Widget is in use', tone: 'danger' })
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/1')
})
it('keeps a create on the form when the save fails', async () => {
const { wrapper, router } = await mountApp(
'/acme/demo/widgets/create',
routes({ [`POST ${BASE}`]: failure(500, 'Boom') }),
)
await wrapper.find('#field-name').setValue('New one')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/create')
expect(toasts.value[0]).toMatchObject({ text: 'Boom', tone: 'danger' })
})
it('follows the default redirect when the form declares no create redirect', async () => {
const envelope = clone(formSchemaFixture)
envelope.data.redirects.create.redirect = ''
envelope.data.redirects.default = 'acme/demo/widgets/update/:id'
const { wrapper, router } = await mountApp(
'/acme/demo/widgets/create',
routes({
[`GET ${BASE}/schema/form`]: { body: envelope },
[`POST ${BASE}`]: { status: 201, body: { data: { id: 5, name: 'X' }, meta: { labels: {} } } },
[`GET ${BASE}/5`]: { body: { data: { id: 5, name: 'X' }, meta: { labels: {} } } },
}),
)
await wrapper.find('#field-name').setValue('X')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/5')
})
})
describe('form view extension context (Phase 10.1, D-05, D-07, D-08, D-09, D-14)', () => {
const ACTION = `${BASE}/widgets/lookup`
const SUMMARY = `${BASE}/partials/summary`
const record = { data: { id: 1, name: 'Blue widget', color: '#0000ff' }, meta: { labels: {} } }
const summary = { data: { nodes: [{ tag: 'p', children: [{ text: 'Summary' }] }] }, meta: {} }
class AcmeDemoLookup extends HTMLElement {}
if (!customElements.get('acme-demo-lookup')) {
customElements.define('acme-demo-lookup', AcmeDemoLookup)
}
/** Records appended plugin scripts; each loads when the test fires it (or at once). */
function stubHead(autoload = true): HTMLScriptElement[] {
const scripts: HTMLScriptElement[] = []
const head = document.head
const append = head.appendChild.bind(head)
vi.spyOn(head, 'appendChild').mockImplementation(<T extends Node>(node: T): T => {
if (node instanceof HTMLScriptElement) {
scripts.push(node)
if (autoload) {
queueMicrotask(() => node.dispatchEvent(new Event('load')))
}
return node
}
if (node instanceof HTMLLinkElement) {
return node
}
return append(node)
})
return scripts
}
function extensionRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/form`]: { body: extensionFormSchemaFixture },
[`GET ${RECORD}`]: { body: record },
[`GET ${SUMMARY}`]: { body: summary },
...overrides,
}
}
function widget(root: Element): HTMLElement {
const element = root.querySelector('acme-demo-lookup')
if (!(element instanceof HTMLElement)) {
throw new Error('widget element not mounted')
}
return element
}
function beforeUnloadPrevented(): boolean {
const event = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(event)
return event.defaultPrevented
}
it('provides the values and the schema locale to widgets', async () => {
stubHead()
const { wrapper } = await mountApp('/acme/demo/widgets/1', extensionRoutes())
await flushPromises()
const element = widget(wrapper.element)
expect(element.getAttribute('fill-values')).toBe('{"name":"Blue widget"}')
// The UI speaks Polish; the widget gets the schema's locale.
expect(element.getAttribute('locale')).toBe('en')
await wrapper.find('#field-name').setValue('Green widget')
expect(element.getAttribute('fill-values')).toBe('{"name":"Green widget"}')
})
it('falls back to the UI locale when the schema has none', async () => {
stubHead()
const schema = clone(extensionFormSchemaFixture)
schema.data.meta.locale = ''
const { wrapper } = await mountApp('/acme/demo/widgets/1', extensionRoutes({ [`GET ${BASE}/schema/form`]: { body: schema } }))
await flushPromises()
expect(widget(wrapper.element).getAttribute('locale')).toBe('pl')
})
it('patches like an edit: the form turns dirty and the field error clears', async () => {
stubHead()
const { wrapper, calls } = await mountApp(
'/acme/demo/widgets/1',
extensionRoutes({
[`PUT ${RECORD}`]: {
status: 422,
body: { error: { code: 'validation_failed', message: 'Validation failed', details: { name: ['Name is taken.'] } } },
},
[`POST ${ACTION}`]: { body: { data: { message: 'Name filled in', fill: { name: 'Catalogue widget' } }, meta: {} } },
}),
)
await flushPromises()
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
expect(wrapper.find('#field-name-error').text()).toContain('Name is taken.')
expect(beforeUnloadPrevented()).toBe(false)
widget(wrapper.element).dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true }))
await flushPromises()
expect((wrapper.find('#field-name').element as HTMLInputElement).value).toBe('Catalogue widget')
expect(wrapper.find('#field-name-error').exists()).toBe(false)
expect(beforeUnloadPrevented()).toBe(true)
// Nothing was saved by the action itself.
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
})
it('renders widgets and partials on create', async () => {
stubHead()
const { wrapper, calls } = await mountApp('/acme/demo/widgets/create', extensionRoutes())
await flushPromises()
const element = widget(wrapper.element)
expect(element.getAttribute('record-id')).toBe('')
const [partial] = requestsTo(calls, 'GET', SUMMARY)
expect(queryOf(partial!).has('id')).toBe(false)
expect(wrapper.find('[data-partial-field] [data-partial="field"]').text()).toBe('Summary')
expect(requestsTo(calls, 'GET', RECORD)).toHaveLength(0)
})
it('loads the controller assets when the schema arrives and mounts the widget after its script', async () => {
const scripts = stubHead(false)
// A fresh URL: the loader remembers scripts across the tests of this file.
const fresh = '/admin-test/assets/acme/demo/js/form.js?v=1'
const schema = clone(extensionFormSchemaFixture)
schema.data.assets.scripts = [fresh]
let release: (reply: Reply) => void = () => undefined
const pending = new Promise<Reply>((resolve) => (release = resolve))
const { wrapper } = await mountApp('/acme/demo/widgets/1', extensionRoutes({ [`GET ${BASE}/schema/form`]: () => pending }))
expect(scripts).toHaveLength(0)
release({ body: schema })
await flushPromises()
expect(scripts.map((script) => script.getAttribute('src'))).toEqual([fresh])
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBe('true')
expect(wrapper.element.querySelector('acme-demo-lookup')).toBeNull()
scripts[0]!.dispatchEvent(new Event('load'))
await flushPromises()
expect(widget(wrapper.element).getAttribute('record-id')).toBe('1')
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBeUndefined()
})
})
describe('forbidden save on the form (UI-SPEC S6, D-27)', () => {
const FALLBACK = 'You do not have permission to make this change. Nothing was saved.'
const strings = {
'backend::lang.form.forbidden': { other: FALLBACK },
'backend::lang.form.error_title': { other: 'The record could not be saved.' },
'backend::lang.form.error_fields': { one: 'Correct :count field.', other: 'Correct :count fields.' },
}
type Mounted = Awaited<ReturnType<typeof mountApp>>['wrapper']
const refused = (message: string, details: Record<string, string[]> = {}): Reply => ({
status: 403,
body: { error: { code: 'forbidden', message, details } },
})
const banner = (wrapper: Mounted) => wrapper.find('[data-forbidden-banner]')
const name = (wrapper: Mounted) => wrapper.find<HTMLInputElement>('#field-name')
const code = (wrapper: Mounted) => wrapper.find<HTMLInputElement>('#field-code')
async function save(wrapper: Mounted): Promise<void> {
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
}
beforeEach(() => {
setBundle({ ...langFixture.data, ...strings }, 'en')
})
it('shows the banner with the server message, or the framework text without one', async () => {
const withMessage = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('Only makers may rename widgets.') }))
await name(withMessage.wrapper).setValue('Renamed')
await save(withMessage.wrapper)
expect(banner(withMessage.wrapper).attributes('role')).toBe('alert')
expect(banner(withMessage.wrapper).text()).toBe('Only makers may rename widgets.')
expect(toasts.value).toHaveLength(0)
withMessage.wrapper.unmount()
const bare = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('') }))
await name(bare.wrapper).setValue('Renamed')
await save(bare.wrapper)
expect(banner(bare.wrapper).text()).toBe(FALLBACK)
expect(bare.wrapper.find('[aria-invalid="true"]').exists()).toBe(false)
})
it('puts details on their fields, focuses the first one and shows no 422 banner', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets/1',
routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('Refused.', { code: ['This code is reserved.'], name: ['This name is reserved.'] }) }),
{ attach: true },
)
await code(wrapper).setValue('W-99')
await save(wrapper)
expect(banner(wrapper).text()).toBe('Refused.')
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
expect(name(wrapper).attributes('aria-invalid')).toBe('true')
expect(code(wrapper).attributes('aria-invalid')).toBe('true')
expect(wrapper.text()).toContain('This name is reserved.')
expect(wrapper.text()).toContain('This code is reserved.')
// name comes first in the form, so it takes the focus.
expect(document.activeElement).toBe(name(wrapper).element)
})
it('keeps the typed values and the dirty state: nothing was saved', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`PUT ${RECORD}`]: refused('Refused.') }), { attach: true })
await name(wrapper).setValue('Typed by hand')
await save(wrapper)
expect(name(wrapper).element.value).toBe('Typed by hand')
expect(requestsTo(calls, 'GET', RECORD)).toHaveLength(1)
// Leaving still asks, because the form is dirty.
await wrapper.find('[data-action="cancel"]').trigger('click')
await flushPromises()
expect(document.body.querySelector('[data-confirm-dialog]')).not.toBeNull()
})
it('clears the banner when the next save starts, and shows nothing after a save that succeeds', async () => {
let attempt = 0
const saved = clone(recordFixture)
saved.data.name = 'Second try'
const { wrapper } = await mountApp(
'/acme/demo/widgets/1',
routes({
[`GET ${BASE}/schema/form`]: flatSchema(),
[`PUT ${RECORD}`]: () => {
attempt += 1
return attempt === 1 ? refused('Refused.', { name: ['Reserved.'] }) : { body: saved }
},
}),
)
await name(wrapper).setValue('First try')
await save(wrapper)
expect(banner(wrapper).exists()).toBe(true)
// Editing does not dismiss it: only the next save attempt does.
await name(wrapper).setValue('Second try')
expect(banner(wrapper).exists()).toBe(true)
await save(wrapper)
expect(attempt).toBe(2)
expect(banner(wrapper).exists()).toBe(false)
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
expect(name(wrapper).attributes('aria-invalid')).toBeUndefined()
})
it('replaces the banner with the 422 banner when the next save fails validation', async () => {
let attempt = 0
const { wrapper } = await mountApp(
'/acme/demo/widgets/1',
routes({
[`GET ${BASE}/schema/form`]: flatSchema(),
[`PUT ${RECORD}`]: () => {
attempt += 1
return attempt === 1
? refused('Refused.')
: { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { name: ['Too short.'] } } } }
},
}),
)
await name(wrapper).setValue('x')
await save(wrapper)
expect(banner(wrapper).exists()).toBe(true)
await save(wrapper)
expect(banner(wrapper).exists()).toBe(false)
expect(wrapper.find('[data-error-banner]').text()).toContain('Correct 1 field.')
})
it('refuses a create the same way and stays on the create route', async () => {
const { wrapper, router } = await mountApp('/acme/demo/widgets/create', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`POST ${BASE}`]: refused('You may not add widgets.') }))
await name(wrapper).setValue('Brand new')
await save(wrapper)
expect(banner(wrapper).text()).toBe('You may not add widgets.')
expect(router.currentRoute.value.name).toBe('create')
expect(name(wrapper).element.value).toBe('Brand new')
})
it('shows a refused delete as a danger toast, not as the banner, and stays on the record', async () => {
const { wrapper, router } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema(), [`DELETE ${RECORD}`]: refused('This widget is in use.') }), { attach: true })
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
await flushPromises()
expect(toasts.value.map((toast) => [toast.text, toast.tone])).toEqual([['This widget is in use.', 'danger']])
expect(banner(wrapper).exists()).toBe(false)
expect(router.currentRoute.value.name).toBe('record')
})
})
describe('password and preset on the form (UI-SPEC S7, D-19, D-27 G7)', () => {
const PEOPLE = `${API}/acme/roster/people`
const PERSON = `${PEOPLE}/1`
type Mounted = Awaited<ReturnType<typeof mountApp>>['wrapper']
const field = (wrapper: Mounted, name: string) => wrapper.find<HTMLInputElement>(`#field-${name}`)
function people(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${PEOPLE}/schema/form`]: { body: rosterFormSchemaFixture },
[`GET ${PERSON}`]: { body: rosterRecordFixture },
[`GET ${PEOPLE}/partials/status`]: { body: { data: { nodes: [] }, meta: {} } },
...overrides,
}
}
async function save(wrapper: Mounted): Promise<void> {
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
}
beforeEach(() => {
setBundle(
{ ...langFixture.data, 'backend::lang.form.show_password': { other: 'Show password' }, 'backend::lang.form.hide_password': { other: 'Hide password' } },
'en',
)
})
it('loads both password fields empty and masked, and puts no password into the page', async () => {
// The guarantee is the server's: a record response never carries a
// password (the framework test T-12.1-10). The form adds none of its own.
const { wrapper } = await mountApp('/acme/roster/people/1', people())
for (const name of ['password', 'password_confirmation']) {
expect(field(wrapper, name).attributes('type')).toBe('password')
expect(field(wrapper, name).element.value).toBe('')
expect(field(wrapper, name).attributes('autocomplete')).toBe('new-password')
}
expect('password' in rosterRecordFixture.data).toBe(false)
})
it('leaves the password out of an update that does not type one', async () => {
const { wrapper, calls } = await mountApp('/acme/roster/people/1', people({ [`PUT ${PERSON}`]: { body: rosterRecordFixture } }))
await field(wrapper, 'name').setValue('Ada King')
await save(wrapper)
const body = (await requestsTo(calls, 'PUT', PERSON)[0]!.clone().json()) as Record<string, unknown>
expect(body.name).toBe('Ada King')
expect('password' in body).toBe(false)
expect('password_confirmation' in body).toBe(false)
})
it('sends the typed pair and clears and hides both fields after the save', async () => {
const { wrapper, calls } = await mountApp('/acme/roster/people/1', people({ [`PUT ${PERSON}`]: { body: rosterRecordFixture } }))
await field(wrapper, 'password').setValue('new-secret-1')
await field(wrapper, 'password_confirmation').setValue('new-secret-1')
await wrapper.find('[data-field="password"] [data-password-toggle]').trigger('click')
expect(field(wrapper, 'password').attributes('type')).toBe('text')
await save(wrapper)
const body = (await requestsTo(calls, 'PUT', PERSON)[0]!.clone().json()) as Record<string, unknown>
expect(body.password).toBe('new-secret-1')
expect(body.password_confirmation).toBe('new-secret-1')
for (const name of ['password', 'password_confirmation']) {
expect(field(wrapper, name).element.value).toBe('')
expect(field(wrapper, name).attributes('type')).toBe('password')
}
// A second save sends no password again.
await field(wrapper, 'name').setValue('Ada King')
await save(wrapper)
const second = (await requestsTo(calls, 'PUT', PERSON)[1]!.clone().json()) as Record<string, unknown>
expect('password' in second).toBe(false)
})
it('keeps the typed password when the save is refused', async () => {
const { wrapper } = await mountApp(
'/acme/roster/people/1',
people({ [`PUT ${PERSON}`]: { status: 403, body: { error: { code: 'forbidden', message: 'Refused.', details: { password: ['Not allowed.'] } } } } }),
)
await field(wrapper, 'password').setValue('new-secret-1')
await field(wrapper, 'password_confirmation').setValue('new-secret-1')
await save(wrapper)
expect(wrapper.find('[data-forbidden-banner]').text()).toBe('Refused.')
expect(field(wrapper, 'password').element.value).toBe('new-secret-1')
expect(field(wrapper, 'password').attributes('aria-invalid')).toBe('true')
})
it('on create, sends a password that was typed and cleared as empty, and leaves an untouched one to the server rules', async () => {
const invalid = { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { password: ['The password is required.'] } } } }
const { wrapper, calls } = await mountApp('/acme/roster/people/create', people({ [`POST ${PEOPLE}`]: invalid }))
await field(wrapper, 'name').setValue('Grace Hopper')
await save(wrapper)
const untouched = (await requestsTo(calls, 'POST', PEOPLE)[0]!.clone().json()) as Record<string, unknown>
expect(untouched.password ?? '').toBe('')
expect(wrapper.text()).toContain('The password is required.')
expect(field(wrapper, 'password').attributes('aria-invalid')).toBe('true')
await field(wrapper, 'password').setValue('typed')
await field(wrapper, 'password').setValue('')
await save(wrapper)
const cleared = (await requestsTo(calls, 'POST', PEOPLE)[1]!.clone().json()) as Record<string, unknown>
expect(cleared.password).toBe('')
})
it('fills a preset target on create only, and stops at the first manual edit', async () => {
const create = await mountApp('/acme/roster/people/create', people())
await field(create.wrapper, 'name').setValue('Zażółć gęślą')
expect(field(create.wrapper, 'slug').element.value).toBe('za-g-l')
await field(create.wrapper, 'slug').setValue('by-hand')
await field(create.wrapper, 'name').setValue('Something Else')
expect(field(create.wrapper, 'slug').element.value).toBe('by-hand')
// Clearing the target by hand is an edit too: it stays empty.
await field(create.wrapper, 'slug').setValue('')
await field(create.wrapper, 'name').setValue('Third Name')
expect(field(create.wrapper, 'slug').element.value).toBe('')
create.wrapper.unmount()
const update = await mountApp('/acme/roster/people/1', people())
await field(update.wrapper, 'name').setValue('Ada King')
expect(field(update.wrapper, 'slug').element.value).toBe('ada-lovelace')
})
/** The roster schema with en/pl content locales and name/slug retyped. */
function mlSchema(nameType: string, slugType: string) {
const envelope = clone(rosterFormSchemaFixture)
envelope.data.meta.enabledLocales = ['en', 'pl']
for (const item of envelope.data.fields) {
if (item.name === 'name') {
item.type = nameType
} else if (item.name === 'slug') {
item.type = slugType
}
}
return { body: envelope }
}
const editor = (wrapper: Mounted, name: string) => wrapper.find<HTMLInputElement>(`#field-${name}-editor`)
const localeSelect = (wrapper: Mounted, name: string) => wrapper.find<HTMLSelectElement>(`[data-field="${name}"] [data-ml-locale]`)
const invalidCreate = { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: {} } } }
it('follows an mltext source per locale on create, and stops only a locale edited by hand', async () => {
const { wrapper, calls } = await mountApp(
'/acme/roster/people/create',
people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('mltext', 'mltext'), [`POST ${PEOPLE}`]: invalidCreate }),
)
await editor(wrapper, 'name').setValue('Zażółć gęślą')
expect(editor(wrapper, 'slug').element.value).toBe('za-g-l')
await localeSelect(wrapper, 'name').setValue('pl')
await editor(wrapper, 'name').setValue('Witaj Świecie')
expect(localeSelect(wrapper, 'slug').element.value).toBe('pl')
expect(editor(wrapper, 'slug').element.value).toBe('witaj-wiecie')
await localeSelect(wrapper, 'name').setValue('en')
expect(editor(wrapper, 'slug').element.value).toBe('za-g-l')
// A hand edit of the pl slug stops pl only.
await localeSelect(wrapper, 'slug').setValue('pl')
await editor(wrapper, 'slug').setValue('reczny')
await editor(wrapper, 'name').setValue('Inny Tytuł')
expect(editor(wrapper, 'slug').element.value).toBe('reczny')
await localeSelect(wrapper, 'name').setValue('en')
await editor(wrapper, 'name').setValue('Hello World')
expect(editor(wrapper, 'slug').element.value).toBe('hello-world')
await save(wrapper)
const body = (await requestsTo(calls, 'POST', PEOPLE)[0]!.clone().json()) as Record<string, unknown>
expect(body.name).toEqual({ en: 'Hello World', pl: 'Inny Tytuł' })
expect(body.slug).toEqual({ en: 'hello-world', pl: 'reczny' })
})
it('does not follow an mltext source on update', async () => {
const record = clone(rosterRecordFixture)
record.data.name = { en: 'Ada Lovelace', pl: '' }
record.data.slug = { en: 'ada-lovelace', pl: '' }
const { wrapper } = await mountApp('/acme/roster/people/1', people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('mltext', 'mltext'), [`GET ${PERSON}`]: { body: record } }))
await editor(wrapper, 'name').setValue('Ada King')
expect(editor(wrapper, 'slug').element.value).toBe('ada-lovelace')
})
it('titles a record by the default locale of an mltext first field, and publishes it as the crumb', async () => {
const record = clone(rosterRecordFixture)
record.data.name = { en: 'Ada Lovelace', pl: 'Ada Lovelace PL' }
const { wrapper } = await mountApp('/acme/roster/people/1', people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('mltext', 'text'), [`GET ${PERSON}`]: { body: record } }))
expect(wrapper.find('h1').text()).toBe('Ada Lovelace')
expect(recordCrumb.value).toBe('Ada Lovelace')
})
it('drives a text slug from the active locale of an mltext name, until edited', async () => {
const { wrapper } = await mountApp('/acme/roster/people/create', people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('mltext', 'text') }))
await editor(wrapper, 'name').setValue('Hello World')
expect(field(wrapper, 'slug').element.value).toBe('hello-world')
await localeSelect(wrapper, 'name').setValue('pl')
await editor(wrapper, 'name').setValue('Witaj Świecie')
expect(field(wrapper, 'slug').element.value).toBe('witaj-wiecie')
await field(wrapper, 'slug').setValue('by-hand')
await editor(wrapper, 'name').setValue('Inny')
expect(field(wrapper, 'slug').element.value).toBe('by-hand')
})
it('fills only the active locale of an mltext slug from a text name', async () => {
const { wrapper } = await mountApp('/acme/roster/people/create', people({ [`GET ${PEOPLE}/schema/form`]: mlSchema('text', 'mltext') }))
await field(wrapper, 'name').setValue('Hello World')
expect(editor(wrapper, 'slug').element.value).toBe('hello-world')
await localeSelect(wrapper, 'slug').setValue('pl')
expect(editor(wrapper, 'slug').element.value).toBe('')
})
it('edits an mltextarea field per locale and saves the nested map', async () => {
const schema = mlSchema('mltextarea', 'text')
for (const item of schema.body.data.fields) {
if (item.name === 'name') {
item.size = 'small'
}
}
const record = clone(rosterRecordFixture)
record.data.name = { en: 'Ada\nLovelace', pl: '' }
const { wrapper, calls } = await mountApp(
'/acme/roster/people/1',
people({ [`GET ${PEOPLE}/schema/form`]: schema, [`GET ${PERSON}`]: { body: record }, [`PUT ${PERSON}`]: { body: rosterRecordFixture } }),
)
const textarea = wrapper.find<HTMLTextAreaElement>('#field-name-editor')
expect(textarea.element.tagName).toBe('TEXTAREA')
expect(textarea.element.value).toBe('Ada\nLovelace')
await localeSelect(wrapper, 'name').setValue('pl')
await wrapper.find<HTMLTextAreaElement>('#field-name-editor').setValue('Ada po polsku')
await save(wrapper)
const body = (await requestsTo(calls, 'PUT', PERSON)[0]!.clone().json()) as Record<string, unknown>
expect(body.name).toEqual({ en: 'Ada\nLovelace', pl: 'Ada po polsku' })
})
})