- WidgetField: skeleton and aria-busy, the 5000 ms whenDefined timeout, script failure, attributes only, fill-values and locale sync, one POST while busy, fill-key-only patching, danger toasts, unmount - PartialHost and partialNodes: exhaustive tag, attribute and URL allowlist, depth and node caps, skeleton sizes, empty, failure, busy refetch and ?id= only with a record - PartialField, ListToolbar, ListView, registry, formState, FormField and FormView: group labels, registered toolbar actions, header refetch rules, form context provision and asset loading
94 lines
4.3 KiB
TypeScript
94 lines
4.3 KiB
TypeScript
// PartialField (D-09; UI-SPEC S2): a `type: partial` form row renders its
|
|
// controller partial on create (no id) and update (?id=) inside a group named
|
|
// by the row's label span; without a label there is no label row and no
|
|
// aria-labelledby. Mounted through FormField, as FormGrid renders it.
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { flushPromises, mount } from '@vue/test-utils'
|
|
import FormField from '../../src/components/form/FormField.vue'
|
|
import PartialField from '../../src/components/form/fields/PartialField.vue'
|
|
import PartialHost from '../../src/components/partial/PartialHost.vue'
|
|
import type { FormField as Field } from '../../src/api/types'
|
|
import { clone, extensionFormSchemaFixture } from '../fixtures/typed'
|
|
import { API, mockApi, queryOf, requestsTo, resetState } from '../helpers'
|
|
|
|
const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
|
|
const SUMMARY = `${API}/acme/demo/widgets/partials/summary`
|
|
const reply = { body: { data: { nodes: [{ tag: 'p', children: [{ text: 'Summary of the widget' }] }] }, meta: {} } }
|
|
|
|
function summaryField(change: Partial<Field> = {}): Field {
|
|
const field = clone(extensionFormSchemaFixture).data.fields.find((item) => item.name === 'summary')
|
|
if (!field) {
|
|
throw new Error('fixture summary field missing')
|
|
}
|
|
return { ...field, ...change }
|
|
}
|
|
|
|
function mountRow(field: Field, recordId: number | null, source: typeof SOURCE | null = SOURCE) {
|
|
return mount(FormField, { props: { field, modelValue: undefined, source, recordId, idPrefix: 'f' } })
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetState()
|
|
})
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
describe('partial form row', () => {
|
|
it('fetches without an id on create and with the record id on update', async () => {
|
|
const calls = mockApi({ [`GET ${SUMMARY}`]: reply })
|
|
mountRow(summaryField(), null)
|
|
mountRow(summaryField(), 3)
|
|
await flushPromises()
|
|
const [create, update] = requestsTo(calls, 'GET', SUMMARY)
|
|
expect(queryOf(create!).has('id')).toBe(false)
|
|
expect(queryOf(update!).get('id')).toBe('3')
|
|
})
|
|
|
|
it('is a group named by the row label span, not a <label>', async () => {
|
|
mockApi({ [`GET ${SUMMARY}`]: reply })
|
|
const wrapper = mountRow(summaryField({ comment: 'Read only', required: true }), 3)
|
|
await flushPromises()
|
|
const group = wrapper.find('[data-partial-field]')
|
|
expect(group.attributes('role')).toBe('group')
|
|
expect(group.attributes('id')).toBe('f-summary')
|
|
expect(group.attributes('aria-labelledby')).toBe('f-summary-label')
|
|
expect(group.attributes('aria-describedby')).toBe('f-summary-comment')
|
|
const label = wrapper.find('#f-summary-label')
|
|
expect(label.element.tagName).toBe('SPAN')
|
|
expect(label.text()).toBe('Summary *')
|
|
expect(label.find('[aria-hidden="true"]').text()).toBe('*')
|
|
expect(wrapper.find('label').exists()).toBe(false)
|
|
expect(group.find('[data-partial="field"]').text()).toBe('Summary of the widget')
|
|
expect(wrapper.findComponent(PartialHost).props()).toMatchObject({ variant: 'field', name: 'summary', recordId: 3 })
|
|
})
|
|
|
|
it('has no label row and no aria-labelledby without a label', async () => {
|
|
mockApi({ [`GET ${SUMMARY}`]: reply })
|
|
const wrapper = mountRow(summaryField({ label: undefined }), null)
|
|
await flushPromises()
|
|
expect(wrapper.find('#f-summary-label').exists()).toBe(false)
|
|
expect(wrapper.find('label').exists()).toBe(false)
|
|
expect(wrapper.find('[data-partial-field]').attributes('aria-labelledby')).toBeUndefined()
|
|
})
|
|
|
|
it('falls back to the field name when the path is empty', async () => {
|
|
const calls = mockApi({ [`GET ${API}/acme/demo/widgets/partials/notes`]: reply })
|
|
mountRow(summaryField({ name: 'notes', path: '' }), null)
|
|
await flushPromises()
|
|
expect(requestsTo(calls, 'GET', `${API}/acme/demo/widgets/partials/notes`)).toHaveLength(1)
|
|
})
|
|
|
|
it('shows the failure box and calls nothing without a controller (settings forms)', async () => {
|
|
const calls = mockApi({})
|
|
const wrapper = mount(PartialField, {
|
|
props: { field: summaryField(), modelValue: undefined, controlId: 'f-summary', source: null, recordId: null },
|
|
})
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-partial-failed]').attributes('role')).toBe('alert')
|
|
expect(wrapper.findComponent(PartialHost).exists()).toBe(false)
|
|
expect(calls).toHaveLength(0)
|
|
})
|
|
})
|