feat(admin): widget field payload and summer-result data channel (quick-261006-eyj)
- WidgetField posts the summer-action event's detail.payload as payload only
when the detail carries one; a payload-less post body is unchanged
- after a successful action the response data is set on the element as the
data attribute (removed when the answer has none) and announced with a
summer-result event carrying {data, fill, message}; failures dispatch nothing
- WIDGET_RESULT_EVENT exported from formContext; unit tests for both directions
- modules/boardwalk/dist rebuilt
This commit is contained in:
@@ -6,16 +6,20 @@ import { currentLocale, t } from '../../../app/i18n'
|
|||||||
import { showToast } from '../../../state/useToasts'
|
import { showToast } from '../../../state/useToasts'
|
||||||
import ExtensionFailure from '../../ui/ExtensionFailure.vue'
|
import ExtensionFailure from '../../ui/ExtensionFailure.vue'
|
||||||
import type { FieldControlProps } from '../control'
|
import type { FieldControlProps } from '../control'
|
||||||
import { FORM_ASSETS, FORM_LOCALE, FORM_PATCH, FORM_VALUES, WIDGET_EVENT, WIDGET_TIMEOUT } from '../formContext'
|
import { FORM_ASSETS, FORM_LOCALE, FORM_PATCH, FORM_VALUES, WIDGET_EVENT, WIDGET_RESULT_EVENT, WIDGET_TIMEOUT } from '../formContext'
|
||||||
|
|
||||||
// A `type: widget` field (D-04, D-05, D-07, D-08; UI-SPEC S3). The plugin's
|
// A `type: widget` field (D-04, D-05, D-07, D-08; UI-SPEC S3). The plugin's
|
||||||
// custom element is created imperatively inside a node Vue never renders
|
// custom element is created imperatively inside a node Vue never renders
|
||||||
// children into. It gets attributes only: record id, field name, locale, the
|
// children into. It gets attributes only: record id, field name, locale, the
|
||||||
// current fill values as JSON and its labels; never a token, a cookie, a Vue
|
// current fill values as JSON and its labels, and after a successful action
|
||||||
|
// the serialized response data as `data`; never a token, a cookie, a Vue
|
||||||
// instance or a function. It asks for its action with a bubbling
|
// instance or a function. It asks for its action with a bubbling
|
||||||
// summer-action event; the SPA posts it with the admin session, writes back
|
// summer-action event whose optional detail.payload (any JSON value) the SPA
|
||||||
// only the declared fill keys the server returned and shows the message.
|
// posts as `payload`; the SPA posts with the admin session, writes back only
|
||||||
// Nothing is saved until the admin presses Save.
|
// the declared fill keys the server returned, sets or removes the `data`
|
||||||
|
// attribute from the response data, dispatches summer-result on the element
|
||||||
|
// with detail {data, fill, message} and shows the message. Nothing is saved
|
||||||
|
// until the admin presses Save.
|
||||||
const props = defineProps<FieldControlProps>()
|
const props = defineProps<FieldControlProps>()
|
||||||
|
|
||||||
const values = inject(FORM_VALUES, null)
|
const values = inject(FORM_VALUES, null)
|
||||||
@@ -73,28 +77,44 @@ function fail(text?: string): void {
|
|||||||
element?.setAttribute('state', 'error')
|
element?.setAttribute('state', 'error')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onAction(): Promise<void> {
|
async function onAction(payload: unknown): Promise<void> {
|
||||||
const source = props.source
|
const source = props.source
|
||||||
if (!element || busy || !source) {
|
const target = element
|
||||||
|
if (!target || busy || !source) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
busy = true
|
busy = true
|
||||||
element.setAttribute('busy', '')
|
target.setAttribute('busy', '')
|
||||||
element.removeAttribute('state')
|
target.removeAttribute('state')
|
||||||
try {
|
try {
|
||||||
const result = await api.POST('/{vendor}/{plugin}/{controller}/widgets/{field}', {
|
const result = await api.POST('/{vendor}/{plugin}/{controller}/widgets/{field}', {
|
||||||
params: { path: { ...source, field: props.field.name } },
|
params: { path: { ...source, field: props.field.name } },
|
||||||
body: { record_id: props.recordId ?? undefined, values: fillValues() },
|
body: {
|
||||||
|
record_id: props.recordId ?? undefined,
|
||||||
|
values: fillValues(),
|
||||||
|
...(payload === undefined ? {} : { payload }),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if (result.data) {
|
if (result.data) {
|
||||||
const fill = result.data.data.fill ?? {}
|
const answer = result.data.data
|
||||||
|
const fill = answer.fill ?? {}
|
||||||
for (const name of props.field.fill ?? []) {
|
for (const name of props.field.fill ?? []) {
|
||||||
if (Object.hasOwn(fill, name)) {
|
if (Object.hasOwn(fill, name)) {
|
||||||
patch(name, fill[name])
|
patch(name, fill[name])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (result.data.data.message) {
|
if (!unmounted) {
|
||||||
showToast(result.data.data.message)
|
if (Object.hasOwn(answer, 'data')) {
|
||||||
|
target.setAttribute('data', JSON.stringify(answer.data))
|
||||||
|
} else {
|
||||||
|
target.removeAttribute('data')
|
||||||
|
}
|
||||||
|
target.dispatchEvent(
|
||||||
|
new CustomEvent(WIDGET_RESULT_EVENT, { detail: { data: answer.data, fill, message: answer.message } }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (answer.message) {
|
||||||
|
showToast(answer.message)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fail(result.error?.error.message)
|
fail(result.error?.error.message)
|
||||||
@@ -107,8 +127,17 @@ async function onAction(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function listener(): void {
|
/** The event's own payload property, or undefined when the detail has none. */
|
||||||
void onAction()
|
function payloadOf(event: Event): unknown {
|
||||||
|
const detail: unknown = (event as CustomEvent<unknown>).detail
|
||||||
|
if (detail !== null && typeof detail === 'object' && Object.hasOwn(detail, 'payload')) {
|
||||||
|
return (detail as { payload: unknown }).payload
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function listener(event: Event): void {
|
||||||
|
void onAction(payloadOf(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -82,5 +82,12 @@ export const FORM_SESSION: InjectionKey<FormSession> = Symbol('summer.form.sessi
|
|||||||
/** Event a widget element dispatches to run its action (bubbles, composed). */
|
/** Event a widget element dispatches to run its action (bubbles, composed). */
|
||||||
export const WIDGET_EVENT = 'summer-action'
|
export const WIDGET_EVENT = 'summer-action'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event the SPA dispatches on the plugin element after each successful action,
|
||||||
|
* with detail {data, fill, message}: the response data as returned (undefined
|
||||||
|
* when the answer carried none), the fill object and the message.
|
||||||
|
*/
|
||||||
|
export const WIDGET_RESULT_EVENT = 'summer-result'
|
||||||
|
|
||||||
/** How long a widget waits for its custom element to be defined. */
|
/** How long a widget waits for its custom element to be defined. */
|
||||||
export const WIDGET_TIMEOUT = 5000
|
export const WIDGET_TIMEOUT = 5000
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
FORM_PATCH,
|
FORM_PATCH,
|
||||||
FORM_VALUES,
|
FORM_VALUES,
|
||||||
WIDGET_EVENT,
|
WIDGET_EVENT,
|
||||||
|
WIDGET_RESULT_EVENT,
|
||||||
WIDGET_TIMEOUT,
|
WIDGET_TIMEOUT,
|
||||||
} from '../../src/components/form/formContext'
|
} from '../../src/components/form/formContext'
|
||||||
import type { AdminRecord, FormField } from '../../src/api/types'
|
import type { AdminRecord, FormField } from '../../src/api/types'
|
||||||
@@ -86,8 +87,15 @@ function element(wrapper: VueWrapper): HTMLElement {
|
|||||||
return found
|
return found
|
||||||
}
|
}
|
||||||
|
|
||||||
function act(target: HTMLElement): void {
|
function act(target: HTMLElement, detail?: unknown): void {
|
||||||
target.dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true }))
|
target.dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true, detail }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collects the summer-result events the SPA dispatches on the element. */
|
||||||
|
function results(target: HTMLElement): unknown[] {
|
||||||
|
const seen: unknown[] = []
|
||||||
|
target.addEventListener(WIDGET_RESULT_EVENT, (event) => seen.push((event as CustomEvent<unknown>).detail))
|
||||||
|
return seen
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A route whose reply the test releases. */
|
/** A route whose reply the test releases. */
|
||||||
@@ -358,3 +366,124 @@ describe('summer-action (D-05, D-07)', () => {
|
|||||||
expect(host.children).toHaveLength(0)
|
expect(host.children).toHaveLength(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('payload and data (summer-result)', () => {
|
||||||
|
const OK: Reply = { body: { data: { message: '', fill: {} }, meta: {} } }
|
||||||
|
const DATA_REPLY: Reply = {
|
||||||
|
body: { data: { message: 'Reordered', fill: { name: 'New', weight: 9 }, data: { items: [{ id: 1 }, { id: 2 }] } }, meta: {} },
|
||||||
|
}
|
||||||
|
|
||||||
|
it('posts detail.payload as payload next to record_id and values', async () => {
|
||||||
|
const { wrapper, calls } = mountWidget({ routes: { [`POST ${ACTION}`]: OK } })
|
||||||
|
await flushPromises()
|
||||||
|
act(element(wrapper), { payload: { order: [3, 1, 2] } })
|
||||||
|
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' },
|
||||||
|
payload: { order: [3, 1, 2] },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([[[1, 2]], [0], [false], [null]])('sends the payload %j as that JSON value', async (payload) => {
|
||||||
|
const { wrapper, calls } = mountWidget({ routes: { [`POST ${ACTION}`]: OK } })
|
||||||
|
await flushPromises()
|
||||||
|
act(element(wrapper), { payload })
|
||||||
|
await flushPromises()
|
||||||
|
const body = (await requestsTo(calls, 'POST', ACTION)[0]!.json()) as Record<string, unknown>
|
||||||
|
expect(Object.hasOwn(body, 'payload')).toBe(true)
|
||||||
|
expect(body.payload).toEqual(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends no payload key without a detail or without a payload property', async () => {
|
||||||
|
const { wrapper, calls } = mountWidget({ routes: { [`POST ${ACTION}`]: OK } })
|
||||||
|
await flushPromises()
|
||||||
|
const target = element(wrapper)
|
||||||
|
act(target)
|
||||||
|
await flushPromises()
|
||||||
|
act(target, { other: 1 })
|
||||||
|
await flushPromises()
|
||||||
|
act(target, 'string detail')
|
||||||
|
await flushPromises()
|
||||||
|
const posts = requestsTo(calls, 'POST', ACTION)
|
||||||
|
expect(posts).toHaveLength(3)
|
||||||
|
for (const post of posts) {
|
||||||
|
const body = (await post.json()) as Record<string, unknown>
|
||||||
|
expect(Object.hasOwn(body, 'payload')).toBe(false)
|
||||||
|
expect(body).toEqual({ record_id: 7, values: { name: 'Blue widget', color: '#0000ff' } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets the data attribute and dispatches summer-result with {data, fill, message}', async () => {
|
||||||
|
const { wrapper, patch } = mountWidget({ routes: { [`POST ${ACTION}`]: DATA_REPLY } })
|
||||||
|
await flushPromises()
|
||||||
|
const target = element(wrapper)
|
||||||
|
const seen = results(target)
|
||||||
|
act(target, { payload: { order: [2, 1] } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(patch.mock.calls).toEqual([['name', 'New']])
|
||||||
|
expect(toasts.value[0]).toMatchObject({ text: 'Reordered', tone: 'success' })
|
||||||
|
expect(target.getAttribute('data')).toBe('{"items":[{"id":1},{"id":2}]}')
|
||||||
|
expect(seen).toEqual([{ data: { items: [{ id: 1 }, { id: 2 }] }, fill: { name: 'New', weight: 9 }, message: 'Reordered' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes the data attribute and reports undefined data on a reply without data', async () => {
|
||||||
|
let reply: Reply = DATA_REPLY
|
||||||
|
const { wrapper } = mountWidget({ routes: { [`POST ${ACTION}`]: () => reply } })
|
||||||
|
await flushPromises()
|
||||||
|
const target = element(wrapper)
|
||||||
|
const seen = results(target)
|
||||||
|
act(target)
|
||||||
|
await flushPromises()
|
||||||
|
expect(target.hasAttribute('data')).toBe(true)
|
||||||
|
reply = { body: { data: { message: '', fill: { color: '#00ff00' } }, meta: {} } }
|
||||||
|
act(target)
|
||||||
|
await flushPromises()
|
||||||
|
expect(target.hasAttribute('data')).toBe(false)
|
||||||
|
expect(seen).toHaveLength(2)
|
||||||
|
const second = seen[1] as Record<string, unknown>
|
||||||
|
expect(Object.hasOwn(second, 'data')).toBe(true)
|
||||||
|
expect(second.data).toBeUndefined()
|
||||||
|
expect(second.fill).toEqual({ color: '#00ff00' })
|
||||||
|
expect(second.message).toBe('')
|
||||||
|
expect(toasts.value).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a 422', { status: 422, body: { error: { code: 'validation_failed', message: 'Nope.', details: {} } } } as Reply],
|
||||||
|
['a 500', { status: 500, body: {} } as Reply],
|
||||||
|
])('dispatches no summer-result and sets no data attribute on %s', async (_, reply) => {
|
||||||
|
const { wrapper } = mountWidget({ routes: { [`POST ${ACTION}`]: reply } })
|
||||||
|
await flushPromises()
|
||||||
|
const target = element(wrapper)
|
||||||
|
const seen = results(target)
|
||||||
|
act(target, { payload: 1 })
|
||||||
|
await flushPromises()
|
||||||
|
expect(seen).toEqual([])
|
||||||
|
expect(target.hasAttribute('data')).toBe(false)
|
||||||
|
expect(target.getAttribute('state')).toBe('error')
|
||||||
|
expect(toasts.value[0]).toMatchObject({ tone: 'danger' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dispatches no summer-result on a network error', async () => {
|
||||||
|
const { wrapper } = mountWidget()
|
||||||
|
await flushPromises()
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network down'))
|
||||||
|
const target = element(wrapper)
|
||||||
|
const seen = results(target)
|
||||||
|
act(target, { payload: 1 })
|
||||||
|
await flushPromises()
|
||||||
|
expect(seen).toEqual([])
|
||||||
|
expect(target.hasAttribute('data')).toBe(false)
|
||||||
|
expect(target.getAttribute('state')).toBe('error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets no data attribute at mount', async () => {
|
||||||
|
const { wrapper } = mountWidget()
|
||||||
|
await flushPromises()
|
||||||
|
expect(element(wrapper).getAttributeNames().sort()).toEqual(['busy-label', 'field-name', 'fill-values', 'label', 'locale', 'record-id'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -6,7 +6,7 @@
|
|||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||||
<title>SummerCMS</title>
|
<title>SummerCMS</title>
|
||||||
<script type="module" crossorigin src="./assets/index-z_LzMU8D.js"></script>
|
<script type="module" crossorigin src="./assets/index-CzWk4rvA.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-CqzF_Nki.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-CqzF_Nki.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in New Issue
Block a user