Files
summercms/admin/tests/form/PluginField.test.ts
Jakub Zych 83fa2f7b2c feat(16.2-01): host plugin field type elements in the admin SPA
- loadFieldTypes fetches GET /field-types after sign-in, loads each script
  once and adds the styles as global links activateStyles never disables
- PluginField creates the type's element, sets the value property and the
  contract attributes, takes summer-change as the form value and never
  writes back a value the element just emitted
- rendererForField and isRegisteredField route any field with an element
  to PluginField in forms, previews and the save payload
- rebuilt modules/boardwalk/dist
2026-10-10 11:10:41 +02:00

156 lines
6.1 KiB
TypeScript

// 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> = {}): 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)
})
})