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:
Jakub Zych
2026-10-06 11:16:46 +02:00
parent 6af88f9df6
commit c6f68e4639
5 changed files with 187 additions and 22 deletions

View File

@@ -14,6 +14,7 @@ import {
FORM_PATCH,
FORM_VALUES,
WIDGET_EVENT,
WIDGET_RESULT_EVENT,
WIDGET_TIMEOUT,
} from '../../src/components/form/formContext'
import type { AdminRecord, FormField } from '../../src/api/types'
@@ -86,8 +87,15 @@ function element(wrapper: VueWrapper): HTMLElement {
return found
}
function act(target: HTMLElement): void {
target.dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true }))
function act(target: HTMLElement, detail?: unknown): void {
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. */
@@ -358,3 +366,124 @@ describe('summer-action (D-05, D-07)', () => {
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'])
})
})