// 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 PluginField from '../../src/components/form/fields/PluginField.vue' import { FIELD_CHANGE_EVENT, FORM_LOCALE, WIDGET_TIMEOUT } from '../../src/components/form/formContext' import { isRegisteredField, rendererForField } from '../../src/components/form/registry' import type { FormField } from '../../src/api/types' import { resetState } 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 } } 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, }, 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) }) })