feat(10.1-02): run registered toolbar actions and scope plugin CSS per controller
- ListToolbar renders server-filtered actions after delete as outline buttons, busy during their POST
- ListView posts {} to toolbar/{action}, toasts, reloads the list and refetches the header partial
- Lists load controller assets too, so other controllers' stylesheet links are disabled on every open
- Vite dev server proxies {prefix}/assets to summer serve; dist rebuilt
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
// 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
|
||||
// through the client allowlist, registered toolbar actions and stylesheets
|
||||
// scoped to their controller. 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'
|
||||
@@ -526,3 +527,113 @@ describe('form partial (D-09, UI-SPEC S2)', () => {
|
||||
expect(await put!.json()).toEqual({ name: 'Blue widget', color: '#0000ff' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('toolbar actions (D-12, UI-SPEC S4)', () => {
|
||||
const RECOUNT = `${BASE}/toolbar/recount`
|
||||
|
||||
it('renders a registered action after delete, enabled without a selection', async () => {
|
||||
stubAssets()
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', listRoutes())
|
||||
const cluster = wrapper.find('[data-action="delete"]').element.parentElement!
|
||||
const names = Array.from(cluster.querySelectorAll('[data-action]')).map((item) => item.getAttribute('data-action'))
|
||||
expect(names).toEqual(['delete', 'recount'])
|
||||
const recount = wrapper.find('[data-action="recount"]')
|
||||
expect(recount.text()).toBe('Recount')
|
||||
expect(recount.attributes('disabled')).toBeUndefined()
|
||||
expect(recount.attributes('aria-busy')).toBeUndefined()
|
||||
expect(recount.classes()).toContain('border-border-strong')
|
||||
expect(recount.find('svg').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="delete"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('posts {} while busy, toasts, reloads the list and refetches the header partial', async () => {
|
||||
stubAssets()
|
||||
const action = deferred()
|
||||
const { wrapper, calls } = await mountApp('/acme/demo/widgets', listRoutes({ [`POST ${RECOUNT}`]: action.route }))
|
||||
const lists = requestsTo(calls, 'GET', BASE).length
|
||||
const partials = requestsTo(calls, 'GET', STATS).length
|
||||
|
||||
await wrapper.find('[data-action="recount"]').trigger('click')
|
||||
await flushPromises()
|
||||
const recount = wrapper.find('[data-action="recount"]')
|
||||
expect(recount.attributes('disabled')).toBeDefined()
|
||||
expect(recount.attributes('aria-busy')).toBe('true')
|
||||
await recount.trigger('click')
|
||||
const [post, ...more] = requestsTo(calls, 'POST', RECOUNT)
|
||||
expect(more).toHaveLength(0)
|
||||
expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
expect(await post!.json()).toEqual({})
|
||||
|
||||
action.release({ body: { data: { message: 'Counts refreshed', fill: {} }, meta: {} } })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[role="status"]').text()).toContain('Counts refreshed')
|
||||
expect(requestsTo(calls, 'GET', BASE).length).toBe(lists + 1)
|
||||
expect(requestsTo(calls, 'GET', STATS).length).toBe(partials + 1)
|
||||
expect(wrapper.find('[data-action="recount"]').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.find('[data-action="recount"]').attributes('aria-busy')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never renders an action the admin may not run', async () => {
|
||||
stubAssets()
|
||||
const schema = clone(extensionListSchemaFixture)
|
||||
schema.data.toolbarActions = []
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', listRoutes({ [`GET ${BASE}/schema/list`]: { body: schema } }))
|
||||
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-action="recount"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-action="create"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows a danger toast with the server message or the framework text', async () => {
|
||||
stubAssets()
|
||||
let reply: Reply = { status: 500, body: { error: { code: 'server_error', message: 'Recount failed.', details: {} } } }
|
||||
const { wrapper, calls } = await mountApp('/acme/demo/widgets', listRoutes({ [`POST ${RECOUNT}`]: () => reply }))
|
||||
const lists = requestsTo(calls, 'GET', BASE).length
|
||||
|
||||
await wrapper.find('[data-action="recount"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-tone="danger"]').text()).toContain('Recount failed.')
|
||||
expect(requestsTo(calls, 'GET', BASE).length).toBe(lists)
|
||||
|
||||
reply = { status: 403, body: {} }
|
||||
await wrapper.find('[data-action="recount"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('[data-tone="danger"]')[0]!.text()).toContain('Nie udało się wykonać akcji. Spróbuj ponownie.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('controller stylesheets (D-14, UI-SPEC S6)', () => {
|
||||
it('disables the first controller stylesheet when another controller opens', async () => {
|
||||
const seen = stubAssets()
|
||||
const gadgets = `${API}/acme/demo/gadgets`
|
||||
const widgetsCss = '/admin-test/assets/acme/demo/css/scoped-widgets.css?v=1'
|
||||
const gadgetsCss = '/admin-test/assets/acme/demo/css/scoped-gadgets.css?v=1'
|
||||
const widgetsSchema = clone(extensionListSchemaFixture)
|
||||
widgetsSchema.data.assets.styles = [widgetsCss]
|
||||
const gadgetsSchema = clone(extensionListSchemaFixture)
|
||||
gadgetsSchema.data.assets.styles = [gadgetsCss]
|
||||
delete gadgetsSchema.data.headerPartial
|
||||
const { router } = await mountApp(
|
||||
'/acme/demo/widgets',
|
||||
listRoutes({
|
||||
[`GET ${BASE}/schema/list`]: { body: widgetsSchema },
|
||||
[`GET ${gadgets}/schema/list`]: { body: gadgetsSchema },
|
||||
[`GET ${gadgets}`]: { body: listRows },
|
||||
}),
|
||||
)
|
||||
const link = (url: string) => seen.links.find((item) => item.getAttribute('href') === url)
|
||||
expect(link(widgetsCss)!.getAttribute(OWNER_ATTRIBUTE)).toBe('acme.demo.widgets')
|
||||
expect(link(widgetsCss)!.disabled).toBe(false)
|
||||
|
||||
await router.push('/acme/demo/gadgets')
|
||||
await flushPromises()
|
||||
expect(link(gadgetsCss)!.getAttribute(OWNER_ATTRIBUTE)).toBe('acme.demo.gadgets')
|
||||
expect(link(gadgetsCss)!.disabled).toBe(false)
|
||||
expect(link(widgetsCss)!.disabled).toBe(true)
|
||||
|
||||
await router.push('/acme/demo/widgets')
|
||||
await flushPromises()
|
||||
expect(link(widgetsCss)!.disabled).toBe(false)
|
||||
expect(link(gadgetsCss)!.disabled).toBe(true)
|
||||
expect(seen.links.filter((item) => item.getAttribute('href') === widgetsCss)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user