- 41 unit and component suites under admin/tests/{app,state,shell,list,form,relation,views,ui}
covering states and a11y roles; every src module is imported by a test
- typed fixture helper assigns each JSON fixture to its generated OpenAPI type
- fix: iconFor ignores inherited object members such as "constructor"
- fix: field controls import ./control instead of the registry (import cycle
left a renderer unregistered depending on module load order)
- fix: dropdown shows the placeholder for an unknown stored value next to an emptyOption
- fix: list announces a failed schema load even when the rows arrive after it
- tailwind no longer scans admin/tests; boardwalk/dist rebuilt
150 lines
6.5 KiB
TypeScript
150 lines
6.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
|
import FormView from '../../src/views/FormView.vue'
|
|
import { recordCrumb } from '../../src/state/useBreadcrumbs'
|
|
import { toasts } from '../../src/state/useToasts'
|
|
import { clone, formSchemaFixture, recordFixture } from '../fixtures/typed'
|
|
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
|
|
|
|
const BASE = `${API}/acme/demo/widgets`
|
|
const RECORD = `${BASE}/1`
|
|
const failure = (status: number, message: string) => ({ status, body: { error: { code: 'x', message, details: {} } } })
|
|
|
|
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
|
return {
|
|
[`GET ${BASE}/schema/form`]: { body: formSchemaFixture },
|
|
[`GET ${RECORD}`]: { body: recordFixture },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
/** A form schema of plain, untabbed fields. */
|
|
function flatSchema() {
|
|
const envelope = clone(formSchemaFixture)
|
|
envelope.data.fields = envelope.data.fields.filter((field) => ['name', 'code', 'weight'].includes(field.name))
|
|
return { body: envelope }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetState()
|
|
})
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
enableAutoUnmount(afterEach)
|
|
|
|
describe('form view', () => {
|
|
it('is the component of the record and create routes', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
|
expect(wrapper.findComponent(FormView).exists()).toBe(true)
|
|
})
|
|
|
|
it('titles a record by its first text field, with the update message as subtitle, and publishes the crumb', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
|
expect(wrapper.find('h1').text()).toBe('Blue widget')
|
|
expect(wrapper.find('header p').text()).toBe('Editing widget')
|
|
expect(recordCrumb.value).toBe('Blue widget')
|
|
wrapper.unmount()
|
|
expect(recordCrumb.value).toBe('')
|
|
})
|
|
|
|
it('titles a new record by the create message and offers no delete', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/create', routes())
|
|
expect(wrapper.find('h1').text()).toBe('New widget')
|
|
expect(wrapper.find('header p').exists()).toBe(false)
|
|
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
|
|
expect(recordCrumb.value).toBe('New widget')
|
|
})
|
|
|
|
it('makes the form the tab panel of the active tab', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
|
const form = wrapper.find('form')
|
|
expect(form.attributes('role')).toBe('tabpanel')
|
|
expect(form.attributes('id')).toBe('field-panel-0')
|
|
expect(form.attributes('aria-labelledby')).toBe('field-tab-0')
|
|
expect(wrapper.find('#field-tab-0').attributes('aria-controls')).toBe('field-panel-0')
|
|
})
|
|
|
|
it('has no tabs and no tab panel role for an untabbed form', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes({ [`GET ${BASE}/schema/form`]: flatSchema() }))
|
|
expect(wrapper.find('[role="tablist"]').exists()).toBe(false)
|
|
expect(wrapper.find('form').attributes('role')).toBeUndefined()
|
|
expect(wrapper.findAll('[data-field]').map((field) => field.attributes('data-field'))).toEqual(['name', 'code', 'weight'])
|
|
})
|
|
|
|
it.each([
|
|
['the record', { [`GET ${RECORD}`]: failure(404, 'Not found') }],
|
|
['the schema', { [`GET ${BASE}/schema/form`]: failure(500, 'Boom') }],
|
|
])('announces a failed load of %s and disables saving', async (_label, override) => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes(override))
|
|
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać formularza.')
|
|
expect(wrapper.find('form').exists()).toBe(false)
|
|
if (_label === 'the schema') {
|
|
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeDefined()
|
|
}
|
|
})
|
|
|
|
it('toasts the generic error when the save cannot reach the server and re-enables saving', async () => {
|
|
const { wrapper, calls } = await mountApp(
|
|
'/acme/demo/widgets/1',
|
|
routes({
|
|
[`PUT ${RECORD}`]: () => {
|
|
throw new TypeError('offline')
|
|
},
|
|
}),
|
|
)
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
|
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
|
|
expect(wrapper.find('[data-action="save"]').attributes('disabled')).toBeUndefined()
|
|
expect(wrapper.find('[data-action="save"]').text()).toBe('Zapisz')
|
|
})
|
|
|
|
it('toasts the server message when a delete is refused and stays on the record', async () => {
|
|
const { wrapper, router } = await mountApp(
|
|
'/acme/demo/widgets/1',
|
|
routes({ [`DELETE ${RECORD}`]: failure(409, 'Widget is in use') }),
|
|
{ attach: true },
|
|
)
|
|
await wrapper.find('[data-action="delete"]').trigger('click')
|
|
await flushPromises()
|
|
;(document.body.querySelector('[data-confirm-dialog] [data-action="confirm"]') as HTMLElement).click()
|
|
await flushPromises()
|
|
expect(toasts.value[0]).toMatchObject({ text: 'Widget is in use', tone: 'danger' })
|
|
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/1')
|
|
})
|
|
|
|
it('keeps a create on the form when the save fails', async () => {
|
|
const { wrapper, router } = await mountApp(
|
|
'/acme/demo/widgets/create',
|
|
routes({ [`POST ${BASE}`]: failure(500, 'Boom') }),
|
|
)
|
|
await wrapper.find('#field-name').setValue('New one')
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/create')
|
|
expect(toasts.value[0]).toMatchObject({ text: 'Boom', tone: 'danger' })
|
|
})
|
|
|
|
it('follows the default redirect when the form declares no create redirect', async () => {
|
|
const envelope = clone(formSchemaFixture)
|
|
envelope.data.redirects.create.redirect = ''
|
|
envelope.data.redirects.default = 'acme/demo/widgets/update/:id'
|
|
const { wrapper, router } = await mountApp(
|
|
'/acme/demo/widgets/create',
|
|
routes({
|
|
[`GET ${BASE}/schema/form`]: { body: envelope },
|
|
[`POST ${BASE}`]: { status: 201, body: { data: { id: 5, name: 'X' }, meta: { labels: {} } } },
|
|
[`GET ${BASE}/5`]: { body: { data: { id: 5, name: 'X' }, meta: { labels: {} } } },
|
|
}),
|
|
)
|
|
await wrapper.find('#field-name').setValue('X')
|
|
await wrapper.find('[data-action="save"]').trigger('click')
|
|
await flushPromises()
|
|
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/5')
|
|
})
|
|
})
|