feat(16.2-01): translatable plugin fields, element uploads and save notices in the admin
- MLPluginField wraps PluginField in the MLField locale switcher; the
element gets the active content locale and that locale's string
- isMLField treats a translatable plugin field as a locale map in the form
state, FormView adopt and the relation child modal
- summer-upload posts the file with the admin session to the field upload
route and answers summer-upload-result {id, url} or {id, error}
- noticeLines shows meta.notices under each field as a warning status line
(text only) and in the saved toast; the stored value is reloaded
- docs/backend/field-types.md documents the full element contract;
modules/boardwalk/dist rebuilt
This commit is contained in:
@@ -10,6 +10,28 @@ beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('save notices', () => {
|
||||
it('shows notices as a warning status line the control points at', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: { field: formField('code'), modelValue: '', notices: ['PL: <style> was removed', 'Second'], idPrefix: 'f' },
|
||||
})
|
||||
const line = wrapper.find('#f-code-notice')
|
||||
expect(line.attributes('role')).toBe('status')
|
||||
expect(line.classes()).toEqual(expect.arrayContaining(['summer-callout', 'summer-callout--warning']))
|
||||
expect(line.findAll('p').map((p) => p.text())).toEqual(['PL: <style> was removed', 'Second'])
|
||||
// Literal text: an HTML-looking message never becomes an element.
|
||||
expect(line.find('style').exists()).toBe(false)
|
||||
expect(line.html()).toContain('<style>')
|
||||
expect(wrapper.find('#f-code').attributes('aria-describedby')).toBe('f-code-comment f-code-notice')
|
||||
})
|
||||
|
||||
it('shows nothing without notices', () => {
|
||||
const wrapper = mount(FormField, { props: { field: formField('code'), modelValue: '', notices: [], idPrefix: 'f' } })
|
||||
expect(wrapper.find('[data-field-notice]').exists()).toBe(false)
|
||||
expect(wrapper.find('#f-code').attributes('aria-describedby')).toBe('f-code-comment')
|
||||
})
|
||||
})
|
||||
|
||||
describe('form field', () => {
|
||||
it('links the label to the control and hides the required asterisk from assistive tech', () => {
|
||||
const wrapper = mount(FormField, { props: { field: formField('name'), modelValue: 'Blue', idPrefix: 'f' } })
|
||||
|
||||
@@ -6,11 +6,21 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import MLPluginField from '../../src/components/form/fields/MLPluginField.vue'
|
||||
import PluginField from '../../src/components/form/fields/PluginField.vue'
|
||||
import { FIELD_CHANGE_EVENT, FORM_LOCALE, WIDGET_TIMEOUT } from '../../src/components/form/formContext'
|
||||
import {
|
||||
FIELD_CHANGE_EVENT,
|
||||
FIELD_UPLOAD_EVENT,
|
||||
FIELD_UPLOAD_RESULT_EVENT,
|
||||
FORM_ENABLED_LOCALES,
|
||||
FORM_LOCALE,
|
||||
WIDGET_TIMEOUT,
|
||||
} from '../../src/components/form/formContext'
|
||||
import { editablePayload, initialValues, isMLField, recordDisplayName } from '../../src/components/form/formState'
|
||||
import { isRegisteredField, rendererForField } from '../../src/components/form/registry'
|
||||
import type { FormField } from '../../src/api/types'
|
||||
import { resetState } from '../helpers'
|
||||
import { t } from '../../src/app/i18n'
|
||||
import { API, mockApi, resetState, type Route } from '../helpers'
|
||||
|
||||
/** A stub plugin element that counts writes to its value property. */
|
||||
class AcmeDemoNotes extends HTMLElement {
|
||||
@@ -40,6 +50,8 @@ function notesField(change: Partial<FormField> = {}): FormField {
|
||||
return { name: 'notes', type: 'notes', label: 'Notes', element: 'acme-demo-notes', typeOptions: { rows: 4 }, ...change }
|
||||
}
|
||||
|
||||
const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'notes' }
|
||||
|
||||
function mountPlugin(props: { field?: FormField; modelValue?: unknown; recordId?: number | null; invalid?: boolean } = {}) {
|
||||
const locale = ref('pl')
|
||||
const wrapper = mount(PluginField, {
|
||||
@@ -50,6 +62,7 @@ function mountPlugin(props: { field?: FormField; modelValue?: unknown; recordId?
|
||||
describedBy: 'f-notes-comment',
|
||||
recordId: props.recordId === undefined ? 7 : props.recordId,
|
||||
invalid: props.invalid,
|
||||
source: SOURCE,
|
||||
},
|
||||
global: { provide: { [FORM_LOCALE as symbol]: locale } },
|
||||
})
|
||||
@@ -153,3 +166,122 @@ describe('PluginField', () => {
|
||||
expect(rendererForField({ name: 'x', type: 'notes' })).not.toBe(PluginField)
|
||||
})
|
||||
})
|
||||
|
||||
/** Dispatches summer-upload on the element and collects the answers. */
|
||||
function upload(el: HTMLElement, detail: unknown): unknown[] {
|
||||
const answers: unknown[] = []
|
||||
el.addEventListener(FIELD_UPLOAD_RESULT_EVENT, (event) => answers.push((event as CustomEvent<unknown>).detail))
|
||||
el.dispatchEvent(new CustomEvent(FIELD_UPLOAD_EVENT, { bubbles: true, detail }))
|
||||
return answers
|
||||
}
|
||||
|
||||
const UPLOAD = `POST ${API}/acme/demo/notes/7/fields/notes/upload`
|
||||
|
||||
describe('PluginField uploads', () => {
|
||||
async function mounted(routes: Record<string, Route>) {
|
||||
const calls = mockApi(routes)
|
||||
const { wrapper } = mountPlugin()
|
||||
await flushPromises()
|
||||
return { el: element(wrapper), calls }
|
||||
}
|
||||
|
||||
it('posts the file to the field upload route and answers with the url', async () => {
|
||||
const { el, calls } = await mounted({ [UPLOAD]: { status: 201, body: { data: { url: '/storage/a.png' }, meta: {} } } })
|
||||
const answers = upload(el, { id: 'u1', file: new File(['png'], 'a.png', { type: 'image/png' }) })
|
||||
await flushPromises()
|
||||
expect(answers).toEqual([{ id: 'u1', url: '/storage/a.png' }])
|
||||
expect(calls).toHaveLength(1)
|
||||
const request = calls[0]!
|
||||
expect(request.method).toBe('POST')
|
||||
expect(request.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
const form = await request.clone().formData()
|
||||
const file = form.get('file')
|
||||
expect(file).toBeInstanceOf(File)
|
||||
expect((file as File).name).toBe('a.png')
|
||||
expect([...form.keys()]).toEqual(['file'])
|
||||
})
|
||||
|
||||
it('answers a 422 with the server message', async () => {
|
||||
const { el } = await mounted({
|
||||
[UPLOAD]: { status: 422, body: { error: { code: 'validation_failed', message: 'Validation failed', details: { notes: ['Not an image.'] } } } },
|
||||
})
|
||||
const answers = upload(el, { id: 'u2', file: new File(['x'], 'a.txt') })
|
||||
await flushPromises()
|
||||
expect(answers).toEqual([{ id: 'u2', error: 'Not an image.' }])
|
||||
})
|
||||
|
||||
it('answers a 413 and a network failure with the generic message', async () => {
|
||||
const { el } = await mounted({ [UPLOAD]: { status: 413, body: { error: { code: 'payload_too_large', message: 'Too large', details: {} } } } })
|
||||
const too = upload(el, { id: 'u3', file: new File(['x'], 'a.txt') })
|
||||
await flushPromises()
|
||||
expect(too).toEqual([{ id: 'u3', error: t('backend::lang.fileupload.upload_failed') }])
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network down'))
|
||||
const down = upload(el, { id: 'u4', file: new File(['x'], 'a.txt') })
|
||||
await flushPromises()
|
||||
expect(down).toEqual([{ id: 'u4', error: t('backend::lang.fileupload.upload_failed') }])
|
||||
})
|
||||
|
||||
it('answers an upload without a file without a request', async () => {
|
||||
const { el, calls } = await mounted({})
|
||||
const answers = upload(el, { id: 'u5', file: 'not a file' })
|
||||
await flushPromises()
|
||||
expect(answers).toEqual([{ id: 'u5', error: t('backend::lang.fileupload.upload_failed') }])
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stored value reload', () => {
|
||||
it('sets the stored value once when a save changed it', async () => {
|
||||
const { wrapper } = mountPlugin({ modelValue: '<p>a</p><style>x</style>' })
|
||||
await flushPromises()
|
||||
const el = element(wrapper)
|
||||
// The save answered with what the type stored; the form adopts it.
|
||||
await wrapper.setProps({ modelValue: '<p>a</p>' })
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ modelValue: '<p>a</p>' })
|
||||
await flushPromises()
|
||||
expect(el.writes).toEqual(['<p>a</p><style>x</style>', '<p>a</p>'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('MLPluginField', () => {
|
||||
class AcmeDemoNotesml extends AcmeDemoNotes {}
|
||||
if (!customElements.get('acme-demo-notesml')) {
|
||||
customElements.define('acme-demo-notesml', AcmeDemoNotesml)
|
||||
}
|
||||
const mlField = notesField({ name: 'body', type: 'notesml', element: 'acme-demo-notesml', translatable: true })
|
||||
|
||||
it('renders through the ML switcher and edits one locale', async () => {
|
||||
expect(rendererForField(mlField)).toBe(MLPluginField)
|
||||
const wrapper = mount(MLPluginField, {
|
||||
props: { field: mlField, modelValue: { pl: 'Polski', en: 'English' }, controlId: 'f-body', recordId: 7, source: SOURCE },
|
||||
global: { provide: { [FORM_ENABLED_LOCALES as symbol]: ref(['pl', 'en']), [FORM_LOCALE as symbol]: ref('pl') } },
|
||||
})
|
||||
await flushPromises()
|
||||
const el = wrapper.find('acme-demo-notesml').element as AcmeDemoNotesml
|
||||
expect(el.getAttribute('locale')).toBe('pl')
|
||||
expect(el.value).toBe('Polski')
|
||||
expect(el.getAttribute('record-id')).toBe('7')
|
||||
|
||||
await wrapper.find('select[data-ml-locale]').setValue('en')
|
||||
await flushPromises()
|
||||
expect(el.getAttribute('locale')).toBe('en')
|
||||
expect(el.value).toBe('English')
|
||||
|
||||
el.edit('English 2')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[{ pl: 'Polski', en: 'English 2' }]])
|
||||
})
|
||||
|
||||
it('is a locale map in the form state helpers', () => {
|
||||
expect(isMLField(mlField)).toBe(true)
|
||||
expect(isMLField(notesField())).toBe(false)
|
||||
expect(isMLField({ type: 'notesml', translatable: true })).toBe(false)
|
||||
expect(initialValues([mlField], ['pl', 'en'])).toEqual({ body: { pl: '', en: '' } })
|
||||
expect(editablePayload([mlField, notesField()], { body: { pl: 'P', en: 'E', xx: 3 }, notes: { blocks: [] } })).toEqual({
|
||||
body: { pl: 'P', en: 'E' },
|
||||
notes: { blocks: [] },
|
||||
})
|
||||
expect(recordDisplayName([mlField], { body: { pl: 'Tytuł', en: 'Title' } }, ['pl', 'en'])).toBe('Tytuł')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
focusField,
|
||||
initialValues,
|
||||
mergeMLValue,
|
||||
noticeLines,
|
||||
panelDomId,
|
||||
presetUpdates,
|
||||
presetValue,
|
||||
@@ -302,3 +303,24 @@ describe('recordDisplayName', () => {
|
||||
expect(recordDisplayName(fields, { notify: true, tags: [1], title: { en: 'Post', pl: '' }, name: 'Anna' }, locales)).toBe('Post')
|
||||
})
|
||||
})
|
||||
|
||||
describe('noticeLines', () => {
|
||||
it('maps notices to lines per field, prefixing the content locale', () => {
|
||||
expect(
|
||||
noticeLines([
|
||||
{ field: 'content', locale: 'pl', message: '2 elements were removed: style, iframe' },
|
||||
{ field: 'content', locale: 'en', message: 'A script was removed' },
|
||||
{ field: 'notes', message: 'Trimmed' },
|
||||
]),
|
||||
).toEqual({
|
||||
content: ['PL: 2 elements were removed: style, iframe', 'EN: A script was removed'],
|
||||
notes: ['Trimmed'],
|
||||
})
|
||||
})
|
||||
|
||||
it('is empty without notices', () => {
|
||||
expect(noticeLines(undefined)).toEqual({})
|
||||
expect(noticeLines([])).toEqual({})
|
||||
expect(noticeLines([{ field: 'x', message: '' }])).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user