- TestFieldTypeUntouched: served values skip Decode, writes and notices; one changed locale writes and reports only that locale; canonical JSON comparison; a create never skips - PluginField: an element that echoes its value on connect is never written a second time
363 lines
15 KiB
TypeScript
363 lines
15 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 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> = {}): 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<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>'])
|
|
})
|
|
})
|
|
|
|
/** A stub element that re-emits its current value when it connects, as an
|
|
* element that normalizes nothing but announces its value on mount does. */
|
|
class AcmeDemoEcho extends AcmeDemoNotes {
|
|
connectedCallback(): void {
|
|
this.dispatchEvent(new CustomEvent(FIELD_CHANGE_EVENT, { bubbles: true, detail: { value: this.value } }))
|
|
}
|
|
}
|
|
if (!customElements.get('acme-demo-echo')) {
|
|
customElements.define('acme-demo-echo', AcmeDemoEcho)
|
|
}
|
|
|
|
describe('untouched value (D-03)', () => {
|
|
// The form: every update:modelValue becomes the next modelValue, as
|
|
// FormView's v-model does.
|
|
function mountEcho(modelValue: unknown) {
|
|
const wrapper: VueWrapper = mount(PluginField, {
|
|
props: {
|
|
field: notesField({ element: 'acme-demo-echo' }),
|
|
modelValue,
|
|
controlId: 'f-notes',
|
|
recordId: 7,
|
|
source: SOURCE,
|
|
'onUpdate:modelValue': (value: unknown) => wrapper.setProps({ modelValue: value }),
|
|
},
|
|
global: { provide: { [FORM_LOCALE as symbol]: ref('pl') } },
|
|
// Attached, so the element's connectedCallback runs.
|
|
attachTo: document.body,
|
|
})
|
|
return wrapper
|
|
}
|
|
|
|
function echoElement(wrapper: VueWrapper): AcmeDemoEcho {
|
|
const found = wrapper.find('acme-demo-echo').element
|
|
if (!(found instanceof AcmeDemoEcho)) {
|
|
throw new Error('echo element not mounted')
|
|
}
|
|
return found
|
|
}
|
|
|
|
it('keeps the served HTML identical when the element echoes it on connect', async () => {
|
|
const served = '\n<!-- imported -->\n<p>Grzyby <b>rosną</b></p>\n<footer class="author-footer"><p>Autor</p></footer>\n'
|
|
const wrapper = mountEcho(served)
|
|
await flushPromises()
|
|
const el = echoElement(wrapper)
|
|
expect(wrapper.emitted('update:modelValue')).toEqual([[served]])
|
|
expect((wrapper.props() as { modelValue: unknown }).modelValue).toBe(served)
|
|
expect(el.value).toBe(served)
|
|
// The property was written once, before connect; the echo wrote nothing.
|
|
expect(el.writes).toEqual([served])
|
|
})
|
|
|
|
it('does not write an equal object value a second time', async () => {
|
|
const served = { blocks: [{ type: 'p', text: 'a' }], version: 1 }
|
|
const wrapper = mountEcho(served)
|
|
await flushPromises()
|
|
const el = echoElement(wrapper)
|
|
expect((wrapper.props() as { modelValue: unknown }).modelValue).toEqual(served)
|
|
// An equal copy from the server (a reload) is not written either.
|
|
await wrapper.setProps({ modelValue: JSON.parse(JSON.stringify(served)) })
|
|
await flushPromises()
|
|
expect(el.writes).toEqual([served])
|
|
})
|
|
|
|
it('writes a value that really differs exactly once', async () => {
|
|
const wrapper = mountEcho('<p>a</p>')
|
|
await flushPromises()
|
|
const el = echoElement(wrapper)
|
|
await wrapper.setProps({ modelValue: '<p>b</p>' })
|
|
await flushPromises()
|
|
await wrapper.setProps({ modelValue: '<p>b</p>' })
|
|
await flushPromises()
|
|
expect(el.writes).toEqual(['<p>a</p>', '<p>b</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ł')
|
|
})
|
|
})
|