feat(10.1-02): mount plugin widget elements and run their actions from the form
- pluginAssets loads controller scripts and stylesheets from {base}/assets/ only, once per URL
- WidgetField mounts the custom element with attributes only and posts summer-action through the typed client
- Only declared fill keys returned by the server are patched; the form turns dirty and nothing saves
- widget is a registered valueless type rendered on create and update, labelled as a group
- backend::lang.extension strings in en and pl; embedded dist rebuilt
This commit is contained in:
68
admin/tests/fixtures/extension.form-schema.json
vendored
Normal file
68
admin/tests/fixtures/extension.form-schema.json
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"data": {
|
||||
"name": "Widget",
|
||||
"fields": [
|
||||
{
|
||||
"name": "name",
|
||||
"label": "Name",
|
||||
"type": "text",
|
||||
"span": "left",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"label": "Colour",
|
||||
"type": "text",
|
||||
"span": "right"
|
||||
},
|
||||
{
|
||||
"name": "lookup",
|
||||
"label": "Catalogue",
|
||||
"type": "widget",
|
||||
"comment": "Fills in the name. Save to keep it.",
|
||||
"widget": "acme-demo-lookup",
|
||||
"action": "lookup",
|
||||
"actionLabel": "Look up",
|
||||
"fill": ["name"]
|
||||
}
|
||||
],
|
||||
"messages": {
|
||||
"create": {
|
||||
"other": "New widget"
|
||||
},
|
||||
"update": {
|
||||
"other": "Editing widget"
|
||||
},
|
||||
"saved": {
|
||||
"other": "Widget „:name” saved"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"other": "Delete this widget?"
|
||||
},
|
||||
"deleted": {
|
||||
"other": "Widget deleted"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
},
|
||||
"assets": {
|
||||
"scripts": ["/admin-test/assets/acme/demo/js/lookup.js?v=abc"],
|
||||
"styles": []
|
||||
},
|
||||
"redirects": {
|
||||
"create": {
|
||||
"redirect": "acme/demo/widgets/update/:id",
|
||||
"redirectClose": "acme/demo/widgets"
|
||||
},
|
||||
"update": {
|
||||
"redirect": "acme/demo/widgets",
|
||||
"redirectClose": "acme/demo/widgets"
|
||||
},
|
||||
"default": "acme/demo/widgets"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
}
|
||||
}
|
||||
12
admin/tests/fixtures/lang.json
vendored
12
admin/tests/fixtures/lang.json
vendored
@@ -21,6 +21,18 @@
|
||||
"backend::lang.auth.title": {
|
||||
"other": "Witaj ponownie"
|
||||
},
|
||||
"backend::lang.extension.action_failed": {
|
||||
"other": "Nie udało się wykonać akcji. Spróbuj ponownie."
|
||||
},
|
||||
"backend::lang.extension.busy": {
|
||||
"other": "Wczytywanie…"
|
||||
},
|
||||
"backend::lang.extension.partial_failed": {
|
||||
"other": "Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie."
|
||||
},
|
||||
"backend::lang.extension.widget_failed": {
|
||||
"other": "Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki."
|
||||
},
|
||||
"backend::lang.form.add": {
|
||||
"other": "Dodaj"
|
||||
},
|
||||
|
||||
3
admin/tests/fixtures/typed.ts
vendored
3
admin/tests/fixtures/typed.ts
vendored
@@ -7,6 +7,7 @@ import type { components } from '../../src/api/schema'
|
||||
import langJson from './lang.json'
|
||||
import navigationJson from './navigation.json'
|
||||
import settingsJson from './settings.json'
|
||||
import extensionFormSchemaJson from './extension.form-schema.json'
|
||||
import formSchemaJson from './widgets.form-schema.json'
|
||||
import listJson from './widgets.list.json'
|
||||
import listSchemaJson from './widgets.list-schema.json'
|
||||
@@ -27,6 +28,8 @@ export const settingsFixture: {
|
||||
values: S['cabana.Envelope-cabana_SettingsResult']
|
||||
} = settingsJson
|
||||
export const formSchemaFixture: S['cabana.Envelope-cabana_FormView'] = formSchemaJson
|
||||
/** A form with a plugin widget and its controller script (Phase 10.1). */
|
||||
export const extensionFormSchemaFixture: S['cabana.Envelope-cabana_FormView'] = extensionFormSchemaJson
|
||||
export const listFixture: Rows = listJson
|
||||
export const listSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = listSchemaJson
|
||||
export const optionsFixture: {
|
||||
|
||||
302
admin/tests/smoke/extension.smoke.test.ts
Normal file
302
admin/tests/smoke/extension.smoke.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
// Phase 10.1 extension point, SPA half (ADMIN-07): plugin assets, a widget
|
||||
// custom element bridged to its action. Fixtures are neutral acme.demo.*
|
||||
// data; no application names appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import {
|
||||
activateStyles,
|
||||
assetAllowed,
|
||||
loadControllerAssets,
|
||||
loadScript,
|
||||
loadStyles,
|
||||
OWNER_ATTRIBUTE,
|
||||
} from '../../src/app/pluginAssets'
|
||||
import {
|
||||
FORM_ASSETS,
|
||||
FORM_LOCALE,
|
||||
FORM_PATCH,
|
||||
FORM_VALUES,
|
||||
WIDGET_EVENT,
|
||||
WIDGET_TIMEOUT,
|
||||
} from '../../src/components/form/formContext'
|
||||
import WidgetField from '../../src/components/form/fields/WidgetField.vue'
|
||||
import ExtensionFailure from '../../src/components/ui/ExtensionFailure.vue'
|
||||
import { isRegistered, needsRecord, rendererFor } from '../../src/components/form/registry'
|
||||
import { clone, extensionFormSchemaFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, type Reply, type Route } from '../helpers'
|
||||
|
||||
const BASE = `${API}/acme/demo/widgets`
|
||||
const RECORD = `${BASE}/1`
|
||||
const WIDGET_ACTION = `${BASE}/widgets/lookup`
|
||||
const SCRIPT = '/admin-test/assets/acme/demo/js/lookup.js?v=abc'
|
||||
|
||||
const record = { data: { id: 1, name: 'Blue widget', color: '#0000ff' }, meta: { labels: {} } }
|
||||
|
||||
// The plugin's element: plain JS, no Vue, defined before the form mounts as
|
||||
// the plugin script would.
|
||||
class AcmeDemoLookup extends HTMLElement {}
|
||||
if (!customElements.get('acme-demo-lookup')) {
|
||||
customElements.define('acme-demo-lookup', AcmeDemoLookup)
|
||||
}
|
||||
|
||||
interface Seen {
|
||||
scripts: string[]
|
||||
links: HTMLLinkElement[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin files never load in happy-dom: a script "loads" on the next
|
||||
* microtask (or errors when listed in failing) and links are only recorded.
|
||||
*/
|
||||
function stubAssets(failing: string[] = []): Seen {
|
||||
const seen: Seen = { scripts: [], links: [] }
|
||||
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) {
|
||||
const src = node.getAttribute('src') ?? ''
|
||||
seen.scripts.push(src)
|
||||
queueMicrotask(() => node.dispatchEvent(new Event(failing.includes(src) ? 'error' : 'load')))
|
||||
return node
|
||||
}
|
||||
if (node instanceof HTMLLinkElement) {
|
||||
seen.links.push(node)
|
||||
return node
|
||||
}
|
||||
return append(node)
|
||||
})
|
||||
return seen
|
||||
}
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${BASE}/schema/form`]: { body: extensionFormSchemaFixture },
|
||||
[`GET ${RECORD}`]: { body: record },
|
||||
[`PUT ${RECORD}`]: async (request) => ({
|
||||
body: { data: { ...record.data, ...((await request.json()) as object) }, meta: record.meta },
|
||||
}),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** A route whose reply the test releases, to observe the in-flight state. */
|
||||
function deferred(): { route: Route; release: (reply: Reply) => void } {
|
||||
let release: (reply: Reply) => void = () => undefined
|
||||
const pending = new Promise<Reply>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
return { route: () => pending, release }
|
||||
}
|
||||
|
||||
function widget(wrapper: VueWrapper): HTMLElement {
|
||||
const element = wrapper.find('acme-demo-lookup').element
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error('widget element not mounted')
|
||||
}
|
||||
return element
|
||||
}
|
||||
|
||||
function act(element: HTMLElement): void {
|
||||
element.dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true }))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('plugin assets (D-14, D-16)', () => {
|
||||
it('accepts only same-origin files under {base}/assets/', () => {
|
||||
expect(assetAllowed(SCRIPT)).toBe(true)
|
||||
for (const url of [
|
||||
'https://cdn.example.test/admin-test/assets/x.js',
|
||||
'//cdn.example.test/admin-test/assets/x.js',
|
||||
'/admin-test/api/v1/x.js',
|
||||
'/admin-test/assets/../api/v1/x.js',
|
||||
'/admin-test/assets/a\\b.js',
|
||||
'/admin-test/assets/a b.js',
|
||||
'/other/assets/x.js',
|
||||
]) {
|
||||
expect(assetAllowed(url), url).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('loads a script once per URL, refuses foreign URLs and retries after a failure', async () => {
|
||||
const failing = '/admin-test/assets/acme/demo/js/broken.js?v=1'
|
||||
const seen = stubAssets([failing])
|
||||
await expect(loadScript('https://cdn.example.test/x.js')).rejects.toThrow()
|
||||
const url = '/admin-test/assets/acme/demo/js/once.js?v=1'
|
||||
const first = loadScript(url)
|
||||
expect(loadScript(url)).toBe(first)
|
||||
await first
|
||||
await expect(loadScript(failing)).rejects.toThrow()
|
||||
await expect(loadScript(failing)).rejects.toThrow()
|
||||
expect(seen.scripts).toEqual([url, failing, failing])
|
||||
|
||||
expect(await loadControllerAssets('acme.demo.widgets', { scripts: [url, failing], styles: [] })).toEqual([failing])
|
||||
})
|
||||
|
||||
it('owns stylesheet links per controller and disables the other controllers', () => {
|
||||
const seen = stubAssets()
|
||||
const css = '/admin-test/assets/acme/demo/css/demo.css?v=1'
|
||||
loadStyles('acme.demo.one', [css, 'https://cdn.example.test/x.css'])
|
||||
loadStyles('acme.demo.one', [css])
|
||||
loadStyles('acme.demo.two', [css])
|
||||
expect(seen.links).toHaveLength(2)
|
||||
const [one, two] = seen.links
|
||||
expect(one!.getAttribute(OWNER_ATTRIBUTE)).toBe('acme.demo.one')
|
||||
expect(one!.rel).toBe('stylesheet')
|
||||
activateStyles('acme.demo.two')
|
||||
expect(one!.disabled).toBe(true)
|
||||
expect(two!.disabled).toBe(false)
|
||||
activateStyles('acme.demo.one')
|
||||
expect(one!.disabled).toBe(false)
|
||||
expect(two!.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget field (D-04, D-05, D-07, D-08)', () => {
|
||||
it('is a registered, valueless control that renders on create', () => {
|
||||
expect(rendererFor('widget')).toBe(WidgetField)
|
||||
expect(isRegistered('widget')).toBe(false)
|
||||
expect(needsRecord('widget')).toBe(false)
|
||||
expect(FORM_VALUES).not.toBe(FORM_PATCH)
|
||||
expect(FORM_LOCALE).not.toBe(FORM_ASSETS)
|
||||
expect(WIDGET_TIMEOUT).toBe(5000)
|
||||
})
|
||||
|
||||
it('mounts the element with attributes only and keeps fill-values current', async () => {
|
||||
const seen = stubAssets()
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
||||
await flushPromises()
|
||||
|
||||
expect(seen.scripts).toContain(SCRIPT)
|
||||
const host = wrapper.find('[data-widget-host]')
|
||||
expect(host.attributes('role')).toBe('group')
|
||||
expect(host.attributes('id')).toBe('field-lookup')
|
||||
expect(host.attributes('aria-labelledby')).toBe('field-lookup-label')
|
||||
expect(host.attributes('aria-describedby')).toBe('field-lookup-comment')
|
||||
expect(host.attributes('aria-busy')).toBeUndefined()
|
||||
expect(wrapper.find('#field-lookup-label').text()).toBe('Catalogue')
|
||||
expect(wrapper.find('label[for="field-lookup"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-widget-skeleton]').exists()).toBe(false)
|
||||
|
||||
const element = widget(wrapper)
|
||||
expect(element.getAttribute('record-id')).toBe('1')
|
||||
expect(element.getAttribute('field-name')).toBe('lookup')
|
||||
expect(element.getAttribute('locale')).toBe('en')
|
||||
expect(element.getAttribute('fill-values')).toBe('{"name":"Blue widget"}')
|
||||
expect(element.getAttribute('label')).toBe('Look up')
|
||||
expect(element.getAttribute('busy-label')).toBe('Wczytywanie…')
|
||||
expect(element.getAttributeNames().sort()).toEqual(['busy-label', 'field-name', 'fill-values', 'label', 'locale', 'record-id'])
|
||||
expect(Object.keys(element)).toEqual([])
|
||||
|
||||
await wrapper.find('#field-name').setValue('Red widget')
|
||||
expect(element.getAttribute('fill-values')).toBe('{"name":"Red widget"}')
|
||||
})
|
||||
|
||||
it('posts the action once while busy and patches only the fill keys the server returned', async () => {
|
||||
stubAssets()
|
||||
const action = deferred()
|
||||
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes({ [`POST ${WIDGET_ACTION}`]: action.route }))
|
||||
await flushPromises()
|
||||
const element = widget(wrapper)
|
||||
|
||||
act(element)
|
||||
await flushPromises()
|
||||
expect(element.hasAttribute('busy')).toBe(true)
|
||||
act(element)
|
||||
await flushPromises()
|
||||
const posts = requestsTo(calls, 'POST', WIDGET_ACTION)
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(posts[0]!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
expect(await posts[0]!.json()).toEqual({ record_id: 1, values: { name: 'Blue widget' } })
|
||||
|
||||
action.release({ body: { data: { message: 'Name filled in', fill: { name: 'Catalogue widget', color: '#ff0000' } }, meta: {} } })
|
||||
await flushPromises()
|
||||
|
||||
expect(element.hasAttribute('busy')).toBe(false)
|
||||
expect(element.hasAttribute('state')).toBe(false)
|
||||
expect((wrapper.find('#field-name').element as HTMLInputElement).value).toBe('Catalogue widget')
|
||||
expect((wrapper.find('#field-color').element as HTMLInputElement).value).toBe('#0000ff')
|
||||
expect(wrapper.find('[role="status"]').text()).toContain('Name filled in')
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(0)
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
const [put] = requestsTo(calls, 'PUT', RECORD)
|
||||
expect(await put!.json()).toEqual({ name: 'Catalogue widget', color: '#0000ff' })
|
||||
})
|
||||
|
||||
it('toasts the server message, marks the element and leaves the values on failure', async () => {
|
||||
stubAssets()
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
routes({
|
||||
[`POST ${WIDGET_ACTION}`]: {
|
||||
status: 422,
|
||||
body: { error: { code: 'validation_failed', message: 'Catalogue unreachable.', details: {} } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
const element = widget(wrapper)
|
||||
act(element)
|
||||
await flushPromises()
|
||||
|
||||
expect(element.getAttribute('state')).toBe('error')
|
||||
expect(element.hasAttribute('busy')).toBe(false)
|
||||
expect(wrapper.find('[data-tone="danger"]').text()).toContain('Catalogue unreachable.')
|
||||
expect((wrapper.find('#field-name').element as HTMLInputElement).value).toBe('Blue widget')
|
||||
})
|
||||
|
||||
it('falls back to the framework message when the error has none', async () => {
|
||||
stubAssets()
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
routes({ [`POST ${WIDGET_ACTION}`]: { status: 500, body: {} } }),
|
||||
)
|
||||
await flushPromises()
|
||||
act(widget(wrapper))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-tone="danger"]').text()).toContain('Nie udało się wykonać akcji. Spróbuj ponownie.')
|
||||
})
|
||||
|
||||
it('renders on create with an empty record id and empty fill values', async () => {
|
||||
stubAssets()
|
||||
const action = deferred()
|
||||
const { wrapper, calls } = await mountApp('/acme/demo/widgets/create', routes({ [`POST ${WIDGET_ACTION}`]: action.route }))
|
||||
await flushPromises()
|
||||
const element = widget(wrapper)
|
||||
expect(element.getAttribute('record-id')).toBe('')
|
||||
expect(element.getAttribute('fill-values')).toBe('{}')
|
||||
|
||||
act(element)
|
||||
await flushPromises()
|
||||
expect(await requestsTo(calls, 'POST', WIDGET_ACTION)[0]!.json()).toEqual({ values: {} })
|
||||
action.release({ body: { data: { message: 'Done', fill: {} }, meta: {} } })
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('shows the failure box instead of the element when its script fails', async () => {
|
||||
const schema = clone(extensionFormSchemaFixture)
|
||||
const broken = '/admin-test/assets/acme/demo/js/missing.js?v=1'
|
||||
schema.data.assets.scripts = [broken]
|
||||
schema.data.fields = schema.data.fields.map((field) =>
|
||||
field.name === 'lookup' ? { ...field, widget: 'acme-demo-missing' } : field,
|
||||
)
|
||||
stubAssets([broken])
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: { body: schema } }))
|
||||
await flushPromises()
|
||||
|
||||
const box = wrapper.find('[data-widget-failed]')
|
||||
expect(box.attributes('role')).toBe('alert')
|
||||
expect(box.attributes('id')).toBe('field-lookup')
|
||||
expect(box.text()).toBe('Nie udało się wczytać tej kontrolki. Odśwież stronę, a jeśli błąd wróci, sprawdź zasoby wtyczki.')
|
||||
expect(wrapper.find('acme-demo-missing').exists()).toBe(false)
|
||||
expect(wrapper.findComponent(ExtensionFailure).exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user