- partialNodes rebuilds the server node tree with h() under the server's tag, attribute and URL lists - PartialHost owns the skeleton, empty and failure states and keeps nodes visible on refetch - type: partial is a valueless group-labelled field rendered on create and update - ListView shows headerPartial above the list card and refetches it after bulk delete - summer-partial and summer-stats style kit in main.css, documented in the cabana README; dist rebuilt
529 lines
22 KiB
TypeScript
529 lines
22 KiB
TypeScript
// Phase 10.1 extension point, SPA half (ADMIN-07): plugin assets, a widget
|
|
// custom element bridged to its action, header and form partials rendered
|
|
// through the client allowlist. 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 PartialField from '../../src/components/form/fields/PartialField.vue'
|
|
import ExtensionFailure from '../../src/components/ui/ExtensionFailure.vue'
|
|
import PartialHost from '../../src/components/partial/PartialHost.vue'
|
|
import {
|
|
PARTIAL_DROPPED_TAGS,
|
|
PARTIAL_TAGS,
|
|
partialAttrAllowed,
|
|
renderPartialNodes,
|
|
} from '../../src/components/partial/partialNodes'
|
|
import { groupLabelled, isRegistered, needsRecord, rendererFor } from '../../src/components/form/registry'
|
|
import listRows from '../fixtures/widgets.list.json'
|
|
import {
|
|
clone,
|
|
extensionFormSchemaFixture,
|
|
extensionListSchemaFixture,
|
|
extensionPartialFixture,
|
|
} from '../fixtures/typed'
|
|
import { API, mountApp, queryOf, requestsTo, resetState, wait, 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 STATS = `${BASE}/partials/stats`
|
|
const SUMMARY = `${BASE}/partials/summary`
|
|
|
|
const summary = { data: { nodes: [{ tag: 'p', children: [{ text: 'Summary of the widget' }] }] }, meta: {} }
|
|
|
|
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 },
|
|
[`GET ${SUMMARY}`]: { body: summary },
|
|
[`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: () => Promise<Reply>; 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)
|
|
})
|
|
})
|
|
|
|
function listRoutes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
|
return {
|
|
[`GET ${BASE}/schema/list`]: { body: extensionListSchemaFixture },
|
|
[`GET ${BASE}`]: { body: listRows },
|
|
[`GET ${STATS}`]: { body: extensionPartialFixture },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('partial node allowlist (D-17)', () => {
|
|
it('mirrors the server lists and checks attributes and URLs', () => {
|
|
expect(PARTIAL_TAGS.has('dl')).toBe(true)
|
|
expect(PARTIAL_TAGS.has('script')).toBe(false)
|
|
expect(PARTIAL_DROPPED_TAGS.has('script')).toBe(true)
|
|
expect(partialAttrAllowed('a', 'href', '/admin-test/x')).toBe(true)
|
|
expect(partialAttrAllowed('a', 'href', '#top')).toBe(true)
|
|
expect(partialAttrAllowed('img', 'src', '#top')).toBe(false)
|
|
for (const href of ['javascript:alert(1)', '//evil.example.test', '/\\evil', 'https://evil.example.test', '/\tx']) {
|
|
expect(partialAttrAllowed('a', 'href', href), href).toBe(false)
|
|
}
|
|
expect(partialAttrAllowed('div', 'onclick', 'x')).toBe(false)
|
|
expect(partialAttrAllowed('div', 'id', 'x')).toBe(false)
|
|
expect(partialAttrAllowed('div', 'style', 'x')).toBe(false)
|
|
expect(partialAttrAllowed('div', 'aria-label', 'x')).toBe(true)
|
|
expect(partialAttrAllowed('td', 'colspan', '2')).toBe(true)
|
|
expect(partialAttrAllowed('div', 'colspan', '2')).toBe(false)
|
|
expect(renderPartialNodes([{ text: '<b>x</b>' }])).toEqual(['<b>x</b>'])
|
|
expect(renderPartialNodes([{ tag: 'script', children: [{ text: 'x' }] }])).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('list header partial (D-03, D-11, UI-SPEC S1)', () => {
|
|
it('renders the stats strip between the heading and the list card and drops hostile nodes', async () => {
|
|
stubAssets()
|
|
const { wrapper } = await mountApp('/acme/demo/widgets', listRoutes())
|
|
|
|
const host = wrapper.find('[data-partial="header"]')
|
|
expect(host.classes()).toContain('summer-partial')
|
|
expect(host.attributes('aria-busy')).toBeUndefined()
|
|
const section = host.element.parentElement!
|
|
const blocks = Array.from(section.children)
|
|
expect(blocks.indexOf(host.element)).toBe(1)
|
|
expect(blocks[0]!.tagName).toBe('HEADER')
|
|
expect(blocks[2]!.querySelector('table')).not.toBeNull()
|
|
|
|
expect(host.find('dl.summer-stats').exists()).toBe(true)
|
|
expect(host.findAll('.summer-stat').length).toBe(2)
|
|
expect(host.findAll('.summer-stat__label').map((item) => item.text())).toEqual(['All widgets', 'Small'])
|
|
expect(host.findAll('.summer-stat__value').map((item) => item.text())).toEqual(['12', '5'])
|
|
|
|
expect(host.find('script').exists()).toBe(false)
|
|
expect(host.text()).not.toContain('hijacked')
|
|
const paragraph = host.find('p[data-note="kept"]')
|
|
expect(paragraph.attributes('id')).toBeUndefined()
|
|
expect(paragraph.attributes('style')).toBeUndefined()
|
|
const unsafe = host.find('a.danger-link')
|
|
expect(unsafe.attributes('href')).toBeUndefined()
|
|
expect(unsafe.attributes('onclick')).toBeUndefined()
|
|
const links = host.findAll('a')
|
|
expect(links[1]!.attributes('href')).toBe('/admin-test/acme/demo/widgets')
|
|
expect(links[1]!.attributes('title')).toBe('All widgets')
|
|
expect(links[2]!.attributes('href')).toBeUndefined()
|
|
|
|
expect(host.find('custom-box').exists()).toBe(false)
|
|
const unwrapped = host.find('p.unwrapped')
|
|
expect(unwrapped.text()).toBe('Use <b>bold</b> & <i>italic</i>')
|
|
expect(unwrapped.find('b').exists()).toBe(false)
|
|
expect(wrapper.findComponent(PartialHost).props('variant')).toBe('header')
|
|
})
|
|
|
|
it('shows one skeleton block on the first load without holding back the table', async () => {
|
|
stubAssets()
|
|
const stats = deferred()
|
|
const { wrapper } = await mountApp('/acme/demo/widgets', listRoutes({ [`GET ${STATS}`]: stats.route }))
|
|
|
|
const loading = wrapper.find('[data-partial-loading]')
|
|
expect(loading.attributes('aria-busy')).toBe('true')
|
|
const skeleton = loading.find('[data-partial-skeleton]')
|
|
expect(skeleton.attributes('aria-hidden')).toBe('true')
|
|
expect(skeleton.classes()).toEqual(expect.arrayContaining(['h-[80px]', 'w-full', 'rounded-card', 'bg-skel']))
|
|
expect(wrapper.findAll('tbody tr').length).toBe(2)
|
|
|
|
stats.release({ body: extensionPartialFixture })
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-partial-loading]').exists()).toBe(false)
|
|
expect(wrapper.find('dl.summer-stats').exists()).toBe(true)
|
|
})
|
|
|
|
it('renders nothing for zero nodes', async () => {
|
|
stubAssets()
|
|
const { wrapper } = await mountApp('/acme/demo/widgets', listRoutes({ [`GET ${STATS}`]: { body: { data: { nodes: [] }, meta: {} } } }))
|
|
expect(wrapper.find('[data-partial]').exists()).toBe(false)
|
|
expect(wrapper.find('[data-partial-loading]').exists()).toBe(false)
|
|
expect(wrapper.find('[data-partial-failed]').exists()).toBe(false)
|
|
})
|
|
|
|
it('shows the failure box on a 500 and keeps the list usable', async () => {
|
|
stubAssets()
|
|
const { wrapper } = await mountApp(
|
|
'/acme/demo/widgets',
|
|
listRoutes({ [`GET ${STATS}`]: { status: 500, body: { error: { code: 'server_error', message: 'Server error', details: {} } } } }),
|
|
)
|
|
const box = wrapper.find('[data-partial-failed]')
|
|
expect(box.attributes('role')).toBe('alert')
|
|
expect(box.text()).toBe('Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie.')
|
|
expect(wrapper.find('[data-partial]').exists()).toBe(false)
|
|
expect(wrapper.findAll('tbody tr').length).toBe(2)
|
|
})
|
|
|
|
it('refetches after a bulk delete with the previous nodes kept, and not on search', async () => {
|
|
stubAssets()
|
|
const later = deferred()
|
|
let gets = 0
|
|
const { wrapper, calls } = await mountApp(
|
|
'/acme/demo/widgets',
|
|
listRoutes({
|
|
[`GET ${STATS}`]: () => (++gets === 1 ? { body: extensionPartialFixture } : later.route()),
|
|
[`POST ${BASE}/bulk-delete`]: { body: { data: { deleted: 2 }, meta: {} } },
|
|
}),
|
|
{ attach: true },
|
|
)
|
|
expect(requestsTo(calls, 'GET', STATS)).toHaveLength(1)
|
|
|
|
await wrapper.find('[data-list-search]').setValue('blue')
|
|
await wait(350)
|
|
await flushPromises()
|
|
expect(requestsTo(calls, 'GET', STATS)).toHaveLength(1)
|
|
|
|
await wrapper.find('th[data-select] [role="checkbox"]').trigger('click')
|
|
await wrapper.find('[data-action="delete"]').trigger('click')
|
|
await flushPromises()
|
|
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLButtonElement).click()
|
|
await flushPromises()
|
|
|
|
expect(requestsTo(calls, 'GET', STATS)).toHaveLength(2)
|
|
const host = wrapper.find('[data-partial="header"]')
|
|
expect(host.attributes('aria-busy')).toBe('true')
|
|
expect(host.find('dl.summer-stats').exists()).toBe(true)
|
|
expect(wrapper.find('[data-partial-skeleton]').exists()).toBe(false)
|
|
|
|
later.release({ body: { data: { nodes: [{ tag: 'p', children: [{ text: 'Nothing left' }] }] }, meta: {} } })
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-partial="header"]').attributes('aria-busy')).toBeUndefined()
|
|
expect(wrapper.find('[data-partial="header"]').text()).toBe('Nothing left')
|
|
})
|
|
})
|
|
|
|
describe('form partial (D-09, UI-SPEC S2)', () => {
|
|
it('is a registered, valueless, group-labelled type', () => {
|
|
expect(rendererFor('partial')).toBe(PartialField)
|
|
expect(isRegistered('partial')).toBe(false)
|
|
expect(needsRecord('partial')).toBe(false)
|
|
expect(groupLabelled('partial')).toBe(true)
|
|
expect(groupLabelled('widget')).toBe(true)
|
|
expect(groupLabelled('text')).toBe(false)
|
|
})
|
|
|
|
it('renders on update with ?id= inside a labelled group', async () => {
|
|
stubAssets()
|
|
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes())
|
|
await flushPromises()
|
|
const [get] = requestsTo(calls, 'GET', SUMMARY)
|
|
expect(queryOf(get!).get('id')).toBe('1')
|
|
const group = wrapper.find('[data-partial-field]')
|
|
expect(group.attributes('role')).toBe('group')
|
|
expect(group.attributes('aria-labelledby')).toBe('field-summary-label')
|
|
expect(wrapper.find('#field-summary-label').text()).toBe('Summary')
|
|
expect(group.find('[data-partial="field"]').text()).toBe('Summary of the widget')
|
|
})
|
|
|
|
it('renders on create without an id, shows its skeleton and no label row without a label', async () => {
|
|
stubAssets()
|
|
const schema = clone(extensionFormSchemaFixture)
|
|
schema.data.fields = schema.data.fields.map((field) => (field.name === 'summary' ? { ...field, label: undefined } : field))
|
|
const pending = deferred()
|
|
const { wrapper, calls } = await mountApp(
|
|
'/acme/demo/widgets/create',
|
|
routes({ [`GET ${BASE}/schema/form`]: { body: schema }, [`GET ${SUMMARY}`]: pending.route }),
|
|
)
|
|
const [get] = requestsTo(calls, 'GET', SUMMARY)
|
|
expect(queryOf(get!).has('id')).toBe(false)
|
|
const skeleton = wrapper.find('[data-partial-field] [data-partial-skeleton]')
|
|
expect(skeleton.classes()).toEqual(expect.arrayContaining(['h-[44px]', 'rounded-control', 'bg-skel']))
|
|
expect(wrapper.find('#field-summary-label').exists()).toBe(false)
|
|
expect(wrapper.find('[data-field="summary"] label').exists()).toBe(false)
|
|
expect(wrapper.find('[data-partial-field]').attributes('aria-labelledby')).toBeUndefined()
|
|
|
|
pending.release({ body: { data: { nodes: [] }, meta: {} } })
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-partial-field]').text()).toBe('')
|
|
})
|
|
|
|
it('shows the failure box in its row and keeps the form saveable', async () => {
|
|
stubAssets()
|
|
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${SUMMARY}`]: { status: 500, body: {} } }))
|
|
await flushPromises()
|
|
expect(wrapper.find('[data-partial-field] [data-partial-failed]').text()).toBe(
|
|
'Nie udało się wczytać tej sekcji. Odśwież stronę, aby spróbować ponownie.',
|
|
)
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
const [put] = requestsTo(calls, 'PUT', RECORD)
|
|
expect(await put!.json()).toEqual({ name: 'Blue widget', color: '#0000ff' })
|
|
})
|
|
})
|