WidgetField assigned its element after append(), so an action the element dispatched from connectedCallback (inside append) found no target and was dropped without a result or an error state; widgets that load on connect never started. The element is now the action target before it is connected. Rebuilt the embedded admin dist.
530 lines
22 KiB
TypeScript
530 lines
22 KiB
TypeScript
// 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_RESULT_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>
|
|
attachTo?: HTMLElement
|
|
}
|
|
|
|
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, {
|
|
attachTo: options.attachTo,
|
|
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, 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. */
|
|
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)
|
|
})
|
|
})
|
|
|
|
describe('an action from connectedCallback (CR-04)', () => {
|
|
it('posts the action an element dispatches while it is being connected', async () => {
|
|
const tag = 'acme-demo-eager'
|
|
if (!customElements.get(tag)) {
|
|
customElements.define(
|
|
tag,
|
|
class extends HTMLElement {
|
|
connectedCallback(): void {
|
|
this.dispatchEvent(new CustomEvent(WIDGET_EVENT, { bubbles: true, composed: true, detail: { payload: { op: 'context' } } }))
|
|
}
|
|
},
|
|
)
|
|
}
|
|
const action = deferred()
|
|
const host = document.createElement('div')
|
|
document.body.append(host)
|
|
const { wrapper, calls } = mountWidget({
|
|
field: lookupField({ widget: tag }),
|
|
attachTo: host,
|
|
routes: { [`POST ${ACTION}`]: action.route },
|
|
})
|
|
await flushPromises()
|
|
const target = wrapper.find(tag).element as HTMLElement
|
|
expect(target.isConnected).toBe(true)
|
|
expect(target.hasAttribute('busy')).toBe(true)
|
|
const posts = requestsTo(calls, 'POST', ACTION)
|
|
expect(posts).toHaveLength(1)
|
|
const body = (await posts[0]!.json()) as Record<string, unknown>
|
|
expect(body.payload).toEqual({ op: 'context' })
|
|
const seen = results(target)
|
|
action.release({ body: { data: { message: '', fill: {} }, meta: {} } })
|
|
await flushPromises()
|
|
expect(target.hasAttribute('busy')).toBe(false)
|
|
expect(seen).toHaveLength(1)
|
|
wrapper.unmount()
|
|
})
|
|
})
|
|
|
|
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'])
|
|
})
|
|
})
|