// PluginField (D-13): a field whose type a plugin registers renders the // type's custom element, hands it the value property and plain attributes, // takes summer-change events as the new form value and never writes back the // value the element just emitted. Mounted on its own with the form context // FormView would provide. 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, 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 { 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 { writes: unknown[] = [] private current: unknown = undefined get value(): unknown { return this.current } set value(next: unknown) { this.writes.push(next) this.current = next } /** What a real element does on a user edit: keep the value, announce it. */ edit(next: unknown): void { this.current = next this.dispatchEvent(new CustomEvent(FIELD_CHANGE_EVENT, { bubbles: true, detail: { value: next } })) } } if (!customElements.get('acme-demo-notes')) { customElements.define('acme-demo-notes', AcmeDemoNotes) } function notesField(change: Partial = {}): 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, { props: { field: props.field ?? notesField(), modelValue: props.modelValue === undefined ? 'hello' : props.modelValue, controlId: 'f-notes', describedBy: 'f-notes-comment', recordId: props.recordId === undefined ? 7 : props.recordId, invalid: props.invalid, source: SOURCE, }, global: { provide: { [FORM_LOCALE as symbol]: locale } }, }) return { wrapper, locale } } function element(wrapper: VueWrapper): AcmeDemoNotes { const found = wrapper.find('acme-demo-notes').element if (!(found instanceof AcmeDemoNotes)) { throw new Error('plugin element not mounted') } return found } beforeEach(() => { resetState() }) afterEach(() => { vi.useRealTimers() document.body.innerHTML = '' }) describe('PluginField', () => { it('sets the value property and the contract attributes, nothing else', async () => { const { wrapper } = mountPlugin({ invalid: true }) await flushPromises() const el = element(wrapper) expect(el.value).toBe('hello') expect(el.writes).toEqual(['hello']) expect(el.getAttribute('field-name')).toBe('notes') expect(el.getAttribute('record-id')).toBe('7') expect(el.getAttribute('locale')).toBe('') expect(el.getAttribute('ui-locale')).toBe('pl') expect(JSON.parse(el.getAttribute('options') ?? '')).toEqual({ rows: 4 }) expect(el.hasAttribute('invalid')).toBe(true) expect(el.hasAttribute('disabled')).toBe(false) // Attributes only: no token, cookie, function or Vue instance. const names = el.getAttributeNames().sort() expect(names).toEqual(['field-name', 'invalid', 'locale', 'options', 'record-id', 'ui-locale']) const host = wrapper.find('[data-plugin-field]') expect(host.attributes('role')).toBe('group') expect(host.attributes('aria-labelledby')).toBe('f-notes-label') expect(host.attributes('aria-describedby')).toBe('f-notes-comment') expect(host.attributes('aria-busy')).toBeUndefined() }) it('marks a read-only field disabled and the create form with an empty record id', async () => { const { wrapper } = mountPlugin({ field: notesField({ readOnly: true }), recordId: null }) await flushPromises() const el = element(wrapper) expect(el.hasAttribute('disabled')).toBe(true) expect(el.getAttribute('record-id')).toBe('') }) it('takes summer-change as the new form value', async () => { const { wrapper } = mountPlugin() await flushPromises() element(wrapper).edit('edited') expect(wrapper.emitted('update:modelValue')).toEqual([['edited']]) }) it('does not write back the value the element just emitted', async () => { const { wrapper } = mountPlugin() await flushPromises() const el = element(wrapper) el.edit('edited') await wrapper.setProps({ modelValue: 'edited' }) await flushPromises() expect(el.writes).toEqual(['hello']) await wrapper.setProps({ modelValue: 'from the server' }) await flushPromises() expect(el.writes).toEqual(['hello', 'from the server']) }) it('compares object values by content', async () => { const { wrapper } = mountPlugin({ modelValue: { blocks: [1, 2] } }) await flushPromises() const el = element(wrapper) el.edit({ blocks: [1, 2, 3] }) await wrapper.setProps({ modelValue: { blocks: [1, 2, 3] } }) await flushPromises() expect(el.writes).toEqual([{ blocks: [1, 2] }]) }) it('ends in the failure state when the tag is never defined', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const { wrapper } = mountPlugin({ field: notesField({ element: 'acme-demo-never' }) }) await flushPromises() expect(wrapper.find('[data-plugin-field]').attributes('aria-busy')).toBe('true') await vi.advanceTimersByTimeAsync(WIDGET_TIMEOUT) await flushPromises() expect(wrapper.find('[data-plugin-field-failed]').exists()).toBe(true) expect(wrapper.find('[data-plugin-field]').exists()).toBe(false) }) it('is the renderer of any field that names an element, and holds a value', () => { expect(rendererForField(notesField())).toBe(PluginField) expect(isRegisteredField(notesField())).toBe(true) expect(isRegisteredField({ name: 'x', type: 'notes' })).toBe(false) 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).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) { 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: '

a

' }) await flushPromises() const el = element(wrapper) // The save answered with what the type stored; the form adopts it. await wrapper.setProps({ modelValue: '

a

' }) await flushPromises() await wrapper.setProps({ modelValue: '

a

' }) await flushPromises() expect(el.writes).toEqual(['

a

', '

a

']) }) }) 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ł') }) })