feat(10.1-02): render header and form partials through an allowlisted node renderer
- 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
This commit is contained in:
@@ -24,6 +24,12 @@
|
||||
"action": "lookup",
|
||||
"actionLabel": "Look up",
|
||||
"fill": ["name"]
|
||||
},
|
||||
{
|
||||
"name": "summary",
|
||||
"label": "Summary",
|
||||
"type": "partial",
|
||||
"path": "summary"
|
||||
}
|
||||
],
|
||||
"messages": {
|
||||
|
||||
106
admin/tests/fixtures/extension.list-schema.json
vendored
Normal file
106
admin/tests/fixtures/extension.list-schema.json
vendored
Normal file
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"data": {
|
||||
"title": "Widgets",
|
||||
"recordsPerPage": 20,
|
||||
"perPageOptions": [],
|
||||
"showSearch": true,
|
||||
"showSetup": false,
|
||||
"showCheckboxes": true,
|
||||
"showSorting": true,
|
||||
"searchTerm": "",
|
||||
"recordUrl": "acme/demo/widgets/update/:id",
|
||||
"toolbarButtons": [
|
||||
"create",
|
||||
"delete"
|
||||
],
|
||||
"toolbarActions": [],
|
||||
"assets": {
|
||||
"scripts": [],
|
||||
"styles": [
|
||||
"/admin-test/assets/acme/demo/css/demo.css?v=abc"
|
||||
]
|
||||
},
|
||||
"columns": [
|
||||
{
|
||||
"key": "name",
|
||||
"label": "Name",
|
||||
"searchable": true,
|
||||
"sortable": true
|
||||
},
|
||||
{
|
||||
"key": "code",
|
||||
"label": "Code",
|
||||
"searchable": true,
|
||||
"sortable": true
|
||||
},
|
||||
{
|
||||
"key": "tags",
|
||||
"label": "Tags",
|
||||
"searchable": false,
|
||||
"sortable": false,
|
||||
"type": "relation",
|
||||
"relation": "tags",
|
||||
"select": "name"
|
||||
},
|
||||
{
|
||||
"key": "created_at",
|
||||
"label": "Created",
|
||||
"searchable": false,
|
||||
"sortable": true,
|
||||
"type": "datetime"
|
||||
},
|
||||
{
|
||||
"key": "active",
|
||||
"label": "Active",
|
||||
"searchable": false,
|
||||
"sortable": false,
|
||||
"type": "switch"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"rowActions": [],
|
||||
"bulkActions": [],
|
||||
"messages": {
|
||||
"create": {
|
||||
"other": "New widget"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"one": "Delete :count widget?",
|
||||
"other": "Delete :count widgets?"
|
||||
},
|
||||
"deleteSelected": {
|
||||
"other": "Delete selected"
|
||||
},
|
||||
"deleted": {
|
||||
"one": "Deleted :count widget",
|
||||
"other": "Deleted :count widgets"
|
||||
},
|
||||
"empty": {
|
||||
"other": "No widgets yet."
|
||||
},
|
||||
"emptySearch": {
|
||||
"other": "Nothing found"
|
||||
},
|
||||
"emptySearchHint": {
|
||||
"other": "No widget matches “:term”."
|
||||
},
|
||||
"recordCount": {
|
||||
"one": ":count widget",
|
||||
"other": ":count widgets"
|
||||
},
|
||||
"searchPrompt": {
|
||||
"other": "Search widgets…"
|
||||
},
|
||||
"selected": {
|
||||
"other": "Selected :count"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
},
|
||||
"headerPartial": "stats"
|
||||
},
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
}
|
||||
}
|
||||
151
admin/tests/fixtures/extension.partial.json
vendored
Normal file
151
admin/tests/fixtures/extension.partial.json
vendored
Normal file
@@ -0,0 +1,151 @@
|
||||
{
|
||||
"data": {
|
||||
"nodes": [
|
||||
{
|
||||
"tag": "dl",
|
||||
"attrs": {
|
||||
"class": "summer-stats"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"tag": "div",
|
||||
"attrs": {
|
||||
"class": "summer-stat"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"tag": "dt",
|
||||
"attrs": {
|
||||
"class": "summer-stat__label"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"text": "All widgets"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag": "dd",
|
||||
"attrs": {
|
||||
"class": "summer-stat__value"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"text": "12"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag": "div",
|
||||
"attrs": {
|
||||
"class": "summer-stat"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"tag": "dt",
|
||||
"attrs": {
|
||||
"class": "summer-stat__label"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"text": "Small"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag": "dd",
|
||||
"attrs": {
|
||||
"class": "summer-stat__value"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"text": "5"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag": "script",
|
||||
"children": [
|
||||
{
|
||||
"text": "window.hijacked = true"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag": "p",
|
||||
"attrs": {
|
||||
"id": "hijack",
|
||||
"style": "color:red",
|
||||
"data-note": "kept"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"tag": "a",
|
||||
"attrs": {
|
||||
"href": "javascript:alert(1)",
|
||||
"onclick": "alert(1)",
|
||||
"class": "danger-link"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"text": "Unsafe link"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"tag": "a",
|
||||
"attrs": {
|
||||
"href": "/admin-test/acme/demo/widgets",
|
||||
"title": "All widgets"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"text": "Safe link"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"tag": "a",
|
||||
"attrs": {
|
||||
"href": "//evil.example.test/x"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"text": "Protocol-relative link"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag": "custom-box",
|
||||
"children": [
|
||||
{
|
||||
"tag": "p",
|
||||
"attrs": {
|
||||
"class": "unwrapped"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"text": "Use <b>bold</b> & <i>italic</i>"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"meta": {}
|
||||
}
|
||||
11
admin/tests/fixtures/typed.ts
vendored
11
admin/tests/fixtures/typed.ts
vendored
@@ -8,6 +8,8 @@ import langJson from './lang.json'
|
||||
import navigationJson from './navigation.json'
|
||||
import settingsJson from './settings.json'
|
||||
import extensionFormSchemaJson from './extension.form-schema.json'
|
||||
import extensionListSchemaJson from './extension.list-schema.json'
|
||||
import extensionPartialJson from './extension.partial.json'
|
||||
import formSchemaJson from './widgets.form-schema.json'
|
||||
import listJson from './widgets.list.json'
|
||||
import listSchemaJson from './widgets.list-schema.json'
|
||||
@@ -30,6 +32,15 @@ export const settingsFixture: {
|
||||
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
|
||||
/** A list with a header partial and a controller stylesheet (Phase 10.1). */
|
||||
export const extensionListSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = extensionListSchemaJson
|
||||
/**
|
||||
* A rendered partial with a stats strip and hostile nodes the client drops.
|
||||
* An assertion, not an annotation: TypeScript widens sibling attrs objects of
|
||||
* a JSON array with `key?: undefined`, which no string index signature
|
||||
* accepts. The assertion still refuses a fixture that does not overlap.
|
||||
*/
|
||||
export const extensionPartialFixture = extensionPartialJson as S['cabana.Envelope-cabana_PartialView']
|
||||
export const listFixture: Rows = listJson
|
||||
export const listSchemaFixture: S['cabana.Envelope-cabana_ListSchema'] = listSchemaJson
|
||||
export const optionsFixture: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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.
|
||||
// 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 {
|
||||
@@ -20,15 +21,33 @@ import {
|
||||
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 { 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'
|
||||
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: {} } }
|
||||
|
||||
@@ -72,6 +91,7 @@ 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 },
|
||||
}),
|
||||
@@ -80,7 +100,7 @@ function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
}
|
||||
|
||||
/** A route whose reply the test releases, to observe the in-flight state. */
|
||||
function deferred(): { route: Route; release: (reply: Reply) => void } {
|
||||
function deferred(): { route: () => Promise<Reply>; release: (reply: Reply) => void } {
|
||||
let release: (reply: Reply) => void = () => undefined
|
||||
const pending = new Promise<Reply>((resolve) => {
|
||||
release = resolve
|
||||
@@ -300,3 +320,209 @@ describe('widget field (D-04, D-05, D-07, D-08)', () => {
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user