test(10.1-04): bring the SPA extension point under Vitest
- 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
This commit is contained in:
@@ -3,8 +3,8 @@ import { mount } from '@vue/test-utils'
|
||||
import FieldRenderer from '../../src/components/form/FieldRenderer.vue'
|
||||
import FormErrorBanner from '../../src/components/form/FormErrorBanner.vue'
|
||||
import FormField from '../../src/components/form/FormField.vue'
|
||||
import { formField } from '../fixtures/typed'
|
||||
import { resetState } from '../helpers'
|
||||
import { clone, extensionFormSchemaFixture, formField } from '../fixtures/typed'
|
||||
import { mockApi, resetState } from '../helpers'
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
@@ -55,6 +55,44 @@ describe('form field', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget and partial rows (Phase 10.1)', () => {
|
||||
function extensionField(name: string, change: Record<string, unknown> = {}) {
|
||||
const field = clone(extensionFormSchemaFixture).data.fields.find((item) => item.name === name)
|
||||
if (!field) {
|
||||
throw new Error(`fixture field ${name} missing`)
|
||||
}
|
||||
return { ...field, ...change }
|
||||
}
|
||||
|
||||
it.each(['lookup', 'summary'])('labels the %s group with a span the group points at', (name) => {
|
||||
mockApi({})
|
||||
const wrapper = mount(FormField, {
|
||||
props: { field: extensionField(name, { required: true }), modelValue: undefined, idPrefix: 'f', source: null },
|
||||
})
|
||||
const label = wrapper.find(`#f-${name}-label`)
|
||||
expect(label.element.tagName).toBe('SPAN')
|
||||
expect(label.classes()).toContain('font-semibold')
|
||||
expect(label.find('[aria-hidden="true"]').text()).toBe('*')
|
||||
expect(wrapper.find('label').exists()).toBe(false)
|
||||
expect(wrapper.find(`#f-${name}`).attributes('aria-labelledby')).toBe(`f-${name}-label`)
|
||||
})
|
||||
|
||||
it.each(['lookup', 'summary'])('shows no label row for an unlabelled %s', (name) => {
|
||||
mockApi({})
|
||||
const wrapper = mount(FormField, { props: { field: extensionField(name, { label: '' }), modelValue: undefined, idPrefix: 'f', source: null } })
|
||||
expect(wrapper.find(`#f-${name}-label`).exists()).toBe(false)
|
||||
expect(wrapper.find('label').exists()).toBe(false)
|
||||
expect(wrapper.find(`#f-${name}`).attributes('aria-labelledby')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('points the widget group at its comment', () => {
|
||||
mockApi({})
|
||||
const wrapper = mount(FormField, { props: { field: extensionField('lookup'), modelValue: undefined, idPrefix: 'f', source: null } })
|
||||
expect(wrapper.find('#f-lookup-comment').text()).toBe('Fills in the name. Save to keep it.')
|
||||
expect(wrapper.find('#f-lookup').attributes('aria-describedby')).toBe('f-lookup-comment')
|
||||
})
|
||||
})
|
||||
|
||||
describe('field renderer', () => {
|
||||
it('picks the control by type and forwards the shared props and updates', async () => {
|
||||
const wrapper = mount(FieldRenderer, {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import FormView from '../../src/views/FormView.vue'
|
||||
import { recordCrumb } from '../../src/state/useBreadcrumbs'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { clone, formSchemaFixture, recordFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
|
||||
import { WIDGET_EVENT } from '../../src/components/form/formContext'
|
||||
import { clone, extensionFormSchemaFixture, formSchemaFixture, recordFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, queryOf, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
|
||||
const BASE = `${API}/acme/demo/widgets`
|
||||
const RECORD = `${BASE}/1`
|
||||
@@ -147,3 +148,143 @@ describe('form view', () => {
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('form view extension context (Phase 10.1, D-05, D-07, D-08, D-09, D-14)', () => {
|
||||
const ACTION = `${BASE}/widgets/lookup`
|
||||
const SUMMARY = `${BASE}/partials/summary`
|
||||
const record = { data: { id: 1, name: 'Blue widget', color: '#0000ff' }, meta: { labels: {} } }
|
||||
const summary = { data: { nodes: [{ tag: 'p', children: [{ text: 'Summary' }] }] }, meta: {} }
|
||||
|
||||
class AcmeDemoLookup extends HTMLElement {}
|
||||
if (!customElements.get('acme-demo-lookup')) {
|
||||
customElements.define('acme-demo-lookup', AcmeDemoLookup)
|
||||
}
|
||||
|
||||
/** Records appended plugin scripts; each loads when the test fires it (or at once). */
|
||||
function stubHead(autoload = true): HTMLScriptElement[] {
|
||||
const scripts: HTMLScriptElement[] = []
|
||||
const head = document.head
|
||||
const append = head.appendChild.bind(head)
|
||||
vi.spyOn(head, 'appendChild').mockImplementation(<T extends Node>(node: T): T => {
|
||||
if (node instanceof HTMLScriptElement) {
|
||||
scripts.push(node)
|
||||
if (autoload) {
|
||||
queueMicrotask(() => node.dispatchEvent(new Event('load')))
|
||||
}
|
||||
return node
|
||||
}
|
||||
if (node instanceof HTMLLinkElement) {
|
||||
return node
|
||||
}
|
||||
return append(node)
|
||||
})
|
||||
return scripts
|
||||
}
|
||||
|
||||
function extensionRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/form`]: { body: extensionFormSchemaFixture },
|
||||
[`GET ${RECORD}`]: { body: record },
|
||||
[`GET ${SUMMARY}`]: { body: summary },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function widget(root: Element): HTMLElement {
|
||||
const element = root.querySelector('acme-demo-lookup')
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error('widget element not mounted')
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
function beforeUnloadPrevented(): boolean {
|
||||
const event = new Event('beforeunload', { cancelable: true })
|
||||
window.dispatchEvent(event)
|
||||
return event.defaultPrevented
|
||||
}
|
||||
|
||||
it('provides the values and the schema locale to widgets', async () => {
|
||||
stubHead()
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', extensionRoutes())
|
||||
await flushPromises()
|
||||
const element = widget(wrapper.element)
|
||||
expect(element.getAttribute('fill-values')).toBe('{"name":"Blue widget"}')
|
||||
// The UI speaks Polish; the widget gets the schema's locale.
|
||||
expect(element.getAttribute('locale')).toBe('en')
|
||||
await wrapper.find('#field-name').setValue('Green widget')
|
||||
expect(element.getAttribute('fill-values')).toBe('{"name":"Green widget"}')
|
||||
})
|
||||
|
||||
it('falls back to the UI locale when the schema has none', async () => {
|
||||
stubHead()
|
||||
const schema = clone(extensionFormSchemaFixture)
|
||||
schema.data.meta.locale = ''
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', extensionRoutes({ [`GET ${BASE}/schema/form`]: { body: schema } }))
|
||||
await flushPromises()
|
||||
expect(widget(wrapper.element).getAttribute('locale')).toBe('pl')
|
||||
})
|
||||
|
||||
it('patches like an edit: the form turns dirty and the field error clears', async () => {
|
||||
stubHead()
|
||||
const { wrapper, calls } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
extensionRoutes({
|
||||
[`PUT ${RECORD}`]: {
|
||||
status: 422,
|
||||
body: { error: { code: 'validation_failed', message: 'Validation failed', details: { name: ['Name is taken.'] } } },
|
||||
},
|
||||
[`POST ${ACTION}`]: { body: { data: { message: 'Name filled in', fill: { name: 'Catalogue widget' } }, meta: {} } },
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
||||
expect(wrapper.find('#field-name-error').text()).toContain('Name is taken.')
|
||||
expect(beforeUnloadPrevented()).toBe(false)
|
||||
|
||||
widget(wrapper.element).dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true }))
|
||||
await flushPromises()
|
||||
expect((wrapper.find('#field-name').element as HTMLInputElement).value).toBe('Catalogue widget')
|
||||
expect(wrapper.find('#field-name-error').exists()).toBe(false)
|
||||
expect(beforeUnloadPrevented()).toBe(true)
|
||||
// Nothing was saved by the action itself.
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders widgets and partials on create', async () => {
|
||||
stubHead()
|
||||
const { wrapper, calls } = await mountApp('/acme/demo/widgets/create', extensionRoutes())
|
||||
await flushPromises()
|
||||
const element = widget(wrapper.element)
|
||||
expect(element.getAttribute('record-id')).toBe('')
|
||||
const [partial] = requestsTo(calls, 'GET', SUMMARY)
|
||||
expect(queryOf(partial!).has('id')).toBe(false)
|
||||
expect(wrapper.find('[data-partial-field] [data-partial="field"]').text()).toBe('Summary')
|
||||
expect(requestsTo(calls, 'GET', RECORD)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('loads the controller assets when the schema arrives and mounts the widget after its script', async () => {
|
||||
const scripts = stubHead(false)
|
||||
// A fresh URL: the loader remembers scripts across the tests of this file.
|
||||
const fresh = '/admin-test/assets/acme/demo/js/form.js?v=1'
|
||||
const schema = clone(extensionFormSchemaFixture)
|
||||
schema.data.assets.scripts = [fresh]
|
||||
let release: (reply: Reply) => void = () => undefined
|
||||
const pending = new Promise<Reply>((resolve) => (release = resolve))
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', extensionRoutes({ [`GET ${BASE}/schema/form`]: () => pending }))
|
||||
expect(scripts).toHaveLength(0)
|
||||
|
||||
release({ body: schema })
|
||||
await flushPromises()
|
||||
expect(scripts.map((script) => script.getAttribute('src'))).toEqual([fresh])
|
||||
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBe('true')
|
||||
expect(wrapper.element.querySelector('acme-demo-lookup')).toBeNull()
|
||||
|
||||
scripts[0]!.dispatchEvent(new Event('load'))
|
||||
await flushPromises()
|
||||
expect(widget(wrapper.element).getAttribute('record-id')).toBe('1')
|
||||
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
93
admin/tests/form/PartialField.test.ts
Normal file
93
admin/tests/form/PartialField.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
360
admin/tests/form/WidgetField.test.ts
Normal file
360
admin/tests/form/WidgetField.test.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
// WidgetField (D-04, D-05, D-07, D-08; UI-SPEC S3, S5; T-10.1-17, T-10.1-18):
|
||||
// the plugin custom element is created once its controller script is in and
|
||||
// its tag is defined, gets attributes only, and asks for its action with a
|
||||
// bubbling summer-action event the SPA posts. Mounted on its own with the
|
||||
// form context FormView would provide; HTTP goes through the fetch mock.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import WidgetField from '../../src/components/form/fields/WidgetField.vue'
|
||||
import ExtensionFailure from '../../src/components/ui/ExtensionFailure.vue'
|
||||
import {
|
||||
FORM_ASSETS,
|
||||
FORM_LOCALE,
|
||||
FORM_PATCH,
|
||||
FORM_VALUES,
|
||||
WIDGET_EVENT,
|
||||
WIDGET_TIMEOUT,
|
||||
} from '../../src/components/form/formContext'
|
||||
import type { AdminRecord, FormField } from '../../src/api/types'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { clone, extensionFormSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mockApi, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
|
||||
const ACTION = `${API}/acme/demo/widgets/widgets/lookup`
|
||||
const SOURCE = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
|
||||
const WIDGET_FAILED = 'Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki.'
|
||||
const ACTION_FAILED = 'Nie udało się wykonać akcji. Spróbuj ponownie.'
|
||||
|
||||
class AcmeDemoLookup extends HTMLElement {}
|
||||
if (!customElements.get('acme-demo-lookup')) {
|
||||
customElements.define('acme-demo-lookup', AcmeDemoLookup)
|
||||
}
|
||||
|
||||
/** The fixture's lookup widget, filling name and color. */
|
||||
function lookupField(change: Partial<FormField> = {}): FormField {
|
||||
const field = clone(extensionFormSchemaFixture).data.fields.find((item) => item.name === 'lookup')
|
||||
if (!field) {
|
||||
throw new Error('fixture lookup field missing')
|
||||
}
|
||||
return { ...field, fill: ['name', 'color'], ...change }
|
||||
}
|
||||
|
||||
interface Options {
|
||||
field?: FormField
|
||||
recordId?: number | null
|
||||
values?: AdminRecord
|
||||
locale?: string
|
||||
assets?: () => Promise<string[]>
|
||||
source?: typeof SOURCE | null
|
||||
routes?: Record<string, Route>
|
||||
}
|
||||
|
||||
function mountWidget(options: Options = {}) {
|
||||
const values = ref<AdminRecord>(options.values ?? { name: 'Blue widget', color: '#0000ff', weight: 3 })
|
||||
const locale = ref(options.locale ?? 'en')
|
||||
const patch = vi.fn((name: string, value: unknown) => {
|
||||
values.value = { ...values.value, [name]: value }
|
||||
})
|
||||
const calls = mockApi(options.routes ?? {})
|
||||
const wrapper = mount(WidgetField, {
|
||||
props: {
|
||||
field: options.field ?? lookupField(),
|
||||
modelValue: undefined,
|
||||
controlId: 'f-lookup',
|
||||
describedBy: 'f-lookup-comment',
|
||||
source: options.source === undefined ? SOURCE : options.source,
|
||||
recordId: options.recordId === undefined ? 7 : options.recordId,
|
||||
},
|
||||
global: {
|
||||
provide: {
|
||||
[FORM_VALUES as symbol]: values,
|
||||
[FORM_PATCH as symbol]: patch,
|
||||
[FORM_LOCALE as symbol]: locale,
|
||||
[FORM_ASSETS as symbol]: options.assets ?? (() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
})
|
||||
return { wrapper, values, locale, patch, calls }
|
||||
}
|
||||
|
||||
function element(wrapper: VueWrapper): HTMLElement {
|
||||
const found = wrapper.find('[data-widget-host] > div > *').element
|
||||
if (!(found instanceof HTMLElement)) {
|
||||
throw new Error('widget element not mounted')
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function act(target: HTMLElement): void {
|
||||
target.dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true }))
|
||||
}
|
||||
|
||||
/** A route whose reply the test releases. */
|
||||
function deferred(): { route: () => Promise<Reply>; release: (reply: Reply) => void } {
|
||||
let release: (reply: Reply) => void = () => undefined
|
||||
const pending = new Promise<Reply>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
return { route: () => pending, release }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('loading and failure states', () => {
|
||||
it('shows a skeleton and aria-busy while the controller script loads', async () => {
|
||||
const { wrapper } = mountWidget({ assets: () => new Promise<string[]>(() => undefined) })
|
||||
await flushPromises()
|
||||
const host = wrapper.find('[data-widget-host]')
|
||||
expect(host.attributes('role')).toBe('group')
|
||||
expect(host.attributes('id')).toBe('f-lookup')
|
||||
expect(host.attributes('aria-busy')).toBe('true')
|
||||
expect(host.attributes('aria-labelledby')).toBe('f-lookup-label')
|
||||
expect(host.attributes('aria-describedby')).toBe('f-lookup-comment')
|
||||
const skeleton = wrapper.find('[data-widget-skeleton]')
|
||||
expect(skeleton.attributes('aria-hidden')).toBe('true')
|
||||
expect(skeleton.classes()).toEqual(expect.arrayContaining(['h-[42px]', 'w-[160px]', 'rounded-control', 'bg-skel']))
|
||||
expect(wrapper.find('acme-demo-lookup').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('waits for the tag to be defined and gives up after 5000 ms', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const { wrapper } = mountWidget({ field: lookupField({ widget: 'acme-demo-never' }) })
|
||||
await flushPromises()
|
||||
expect(WIDGET_TIMEOUT).toBe(5000)
|
||||
await vi.advanceTimersByTimeAsync(WIDGET_TIMEOUT - 1)
|
||||
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBe('true')
|
||||
expect(wrapper.find('[data-widget-failed]').exists()).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await flushPromises()
|
||||
const box = wrapper.find('[data-widget-failed]')
|
||||
expect(box.attributes('role')).toBe('alert')
|
||||
expect(box.attributes('id')).toBe('f-lookup')
|
||||
expect(box.attributes('aria-describedby')).toBe('f-lookup-comment')
|
||||
expect(box.text()).toBe(WIDGET_FAILED)
|
||||
expect(wrapper.findComponent(ExtensionFailure).exists()).toBe(true)
|
||||
expect(wrapper.find('[data-widget-host]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('mounts an element defined while it waits (customElements.whenDefined)', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const { wrapper } = mountWidget({ field: lookupField({ widget: 'acme-demo-late' }) })
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
customElements.define('acme-demo-late', class extends HTMLElement {})
|
||||
await flushPromises()
|
||||
expect(wrapper.find('acme-demo-late').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-widget-host]').attributes('aria-busy')).toBeUndefined()
|
||||
// The timeout was cancelled: nothing fails later.
|
||||
await vi.advanceTimersByTimeAsync(WIDGET_TIMEOUT)
|
||||
expect(wrapper.find('[data-widget-failed]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('fails at once when the script failed and the tag is still undefined', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const { wrapper } = mountWidget({
|
||||
field: lookupField({ widget: 'acme-demo-broken' }),
|
||||
assets: () => Promise.resolve(['/admin-test/assets/acme/demo/js/broken.js']),
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-widget-failed]').attributes('role')).toBe('alert')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('still mounts a tag another script already defined when one script failed', async () => {
|
||||
const { wrapper } = mountWidget({ assets: () => Promise.resolve(['/admin-test/assets/acme/demo/js/other.js']) })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('acme-demo-lookup').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('fails without a tag or without a controller', async () => {
|
||||
for (const options of [{ field: lookupField({ widget: '' }) }, { source: null }] as Options[]) {
|
||||
const { wrapper } = mountWidget(options)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-widget-failed]').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('has no aria-labelledby without a label', async () => {
|
||||
const { wrapper } = mountWidget({ field: lookupField({ label: undefined }) })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-widget-host]').attributes('aria-labelledby')).toBeUndefined()
|
||||
expect(element(wrapper).getAttribute('label')).toBe('Look up')
|
||||
})
|
||||
})
|
||||
|
||||
describe('element attributes (D-08)', () => {
|
||||
it('sets attributes only, never properties or functions', async () => {
|
||||
const { wrapper } = mountWidget()
|
||||
await flushPromises()
|
||||
const target = element(wrapper)
|
||||
expect(target.tagName.toLowerCase()).toBe('acme-demo-lookup')
|
||||
expect(target.getAttributeNames().sort()).toEqual(['busy-label', 'field-name', 'fill-values', 'label', 'locale', 'record-id'])
|
||||
expect(target.getAttribute('record-id')).toBe('7')
|
||||
expect(target.getAttribute('field-name')).toBe('lookup')
|
||||
expect(target.getAttribute('locale')).toBe('en')
|
||||
expect(target.getAttribute('fill-values')).toBe('{"name":"Blue widget","color":"#0000ff"}')
|
||||
expect(target.getAttribute('label')).toBe('Look up')
|
||||
expect(target.getAttribute('busy-label')).toBe('Wczytywanie…')
|
||||
expect(Object.keys(target)).toEqual([])
|
||||
expect(wrapper.find('[data-widget-skeleton]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('follows form values and the locale, and leaves out absent fill values', async () => {
|
||||
const { wrapper, values, locale } = mountWidget({ values: { name: 'Blue widget' } })
|
||||
await flushPromises()
|
||||
const target = element(wrapper)
|
||||
expect(target.getAttribute('fill-values')).toBe('{"name":"Blue widget"}')
|
||||
values.value = { name: 'Red widget', color: '#ff0000', weight: 9 }
|
||||
await flushPromises()
|
||||
expect(target.getAttribute('fill-values')).toBe('{"name":"Red widget","color":"#ff0000"}')
|
||||
locale.value = 'pl'
|
||||
await flushPromises()
|
||||
expect(target.getAttribute('locale')).toBe('pl')
|
||||
})
|
||||
|
||||
it('labels the element from actionLabel, then label, then the field name', async () => {
|
||||
const { wrapper } = mountWidget({ field: lookupField({ actionLabel: undefined, label: undefined }) })
|
||||
await flushPromises()
|
||||
expect(element(wrapper).getAttribute('label')).toBe('lookup')
|
||||
})
|
||||
|
||||
it('has an empty record id on create', async () => {
|
||||
const { wrapper } = mountWidget({ recordId: null, values: {} })
|
||||
await flushPromises()
|
||||
expect(element(wrapper).getAttribute('record-id')).toBe('')
|
||||
expect(element(wrapper).getAttribute('fill-values')).toBe('{}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('summer-action (D-05, D-07)', () => {
|
||||
it('posts {record_id, values} with the CSRF header, once while busy', async () => {
|
||||
const action = deferred()
|
||||
const { wrapper, calls } = mountWidget({ routes: { [`POST ${ACTION}`]: action.route } })
|
||||
await flushPromises()
|
||||
const target = element(wrapper)
|
||||
act(target)
|
||||
await flushPromises()
|
||||
expect(target.hasAttribute('busy')).toBe(true)
|
||||
act(target)
|
||||
act(target)
|
||||
await flushPromises()
|
||||
const posts = requestsTo(calls, 'POST', ACTION)
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
expect(await posts[0]!.json()).toEqual({ record_id: 7, values: { name: 'Blue widget', color: '#0000ff' } })
|
||||
|
||||
action.release({ body: { data: { message: '', fill: {} }, meta: {} } })
|
||||
await flushPromises()
|
||||
expect(target.hasAttribute('busy')).toBe(false)
|
||||
act(target)
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'POST', ACTION)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('patches only the declared fill keys the server returned and toasts the message', async () => {
|
||||
const { wrapper, patch } = mountWidget({
|
||||
routes: { [`POST ${ACTION}`]: { body: { data: { message: 'Name filled in', fill: { name: 'Catalogue widget', weight: 99 } }, meta: {} } } },
|
||||
})
|
||||
await flushPromises()
|
||||
act(element(wrapper))
|
||||
await flushPromises()
|
||||
expect(patch.mock.calls).toEqual([['name', 'Catalogue widget']])
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Name filled in', tone: 'success' })
|
||||
expect(element(wrapper).getAttribute('fill-values')).toBe('{"name":"Catalogue widget","color":"#0000ff"}')
|
||||
expect(element(wrapper).hasAttribute('state')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not toast an empty message', async () => {
|
||||
const { wrapper, patch } = mountWidget({ routes: { [`POST ${ACTION}`]: { body: { data: { message: '', fill: { color: null } }, meta: {} } } } })
|
||||
await flushPromises()
|
||||
act(element(wrapper))
|
||||
await flushPromises()
|
||||
expect(patch.mock.calls).toEqual([['color', null]])
|
||||
expect(toasts.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sends no record_id on create', async () => {
|
||||
const { wrapper, calls } = mountWidget({
|
||||
recordId: null,
|
||||
values: {},
|
||||
routes: { [`POST ${ACTION}`]: { body: { data: { message: 'Done', fill: {} }, meta: {} } } },
|
||||
})
|
||||
await flushPromises()
|
||||
act(element(wrapper))
|
||||
await flushPromises()
|
||||
expect(await requestsTo(calls, 'POST', ACTION)[0]!.json()).toEqual({ values: {} })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the server message', { status: 422, body: { error: { code: 'validation_failed', message: 'Catalogue unreachable.', details: {} } } }, 'Catalogue unreachable.'],
|
||||
['the framework text without one', { status: 500, body: {} }, ACTION_FAILED],
|
||||
['the framework text on a 403', { status: 403, body: { error: { code: 'forbidden', message: '', details: {} } } }, ACTION_FAILED],
|
||||
])('toasts danger with %s and marks the element', async (_, reply, text) => {
|
||||
const { wrapper, patch } = mountWidget({ routes: { [`POST ${ACTION}`]: reply } })
|
||||
await flushPromises()
|
||||
const target = element(wrapper)
|
||||
act(target)
|
||||
await flushPromises()
|
||||
expect(toasts.value[0]).toMatchObject({ text, tone: 'danger' })
|
||||
expect(target.getAttribute('state')).toBe('error')
|
||||
expect(target.hasAttribute('busy')).toBe(false)
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears the error state on the next run', async () => {
|
||||
let reply: Reply = { status: 500, body: {} }
|
||||
const { wrapper } = mountWidget({ routes: { [`POST ${ACTION}`]: () => reply } })
|
||||
await flushPromises()
|
||||
const target = element(wrapper)
|
||||
act(target)
|
||||
await flushPromises()
|
||||
expect(target.getAttribute('state')).toBe('error')
|
||||
reply = { body: { data: { message: 'ok', fill: {} }, meta: {} } }
|
||||
act(target)
|
||||
await flushPromises()
|
||||
expect(target.hasAttribute('state')).toBe(false)
|
||||
})
|
||||
|
||||
it('toasts the framework text when the request cannot be sent', async () => {
|
||||
const { wrapper } = mountWidget()
|
||||
await flushPromises()
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network down'))
|
||||
const target = element(wrapper)
|
||||
act(target)
|
||||
await flushPromises()
|
||||
expect(toasts.value[0]).toMatchObject({ text: ACTION_FAILED, tone: 'danger' })
|
||||
expect(target.getAttribute('state')).toBe('error')
|
||||
expect(target.hasAttribute('busy')).toBe(false)
|
||||
})
|
||||
|
||||
it('stops listening once unmounted', async () => {
|
||||
const { wrapper, calls } = mountWidget({ routes: { [`POST ${ACTION}`]: (): Reply => ({ body: { data: { message: 'x', fill: {} }, meta: {} } }) } })
|
||||
await flushPromises()
|
||||
const target = element(wrapper)
|
||||
wrapper.unmount()
|
||||
act(target)
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'POST', ACTION)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('never mounts an element after an unmount during loading', async () => {
|
||||
let release: (failed: string[]) => void = () => undefined
|
||||
const { wrapper } = mountWidget({ assets: () => new Promise<string[]>((resolve) => (release = resolve)) })
|
||||
await flushPromises()
|
||||
const host = wrapper.find('[data-widget-host] > div').element
|
||||
wrapper.unmount()
|
||||
release([])
|
||||
await flushPromises()
|
||||
expect(host.children).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
tabDomId,
|
||||
tabOf,
|
||||
} from '../../src/components/form/formState'
|
||||
import { formField, formSchemaFixture } from '../fixtures/typed'
|
||||
import { extensionFormSchemaFixture, formField, formSchemaFixture } from '../fixtures/typed'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
@@ -50,6 +50,13 @@ describe('form state helpers', () => {
|
||||
expect(body).toEqual({ name: 'Blue', tags: [3], active: false })
|
||||
})
|
||||
|
||||
it('never sends widget or partial fields, even with a value under their name', () => {
|
||||
const fields = [formField('name'), ...extensionFormSchemaFixture.data.fields.filter((field) => field.type === 'widget' || field.type === 'partial')]
|
||||
expect(fields.map((field) => field.type)).toEqual(['text', 'widget', 'partial'])
|
||||
expect(editablePayload(fields, { name: 'Blue', lookup: 'x', summary: { nodes: [] } })).toEqual({ name: 'Blue' })
|
||||
expect(initialValues(fields.slice(1))).toEqual({})
|
||||
})
|
||||
|
||||
it('starts a new record from defaults, toggles off and empty multiple relations', () => {
|
||||
const fields = [
|
||||
{ ...formField('name'), default: 'Unnamed' },
|
||||
|
||||
@@ -6,11 +6,14 @@ import RelationField from '../../src/components/form/fields/RelationField.vue'
|
||||
import SwitchField from '../../src/components/form/fields/SwitchField.vue'
|
||||
import TextField from '../../src/components/form/fields/TextField.vue'
|
||||
import TextareaField from '../../src/components/form/fields/TextareaField.vue'
|
||||
import PartialField from '../../src/components/form/fields/PartialField.vue'
|
||||
import UnsupportedField from '../../src/components/form/fields/UnsupportedField.vue'
|
||||
import WidgetField from '../../src/components/form/fields/WidgetField.vue'
|
||||
import RelationManager from '../../src/components/relation/RelationManager.vue'
|
||||
import {
|
||||
controlAttributes,
|
||||
controlClass,
|
||||
groupLabelled,
|
||||
isRegistered,
|
||||
needsRecord,
|
||||
ownsLabel,
|
||||
@@ -60,6 +63,26 @@ describe('field renderer registry (D-05)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin extension types (Phase 10.1, D-09)', () => {
|
||||
it('registers widget and partial with their controls', () => {
|
||||
expect(rendererFor('widget')).toBe(WidgetField)
|
||||
expect(rendererFor('partial')).toBe(PartialField)
|
||||
})
|
||||
|
||||
it.each(['widget', 'partial'])('treats %s as valueless, rendered on create and group-labelled', (type) => {
|
||||
expect(isRegistered(type)).toBe(false)
|
||||
expect(needsRecord(type)).toBe(false)
|
||||
expect(groupLabelled(type)).toBe(true)
|
||||
expect(ownsLabel(type)).toBe(false)
|
||||
})
|
||||
|
||||
it('labels every other type as a control, not a group', () => {
|
||||
for (const type of ['text', 'textarea', 'number', 'dropdown', 'switch', 'checkbox', 'relation', RELATION_MANAGER, 'colorpicker']) {
|
||||
expect(groupLabelled(type), type).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('control attributes', () => {
|
||||
it('passes only allow-listed Winter attributes, lower-cased, and drops null and false', () => {
|
||||
const field = formField('name')
|
||||
|
||||
Reference in New Issue
Block a user