test(10-05): bring every SPA module, composable and component under Vitest
- 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
This commit is contained in:
92
admin/tests/form/FormField.test.ts
Normal file
92
admin/tests/form/FormField.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import FieldRenderer from '../../src/components/form/FieldRenderer.vue'
|
||||
import FormErrorBanner from '../../src/components/form/FormErrorBanner.vue'
|
||||
import FormField from '../../src/components/form/FormField.vue'
|
||||
import { formField } from '../fixtures/typed'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('form field', () => {
|
||||
it('links the label to the control and hides the required asterisk from assistive tech', () => {
|
||||
const wrapper = mount(FormField, { props: { field: formField('name'), modelValue: 'Blue', idPrefix: 'f' } })
|
||||
const label = wrapper.find('label')
|
||||
expect(label.attributes('for')).toBe('f-name')
|
||||
expect(label.text()).toBe('Name *')
|
||||
expect(label.find('[aria-hidden="true"]').text()).toBe('*')
|
||||
const input = wrapper.find('#f-name')
|
||||
expect(input.attributes('aria-required')).toBe('true')
|
||||
expect(input.attributes('aria-invalid')).toBeUndefined()
|
||||
expect(input.attributes('aria-describedby')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('describes the control by its comment and, when invalid, by its error line', () => {
|
||||
const wrapper = mount(FormField, {
|
||||
props: { field: formField('code'), modelValue: '', errors: ['Taken', 'Too short'], idPrefix: 'f' },
|
||||
})
|
||||
const input = wrapper.find('#f-code')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(input.attributes('aria-describedby')).toBe('f-code-comment f-code-error')
|
||||
expect(wrapper.find('#f-code-comment').text()).toBe('Unique widget code')
|
||||
expect(wrapper.findAll('#f-code-error p').map((line) => line.text())).toEqual(['Taken', 'Too short'])
|
||||
})
|
||||
|
||||
it('uses the field name without a label and the default id prefix', () => {
|
||||
const field = { ...formField('code'), label: '', comment: undefined }
|
||||
const wrapper = mount(FormField, { props: { field, modelValue: '' } })
|
||||
expect(wrapper.find('label').text()).toBe('code')
|
||||
expect(wrapper.find('label').attributes('for')).toBe('field-code')
|
||||
})
|
||||
|
||||
it('lets a toggle card render its own label and still points at its comment', () => {
|
||||
const wrapper = mount(FormField, { props: { field: formField('active'), modelValue: false, errors: ['Bad'], idPrefix: 'f' } })
|
||||
expect(wrapper.findAll('label')).toHaveLength(1)
|
||||
expect(wrapper.find('label').attributes('for')).toBe('f-active')
|
||||
expect(wrapper.find('[role="switch"]').attributes('aria-describedby')).toBe('f-active-comment f-active-error')
|
||||
})
|
||||
|
||||
it('forwards value updates from the control', async () => {
|
||||
const wrapper = mount(FormField, { props: { field: formField('name'), modelValue: '', idPrefix: 'f' } })
|
||||
await wrapper.find('input').setValue('New')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['New']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('field renderer', () => {
|
||||
it('picks the control by type and forwards the shared props and updates', async () => {
|
||||
const wrapper = mount(FieldRenderer, {
|
||||
props: { field: formField('weight'), modelValue: 3, controlId: 'x', invalid: true, describedBy: 'x-error' },
|
||||
})
|
||||
const input = wrapper.find('input[type="number"]')
|
||||
expect(input.attributes('id')).toBe('x')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(input.attributes('aria-describedby')).toBe('x-error')
|
||||
await input.setValue('7')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[7]])
|
||||
})
|
||||
|
||||
it('renders the unsupported box for an unknown type', () => {
|
||||
const wrapper = mount(FieldRenderer, { props: { field: formField('color'), modelValue: '#fff', controlId: 'c' } })
|
||||
expect(wrapper.find('[data-unsupported-field]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('form error banner (D-09)', () => {
|
||||
it('is an alert with the plural field count that lists messages of non-field keys', () => {
|
||||
const wrapper = mount(FormErrorBanner, {
|
||||
props: { errors: { name: ['Required'], code: ['Taken'], _form: ['Record is locked'] }, fieldNames: ['name', 'code'] },
|
||||
})
|
||||
const banner = wrapper.find('[role="alert"]')
|
||||
expect(banner.text()).toContain('Nie udało się zapisać.')
|
||||
expect(banner.text()).toContain('Popraw 3 pola oznaczone poniżej.')
|
||||
expect(banner.findAll('p').at(-1)!.text()).toBe('Record is locked')
|
||||
})
|
||||
|
||||
it('renders nothing without errors', () => {
|
||||
const wrapper = mount(FormErrorBanner, { props: { errors: {}, fieldNames: ['name'] } })
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
52
admin/tests/form/FormGrid.test.ts
Normal file
52
admin/tests/form/FormGrid.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import FormGrid from '../../src/components/form/FormGrid.vue'
|
||||
import { formField } from '../fixtures/typed'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('form grid', () => {
|
||||
it('places fields by span: left, right, full by default, auto and row in the next slot', () => {
|
||||
const fields = [
|
||||
formField('name'),
|
||||
formField('code'),
|
||||
formField('notes'),
|
||||
{ ...formField('weight'), span: 'auto' },
|
||||
{ ...formField('size'), span: 'row' },
|
||||
{ ...formField('serial'), span: undefined },
|
||||
formField('members'),
|
||||
]
|
||||
const wrapper = mount(FormGrid, { props: { fields, values: {}, errors: {}, source: null, idPrefix: 'grid' } })
|
||||
const classes = (name: string) => wrapper.find(`[data-field="${name}"]`).classes()
|
||||
expect(classes('name')).toContain('min-[600px]:col-start-1')
|
||||
expect(classes('code')).toContain('min-[600px]:col-start-2')
|
||||
expect(classes('notes')).toContain('col-span-full')
|
||||
expect(classes('weight')).not.toContain('col-span-full')
|
||||
expect(classes('size')).not.toContain('col-span-full')
|
||||
expect(classes('serial')).toContain('col-span-full')
|
||||
// The relation manager always takes the whole row.
|
||||
expect(classes('members')).toContain('col-span-full')
|
||||
})
|
||||
|
||||
it('passes values, errors and labels down and emits updates by field name', async () => {
|
||||
const wrapper = mount(FormGrid, {
|
||||
props: {
|
||||
fields: [formField('name'), formField('owner')],
|
||||
values: { name: 'Blue', owner: 5 },
|
||||
errors: { name: ['Required'] },
|
||||
labels: { owner: [{ value: 5, label: 'dana@example.test' }] },
|
||||
source: null,
|
||||
idPrefix: 'grid',
|
||||
},
|
||||
})
|
||||
const input = wrapper.find('#grid-name')
|
||||
expect((input.element as HTMLInputElement).value).toBe('Blue')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.find('#grid-owner').text()).toBe('dana@example.test')
|
||||
await input.setValue('Red')
|
||||
expect(wrapper.emitted('update')).toEqual([['name', 'Red']])
|
||||
})
|
||||
})
|
||||
61
admin/tests/form/FormTabs.test.ts
Normal file
61
admin/tests/form/FormTabs.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||
import FormTabs from '../../src/components/form/FormTabs.vue'
|
||||
import type { TabItem } from '../../src/components/form/formState'
|
||||
import { keydown, resetState } from '../helpers'
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{ key: 'default', label: 'Podstawowe', errors: 0 },
|
||||
{ key: 'tab:Details', label: 'Details', errors: 2 },
|
||||
{ key: 'tab:Members', label: 'Members', errors: 1 },
|
||||
]
|
||||
|
||||
function mountTabs(modelValue = 'default') {
|
||||
return mount(FormTabs, { props: { tabs, modelValue, idPrefix: 'f' }, attachTo: document.body })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('form tabs', () => {
|
||||
it('is a tablist of tabs with roving tabindex, aria-selected and aria-controls', () => {
|
||||
const wrapper = mountTabs('tab:Details')
|
||||
expect(wrapper.find('[role="tablist"]').exists()).toBe(true)
|
||||
const items = wrapper.findAll('[role="tab"]')
|
||||
expect(items.map((tab) => tab.attributes('aria-selected'))).toEqual(['false', 'true', 'false'])
|
||||
expect(items.map((tab) => tab.attributes('tabindex'))).toEqual(['-1', '0', '-1'])
|
||||
expect(items.map((tab) => tab.attributes('id'))).toEqual(['f-tab-0', 'f-tab-1', 'f-tab-2'])
|
||||
expect(items.map((tab) => tab.attributes('aria-controls'))).toEqual(['f-panel-0', 'f-panel-1', 'f-panel-2'])
|
||||
})
|
||||
|
||||
it('badges tabs with invalid fields with a plural label', () => {
|
||||
const wrapper = mountTabs()
|
||||
const badges = wrapper.findAll('[data-tab-errors]')
|
||||
expect(badges.map((badge) => [badge.text(), badge.attributes('aria-label')])).toEqual([
|
||||
['2', '2 błędy'],
|
||||
['1', '1 błąd'],
|
||||
])
|
||||
})
|
||||
|
||||
it('selects on click and moves with the arrow keys, wrapping and moving focus', async () => {
|
||||
const wrapper = mountTabs()
|
||||
await wrapper.findAll('[role="tab"]')[2]!.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['tab:Members'])
|
||||
|
||||
const first = wrapper.findAll('[role="tab"]')[0]!.element
|
||||
expect(keydown(first, 'ArrowLeft').defaultPrevented).toBe(true)
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['tab:Members'])
|
||||
expect(document.activeElement?.id).toBe('f-tab-2')
|
||||
keydown(first, 'ArrowRight')
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual(['tab:Details'])
|
||||
expect(document.activeElement?.id).toBe('f-tab-1')
|
||||
expect(keydown(first, 'Enter').defaultPrevented).toBe(false)
|
||||
})
|
||||
})
|
||||
149
admin/tests/form/FormView.test.ts
Normal file
149
admin/tests/form/FormView.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
243
admin/tests/form/RelationField.test.ts
Normal file
243
admin/tests/form/RelationField.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import RelationField from '../../src/components/form/fields/RelationField.vue'
|
||||
import { formField, optionsFixture, recordFixture } from '../fixtures/typed'
|
||||
import { API, keydown, mockApi, queryOf, requestsTo, resetState, wait, type Route } from '../helpers'
|
||||
|
||||
const source = { vendor: 'acme', plugin: 'demo', controller: 'widgets' }
|
||||
const MAKER = `${API}/acme/demo/widgets/fields/maker/options`
|
||||
const TAGS = `${API}/acme/demo/widgets/fields/tags/options`
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${MAKER}`]: (request) => {
|
||||
const query = queryOf(request)
|
||||
if (query.get('search') === 'acme') {
|
||||
return { body: optionsFixture.makerFieldSearch }
|
||||
}
|
||||
return { body: query.get('page') === '2' ? optionsFixture.makerFieldPage2 : optionsFixture.makerField }
|
||||
},
|
||||
[`GET ${TAGS}`]: { body: optionsFixture.tagsField },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountField(name: string, modelValue: unknown, extra: Record<string, unknown> = {}) {
|
||||
return mount(RelationField, {
|
||||
props: {
|
||||
field: formField(name),
|
||||
modelValue,
|
||||
controlId: `r-${name}`,
|
||||
labels: recordFixture.meta.labels[name],
|
||||
source,
|
||||
...extra,
|
||||
},
|
||||
attachTo: document.body,
|
||||
})
|
||||
}
|
||||
|
||||
const combobox = (wrapper: VueWrapper) => wrapper.find('[role="combobox"]')
|
||||
const optionTexts = (wrapper: VueWrapper) => wrapper.findAll('[role="option"]').map((option) => option.text())
|
||||
|
||||
async function openList(wrapper: VueWrapper) {
|
||||
await combobox(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('read-only relation (D-26)', () => {
|
||||
it('shows only the label from meta.labels, with no control', () => {
|
||||
const wrapper = mountField('owner', 5, { describedBy: 'r-owner-comment' })
|
||||
const box = wrapper.find('[data-relation-readonly]')
|
||||
expect(box.text()).toBe('dana@example.test')
|
||||
expect(box.attributes('id')).toBe('r-owner')
|
||||
expect(box.attributes('aria-describedby')).toBe('r-owner-comment')
|
||||
expect(box.classes()).toContain('text-text')
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the emptyOption, then a muted dash', () => {
|
||||
const withEmpty = mountField('owner', null, { labels: [], field: { ...formField('owner'), emptyOption: 'Nobody' } })
|
||||
expect(withEmpty.find('[data-relation-readonly]').text()).toBe('Nobody')
|
||||
const bare = mountField('owner', null, { labels: undefined, field: { ...formField('owner'), emptyOption: undefined } })
|
||||
expect(bare.find('[data-relation-readonly]').text()).toBe('—')
|
||||
expect(bare.find('[data-relation-readonly]').classes()).toContain('text-muted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('single relation (D-17)', () => {
|
||||
it('is a collapsed combobox showing the current label', () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('maker', 2)
|
||||
const input = combobox(wrapper)
|
||||
expect(input.attributes('aria-expanded')).toBe('false')
|
||||
expect(input.attributes('aria-controls')).toBe('r-maker-listbox')
|
||||
expect(input.attributes('aria-autocomplete')).toBe('list')
|
||||
expect((input.element as HTMLInputElement).value).toBe('Bolt & Co')
|
||||
expect(wrapper.find('[role="listbox"]').attributes('id')).toBe('r-maker-listbox')
|
||||
})
|
||||
|
||||
it('loads the first page of 20 options on open, emptyOption first and muted', async () => {
|
||||
const calls = mockApi(routes())
|
||||
const wrapper = mountField('maker', 2)
|
||||
await openList(wrapper)
|
||||
const [request] = requestsTo(calls, 'GET', MAKER)
|
||||
expect(queryOf(request!).get('page')).toBe('1')
|
||||
expect(queryOf(request!).get('per_page')).toBe('20')
|
||||
expect(queryOf(request!).has('search')).toBe(false)
|
||||
expect(combobox(wrapper).attributes('aria-expanded')).toBe('true')
|
||||
expect(optionTexts(wrapper)).toEqual(['— none —', 'Acme Works', 'Bolt & Co'])
|
||||
const options = wrapper.findAll('[role="option"]')
|
||||
expect(options[0]!.attributes('data-value')).toBe('empty')
|
||||
expect(options[0]!.classes()).toContain('text-muted')
|
||||
expect(options.map((option) => option.attributes('aria-selected'))).toEqual(['false', 'false', 'true'])
|
||||
})
|
||||
|
||||
it('chooses an id or null and closes', async () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('maker', 2)
|
||||
await openList(wrapper)
|
||||
await wrapper.findAll('[role="option"]')[1]!.trigger('mousedown')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[1]])
|
||||
expect(combobox(wrapper).attributes('aria-expanded')).toBe('false')
|
||||
await openList(wrapper)
|
||||
await wrapper.findAll('[role="option"]')[0]!.trigger('mousedown')
|
||||
expect(wrapper.emitted('update:modelValue')!.at(-1)).toEqual([null])
|
||||
})
|
||||
|
||||
it('moves the active option with the arrows, exposes it as aria-activedescendant and picks it with Enter', async () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('maker', null)
|
||||
const input = combobox(wrapper).element
|
||||
expect(keydown(input, 'ArrowDown').defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
expect(combobox(wrapper).attributes('aria-activedescendant')).toBeUndefined()
|
||||
keydown(input, 'ArrowDown')
|
||||
keydown(input, 'ArrowDown')
|
||||
keydown(input, 'ArrowDown')
|
||||
keydown(input, 'ArrowDown')
|
||||
await flushPromises()
|
||||
expect(combobox(wrapper).attributes('aria-activedescendant')).toBe('r-maker-listbox-2')
|
||||
keydown(input, 'ArrowUp')
|
||||
await flushPromises()
|
||||
expect(combobox(wrapper).attributes('aria-activedescendant')).toBe('r-maker-listbox-1')
|
||||
expect(keydown(input, 'Enter').defaultPrevented).toBe(true)
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[1]])
|
||||
})
|
||||
|
||||
it('closes on Escape and blur, and ignores Enter and Escape while closed', async () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('maker', 2)
|
||||
const input = combobox(wrapper).element
|
||||
expect(keydown(input, 'Enter').defaultPrevented).toBe(false)
|
||||
expect(keydown(input, 'Escape').defaultPrevented).toBe(false)
|
||||
await openList(wrapper)
|
||||
expect(keydown(input, 'Escape').defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
expect(combobox(wrapper).attributes('aria-expanded')).toBe('false')
|
||||
await openList(wrapper)
|
||||
await combobox(wrapper).trigger('blur')
|
||||
expect(combobox(wrapper).attributes('aria-expanded')).toBe('false')
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('searches after a 300 ms debounce and loads more on demand', async () => {
|
||||
const calls = mockApi(routes())
|
||||
const wrapper = mountField('maker', null)
|
||||
await openList(wrapper)
|
||||
expect(wrapper.find('[data-action="more-options"]').exists()).toBe(true)
|
||||
await wrapper.find('[data-action="more-options"]').trigger('mousedown')
|
||||
await flushPromises()
|
||||
expect(optionTexts(wrapper)).toEqual(['— none —', 'Acme Works', 'Bolt & Co', 'Cog Ltd'])
|
||||
expect(queryOf(requestsTo(calls, 'GET', MAKER).at(-1)!).get('page')).toBe('2')
|
||||
expect(wrapper.find('[data-action="more-options"]').exists()).toBe(false)
|
||||
|
||||
await combobox(wrapper).setValue('acme')
|
||||
await wait(150)
|
||||
expect(requestsTo(calls, 'GET', MAKER)).toHaveLength(2)
|
||||
await wait(200)
|
||||
await flushPromises()
|
||||
expect(queryOf(requestsTo(calls, 'GET', MAKER).at(-1)!).get('search')).toBe('acme')
|
||||
expect(optionTexts(wrapper)).toEqual(['— none —', 'Acme Works'])
|
||||
})
|
||||
|
||||
it('loads more when the list is scrolled to its end', async () => {
|
||||
const calls = mockApi(routes())
|
||||
const wrapper = mountField('maker', null)
|
||||
await openList(wrapper)
|
||||
const list = wrapper.find('[role="listbox"]').element as HTMLElement
|
||||
Object.defineProperty(list, 'scrollHeight', { configurable: true, value: 400 })
|
||||
Object.defineProperty(list, 'clientHeight', { configurable: true, value: 256 })
|
||||
list.scrollTop = 10
|
||||
list.dispatchEvent(new Event('scroll'))
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', MAKER)).toHaveLength(1)
|
||||
list.scrollTop = 140
|
||||
list.dispatchEvent(new Event('scroll'))
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'GET', MAKER)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows "no options" for an empty or failed options load', async () => {
|
||||
mockApi(routes({ [`GET ${MAKER}`]: { status: 500, body: { error: { code: 'x', message: 'x', details: {} } } } }))
|
||||
const wrapper = mountField('maker', null)
|
||||
await openList(wrapper)
|
||||
expect(optionTexts(wrapper)).toEqual(['— none —'])
|
||||
expect(wrapper.find('[role="listbox"]').text()).toContain('Brak wyników')
|
||||
})
|
||||
|
||||
it('never requests options without a source controller', async () => {
|
||||
const calls = mockApi(routes())
|
||||
const wrapper = mountField('maker', null, { source: null })
|
||||
await openList(wrapper)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('multiple relation (D-18)', () => {
|
||||
it('shows ordered chips with initials and labelled remove buttons', async () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('tags', [3, 1])
|
||||
const chips = wrapper.findAll('[data-chip]')
|
||||
expect(chips.map((chip) => chip.find('.font-semibold').text())).toEqual(['Round', 'Small'])
|
||||
expect(chips[0]!.find('[aria-hidden="true"]').text()).toBe('R')
|
||||
expect(chips.map((chip) => chip.find('button').attributes('aria-label'))).toEqual(['Usuń: Round', 'Usuń: Small'])
|
||||
await chips[0]!.find('button').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[[1]]])
|
||||
})
|
||||
|
||||
it('offers only unselected options and appends the chosen id', async () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('tags', [3, 1])
|
||||
await openList(wrapper)
|
||||
expect(optionTexts(wrapper)).toEqual(['Large'])
|
||||
expect(wrapper.find('[role="option"]').attributes('aria-selected')).toBe('false')
|
||||
await wrapper.find('[role="option"]').trigger('mousedown')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[[3, 1, 4]]])
|
||||
})
|
||||
|
||||
it('removes the last chip on Backspace with an empty query', async () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('tags', [3, '1', 'x'])
|
||||
keydown(combobox(wrapper).element, 'Backspace')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[[3]]])
|
||||
const empty = mountField('tags', [])
|
||||
keydown(combobox(empty).element, 'Backspace')
|
||||
expect(empty.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('labels an unknown id with the id itself', () => {
|
||||
mockApi(routes())
|
||||
const wrapper = mountField('tags', [99], { labels: [] })
|
||||
expect(wrapper.find('[data-chip] .font-semibold').text()).toBe('99')
|
||||
})
|
||||
})
|
||||
111
admin/tests/form/Settings.test.ts
Normal file
111
admin/tests/form/Settings.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import SettingsFormView from '../../src/views/SettingsFormView.vue'
|
||||
import SettingsIndexView from '../../src/views/SettingsIndexView.vue'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { toasts } from '../../src/state/useToasts'
|
||||
import { settingsFixture } from '../fixtures/typed'
|
||||
import { API, mountApp, requestsTo, resetState, type Route } from '../helpers'
|
||||
|
||||
const PAGE = `${API}/settings/search`
|
||||
const failure = (status: number, message: string, details: Record<string, unknown> = {}) => ({
|
||||
status,
|
||||
body: { error: { code: 'x', message, details } },
|
||||
})
|
||||
|
||||
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${API}/settings`]: { body: settingsFixture.list },
|
||||
[`GET ${PAGE}/schema`]: { body: settingsFixture.schema },
|
||||
[`GET ${PAGE}`]: { body: settingsFixture.values },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function openPage(overrides: Record<string, Route> = {}) {
|
||||
setSettings(settingsFixture.list.data)
|
||||
return mountApp('/settings/search', routes(overrides))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('settings index (D-21)', () => {
|
||||
it('says so when no settings page is permitted', async () => {
|
||||
const { wrapper } = await mountApp('/settings', routes())
|
||||
expect(wrapper.findComponent(SettingsIndexView).exists()).toBe(true)
|
||||
expect(wrapper.find('h1').text()).toBe('Ustawienia')
|
||||
expect(wrapper.find('section > p').text()).toBe('Brak dostępnych ustawień.')
|
||||
})
|
||||
|
||||
it('links every page by encoded code with its label and description', async () => {
|
||||
setSettings([{ ...settingsFixture.list.data[0]!, code: 'a b', category: '', description: '' }])
|
||||
const { wrapper } = await mountApp('/settings', routes())
|
||||
const link = wrapper.find('[data-settings="a b"]')
|
||||
expect(link.attributes('href')).toBe('/settings/a%20b')
|
||||
expect(link.text()).toBe('Search')
|
||||
expect(wrapper.find('[data-settings-group] h2').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings form (D-21)', () => {
|
||||
it('titles the page from the entry and links back to the index', async () => {
|
||||
const { wrapper } = await openPage()
|
||||
expect(wrapper.findComponent(SettingsFormView).exists()).toBe(true)
|
||||
expect(wrapper.find('h1').text()).toBe('Search')
|
||||
expect(wrapper.find('header p').text()).toBe('Search engine options')
|
||||
expect(wrapper.find('[data-action="back"]').attributes('href')).toBe('/settings')
|
||||
expect(wrapper.find('[data-action="back"]').attributes('aria-label')).toBe('Wróć do ustawień')
|
||||
})
|
||||
|
||||
it('falls back to the schema name, then the code, without an entry', async () => {
|
||||
const { wrapper } = await mountApp('/settings/search', routes())
|
||||
expect(wrapper.find('h1').text()).toBe('Search')
|
||||
expect(wrapper.find('header p').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('announces a failed load and disables saving', async () => {
|
||||
const { wrapper } = await openPage({ [`GET ${PAGE}`]: failure(500, 'Boom') })
|
||||
expect(wrapper.find('[role="alert"]').text()).toBe('Nie udało się wczytać formularza.')
|
||||
expect(wrapper.find('form').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('toasts the server message for a refused save and the generic one for a network failure', async () => {
|
||||
const { wrapper } = await openPage({ [`PUT ${PAGE}`]: failure(403, 'Not allowed') })
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Not allowed', tone: 'danger' })
|
||||
|
||||
wrapper.unmount()
|
||||
const offline = await openPage({
|
||||
[`PUT ${PAGE}`]: () => {
|
||||
throw new TypeError('offline')
|
||||
},
|
||||
})
|
||||
await offline.wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(toasts.value[0]).toMatchObject({ text: 'Nie udało się zapisać zmian. Spróbuj ponownie.', tone: 'danger' })
|
||||
expect(offline.wrapper.find('[data-action="save"]').attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps a 422 onto fields, lists unknown keys in the banner, focuses the field and clears it on change', async () => {
|
||||
const { wrapper, calls } = await openPage({
|
||||
[`PUT ${PAGE}`]: failure(422, 'Invalid', { engine_host: ['Unreachable'], other: ['Global problem'] }),
|
||||
})
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'PUT', PAGE)).toHaveLength(1)
|
||||
const input = wrapper.find('#setting-engine_host')
|
||||
expect(input.attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.find('[data-error-banner]').text()).toContain('Global problem')
|
||||
await input.setValue('ok.local')
|
||||
expect(wrapper.find('#setting-engine_host').attributes('aria-invalid')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
215
admin/tests/form/fields.test.ts
Normal file
215
admin/tests/form/fields.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import CheckboxField from '../../src/components/form/fields/CheckboxField.vue'
|
||||
import DropdownField from '../../src/components/form/fields/DropdownField.vue'
|
||||
import NumberField from '../../src/components/form/fields/NumberField.vue'
|
||||
import SwitchField from '../../src/components/form/fields/SwitchField.vue'
|
||||
import TextareaField from '../../src/components/form/fields/TextareaField.vue'
|
||||
import TextField from '../../src/components/form/fields/TextField.vue'
|
||||
import UnsupportedField from '../../src/components/form/fields/UnsupportedField.vue'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import type { FormField } from '../../src/api/types'
|
||||
import { formField } from '../fixtures/typed'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
const common = { controlId: 'c', describedBy: 'c-comment' }
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
})
|
||||
|
||||
describe('text field', () => {
|
||||
it('is a required-aware text input that emits its value', async () => {
|
||||
const field = { ...formField('name'), attributes: { placeholder: 'Type', onclick: 'x' } }
|
||||
const wrapper = mount(TextField, { props: { field, modelValue: 'Blue', invalid: true, ...common } })
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes()).toMatchObject({
|
||||
id: 'c',
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
'aria-required': 'true',
|
||||
'aria-invalid': 'true',
|
||||
'aria-describedby': 'c-comment',
|
||||
placeholder: 'Type',
|
||||
})
|
||||
expect(input.attributes('required')).toBeDefined()
|
||||
expect(input.attributes('onclick')).toBeUndefined()
|
||||
expect(input.classes()).toContain('border-danger')
|
||||
await input.setValue('Red')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['Red']])
|
||||
})
|
||||
|
||||
it('shows an empty input for null and stringifies numbers', () => {
|
||||
const optional = formField('code')
|
||||
expect((mount(TextField, { props: { field: optional, modelValue: null, controlId: 'c' } }).find('input').element as HTMLInputElement).value).toBe('')
|
||||
const input = mount(TextField, { props: { field: optional, modelValue: 12, controlId: 'c' } }).find('input')
|
||||
expect((input.element as HTMLInputElement).value).toBe('12')
|
||||
expect(input.attributes('aria-required')).toBeUndefined()
|
||||
expect(input.attributes('required')).toBeUndefined()
|
||||
expect(input.attributes('aria-describedby')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('textarea field', () => {
|
||||
it.each([
|
||||
['tiny', '2'],
|
||||
['small', '4'],
|
||||
['large', '8'],
|
||||
['huge', '12'],
|
||||
['giant', '18'],
|
||||
[undefined, '5'],
|
||||
['odd', '5'],
|
||||
])('maps size %s to %s rows', (size, rows) => {
|
||||
const field: FormField = { ...formField('notes'), size }
|
||||
expect(mount(TextareaField, { props: { field, modelValue: 'x', controlId: 't' } }).find('textarea').attributes('rows')).toBe(rows)
|
||||
})
|
||||
|
||||
it('emits its text', async () => {
|
||||
const wrapper = mount(TextareaField, { props: { field: formField('notes'), modelValue: undefined, controlId: 't' } })
|
||||
expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe('')
|
||||
await wrapper.find('textarea').setValue('Long text')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['Long text']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('number field', () => {
|
||||
it('emits numbers and null for an empty input', async () => {
|
||||
const wrapper = mount(NumberField, { props: { field: formField('weight'), modelValue: 12, controlId: 'n' } })
|
||||
const input = wrapper.find('input')
|
||||
expect(input.attributes('type')).toBe('number')
|
||||
expect(input.attributes('inputmode')).toBe('decimal')
|
||||
expect((input.element as HTMLInputElement).value).toBe('12')
|
||||
await input.setValue('3.5')
|
||||
await input.setValue('')
|
||||
await input.setValue(' ')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[3.5], [null], [null]])
|
||||
})
|
||||
|
||||
it('emits null for a value that is not a finite number', () => {
|
||||
const wrapper = mount(NumberField, { props: { field: formField('weight'), modelValue: null, controlId: 'n' } })
|
||||
const input = wrapper.find('input').element as HTMLInputElement
|
||||
Object.defineProperty(input, 'value', { configurable: true, get: () => 'Infinity' })
|
||||
input.dispatchEvent(new Event('input'))
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('dropdown field', () => {
|
||||
const typed: FormField = {
|
||||
...formField('size'),
|
||||
options: [
|
||||
{ value: 's', label: 'Small' },
|
||||
{ value: 2, label: 'Two' },
|
||||
{ value: true, label: 'Yes' },
|
||||
],
|
||||
}
|
||||
|
||||
it('offers the emptyOption first and sends null for it', async () => {
|
||||
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: null, controlId: 'd' } })
|
||||
const select = wrapper.find('select')
|
||||
expect(select.findAll('option').map((option) => option.text())).toEqual(['— none —', 'Small', 'Two', 'Yes'])
|
||||
expect((select.element as HTMLSelectElement).value).toBe('empty')
|
||||
expect(select.classes()).toContain('text-muted')
|
||||
await select.setValue('empty')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[null]])
|
||||
})
|
||||
|
||||
it('keeps the JSON type of the chosen option value', async () => {
|
||||
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: 's', controlId: 'd' } })
|
||||
const select = wrapper.find('select')
|
||||
expect((select.element as HTMLSelectElement).value).toBe('0')
|
||||
expect(select.classes()).not.toContain('text-muted')
|
||||
await select.setValue('1')
|
||||
await select.setValue('2')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[2], [true]])
|
||||
})
|
||||
|
||||
it('matches a stored value by its string form', () => {
|
||||
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: '2', controlId: 'd' } })
|
||||
expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('1')
|
||||
})
|
||||
|
||||
it('shows a disabled placeholder without an emptyOption or a matching value', () => {
|
||||
const field: FormField = { ...typed, emptyOption: undefined, required: true }
|
||||
const wrapper = mount(DropdownField, { props: { field, modelValue: 'unknown', controlId: 'd', invalid: true } })
|
||||
const placeholder = wrapper.find('option[value="placeholder"]')
|
||||
expect(placeholder.text()).toBe('Wybierz…')
|
||||
expect(placeholder.attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('select').attributes('aria-required')).toBe('true')
|
||||
expect(wrapper.find('select').attributes('aria-invalid')).toBe('true')
|
||||
expect(mount(DropdownField, { props: { field, modelValue: 's', controlId: 'd' } }).find('option[value="placeholder"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the placeholder, not the empty option, for a stored value it does not know', () => {
|
||||
const wrapper = mount(DropdownField, { props: { field: typed, modelValue: 'zzz', controlId: 'd' } })
|
||||
expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('placeholder')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggle cards', () => {
|
||||
it('renders a switch with role=switch, aria-checked, its own label and comment', async () => {
|
||||
const wrapper = mount(SwitchField, { props: { field: formField('active'), modelValue: true, controlId: 's', describedBy: 's-comment' } })
|
||||
expect(wrapper.find('[data-toggle-card]').exists()).toBe(true)
|
||||
expect(wrapper.find('label').attributes('for')).toBe('s')
|
||||
expect(wrapper.find('label').text()).toBe('Active')
|
||||
expect(wrapper.find('#s-comment').text()).toBe('Shown in the catalogue')
|
||||
const control = wrapper.find('[role="switch"]')
|
||||
expect(control.attributes('id')).toBe('s')
|
||||
expect(control.attributes('type')).toBe('button')
|
||||
expect(control.attributes('aria-checked')).toBe('true')
|
||||
expect(control.attributes('aria-describedby')).toBe('s-comment')
|
||||
await control.trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
})
|
||||
|
||||
it('keeps a numeric switch value numeric and marks it invalid', async () => {
|
||||
const field = { ...formField('active'), required: true, label: '' }
|
||||
const wrapper = mount(SwitchField, { props: { field, modelValue: 0, controlId: 's', invalid: true } })
|
||||
expect(wrapper.find('[role="switch"]').attributes('aria-checked')).toBe('false')
|
||||
expect(wrapper.find('[role="switch"]').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.find('[data-toggle-card]').classes()).toContain('border-danger')
|
||||
expect(wrapper.find('label').text()).toBe('active *')
|
||||
await wrapper.find('[role="switch"]').trigger('click')
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[1]])
|
||||
})
|
||||
|
||||
it('renders a checkbox card that emits its checked state', async () => {
|
||||
const wrapper = mount(CheckboxField, { props: { field: formField('featured'), modelValue: '1', controlId: 'k' } })
|
||||
const box = wrapper.find('input[type="checkbox"]')
|
||||
expect((box.element as HTMLInputElement).checked).toBe(true)
|
||||
expect(wrapper.find('label').attributes('for')).toBe('k')
|
||||
expect(wrapper.find('#k-comment').text()).toBe('Pinned to the top')
|
||||
await box.setValue(false)
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
|
||||
const numeric = mount(CheckboxField, { props: { field: formField('featured'), modelValue: 0, controlId: 'k', invalid: true } })
|
||||
expect(numeric.find('input').attributes('aria-invalid')).toBe('true')
|
||||
await numeric.find('input').setValue(true)
|
||||
expect(numeric.emitted('update:modelValue')).toEqual([[1]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsupported field (D-05)', () => {
|
||||
it('names the type as code inside the translated sentence', () => {
|
||||
const wrapper = mount(UnsupportedField, { props: { field: formField('color'), modelValue: '#fff', controlId: 'u', describedBy: 'u-error' } })
|
||||
const box = wrapper.find('[data-unsupported-field]')
|
||||
expect(box.attributes('id')).toBe('u')
|
||||
expect(box.attributes('aria-describedby')).toBe('u-error')
|
||||
expect(box.text()).toBe('Nieobsługiwany typ pola: colorpicker')
|
||||
expect(box.find('code').text()).toBe('colorpicker')
|
||||
})
|
||||
|
||||
it('appends the type when the sentence has no :type placeholder', () => {
|
||||
setBundle({ 'backend::lang.form.unsupported_field': { other: 'Unsupported field' } }, 'en')
|
||||
const wrapper = mount(UnsupportedField, { props: { field: formField('color'), modelValue: null, controlId: 'u' } })
|
||||
expect(wrapper.text()).toBe('Unsupported field colorpicker')
|
||||
expect(wrapper.find('code').text()).toBe('colorpicker')
|
||||
})
|
||||
|
||||
it('renders a hostile type name as text', () => {
|
||||
const field = { ...formField('color'), type: '<img src=x onerror=alert(1)>' }
|
||||
const wrapper = mount(UnsupportedField, { props: { field, modelValue: null, controlId: 'u' } })
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.find('code').text()).toBe('<img src=x onerror=alert(1)>')
|
||||
})
|
||||
})
|
||||
92
admin/tests/form/formState.test.ts
Normal file
92
admin/tests/form/formState.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
contextAllows,
|
||||
DEFAULT_TAB,
|
||||
editablePayload,
|
||||
fieldErrors,
|
||||
focusField,
|
||||
initialValues,
|
||||
panelDomId,
|
||||
snapshot,
|
||||
tabDomId,
|
||||
tabOf,
|
||||
} from '../../src/components/form/formState'
|
||||
import { formField, formSchemaFixture } from '../fixtures/typed'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('form state helpers', () => {
|
||||
it('keys tabs by label with a default tab for untabbed fields', () => {
|
||||
expect(tabOf(formField('name'))).toBe(DEFAULT_TAB)
|
||||
expect(tabOf(formField('serial'))).toBe('tab:Details')
|
||||
expect(tabDomId('field', 2)).toBe('field-tab-2')
|
||||
expect(panelDomId('field', 2)).toBe('field-panel-2')
|
||||
})
|
||||
|
||||
it('applies Winter context semantics', () => {
|
||||
expect(contextAllows(formField('name'), 'create')).toBe(true)
|
||||
expect(contextAllows(formField('serial'), 'update')).toBe(true)
|
||||
expect(contextAllows(formField('serial'), 'create')).toBe(false)
|
||||
expect(contextAllows(formField('batch'), 'create')).toBe(true)
|
||||
expect(contextAllows(formField('batch'), 'update')).toBe(false)
|
||||
expect(contextAllows({ ...formField('name'), context: [] }, 'update')).toBe(true)
|
||||
expect(contextAllows({ ...formField('name'), context: null as unknown as undefined }, 'update')).toBe(true)
|
||||
})
|
||||
|
||||
it('sends only editable, registered fields that hold a value', () => {
|
||||
const fields = formSchemaFixture.data.fields
|
||||
const body = editablePayload(fields, {
|
||||
name: 'Blue',
|
||||
code: undefined,
|
||||
color: '#fff',
|
||||
owner: 5,
|
||||
members: [1],
|
||||
tags: [3],
|
||||
active: false,
|
||||
unknown_key: 'x',
|
||||
})
|
||||
expect(body).toEqual({ name: 'Blue', tags: [3], active: false })
|
||||
})
|
||||
|
||||
it('starts a new record from defaults, toggles off and empty multiple relations', () => {
|
||||
const fields = [
|
||||
{ ...formField('name'), default: 'Unnamed' },
|
||||
formField('active'),
|
||||
formField('featured'),
|
||||
formField('tags'),
|
||||
formField('maker'),
|
||||
{ ...formField('weight'), default: 0 },
|
||||
]
|
||||
expect(initialValues(fields)).toEqual({ name: 'Unnamed', active: false, featured: false, tags: [], weight: 0 })
|
||||
})
|
||||
|
||||
it('normalizes 422 details into string lists and drops empty entries', () => {
|
||||
expect(
|
||||
fieldErrors({
|
||||
name: ['Required', '', 3],
|
||||
code: 'Taken',
|
||||
empty: [],
|
||||
blank: '',
|
||||
nested: { a: 1 },
|
||||
}),
|
||||
).toEqual({ name: ['Required'], code: ['Taken'] })
|
||||
expect(fieldErrors(undefined)).toEqual({})
|
||||
})
|
||||
|
||||
it('snapshots records independently of key order', () => {
|
||||
expect(snapshot({ a: 1, b: [2] })).toBe(snapshot({ b: [2], a: 1 }))
|
||||
expect(snapshot({ a: 1 })).not.toBe(snapshot({ a: 2 }))
|
||||
})
|
||||
|
||||
it('focuses a control by its id, making a non-control focusable first', () => {
|
||||
document.body.innerHTML = '<input id="field-name"><div id="field-members"></div>'
|
||||
expect(focusField('field', 'name')).toBe(true)
|
||||
expect(document.activeElement?.id).toBe('field-name')
|
||||
expect(focusField('field', 'members')).toBe(true)
|
||||
expect(document.getElementById('field-members')?.getAttribute('tabindex')).toBe('-1')
|
||||
expect(document.activeElement?.id).toBe('field-members')
|
||||
expect(focusField('field', 'missing')).toBe(false)
|
||||
})
|
||||
})
|
||||
127
admin/tests/form/registry.test.ts
Normal file
127
admin/tests/form/registry.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import CheckboxField from '../../src/components/form/fields/CheckboxField.vue'
|
||||
import DropdownField from '../../src/components/form/fields/DropdownField.vue'
|
||||
import NumberField from '../../src/components/form/fields/NumberField.vue'
|
||||
import RelationField from '../../src/components/form/fields/RelationField.vue'
|
||||
import SwitchField from '../../src/components/form/fields/SwitchField.vue'
|
||||
import TextField from '../../src/components/form/fields/TextField.vue'
|
||||
import TextareaField from '../../src/components/form/fields/TextareaField.vue'
|
||||
import UnsupportedField from '../../src/components/form/fields/UnsupportedField.vue'
|
||||
import RelationManager from '../../src/components/relation/RelationManager.vue'
|
||||
import {
|
||||
controlAttributes,
|
||||
controlClass,
|
||||
isRegistered,
|
||||
needsRecord,
|
||||
ownsLabel,
|
||||
RELATION_MANAGER,
|
||||
rendererFor,
|
||||
toggleOn,
|
||||
toggleValue,
|
||||
} from '../../src/components/form/registry'
|
||||
import * as control from '../../src/components/form/control'
|
||||
import { formField } from '../fixtures/typed'
|
||||
|
||||
describe('field renderer registry (D-05)', () => {
|
||||
it.each([
|
||||
['text', TextField],
|
||||
['textarea', TextareaField],
|
||||
['number', NumberField],
|
||||
['dropdown', DropdownField],
|
||||
['switch', SwitchField],
|
||||
['checkbox', CheckboxField],
|
||||
['relation', RelationField],
|
||||
[RELATION_MANAGER, RelationManager],
|
||||
])('renders %s with its control', (type, component) => {
|
||||
expect(rendererFor(type)).toBe(component)
|
||||
})
|
||||
|
||||
it.each(['colorpicker', 'fileupload', '', 'TEXT', 'constructor'])('falls back to UnsupportedField for %j', (type) => {
|
||||
expect(rendererFor(type)).toBe(UnsupportedField)
|
||||
expect(isRegistered(type)).toBe(false)
|
||||
})
|
||||
|
||||
it('edits values of every control type except the record-bound relation manager', () => {
|
||||
expect(RELATION_MANAGER).toBe('relation-manager')
|
||||
for (const type of ['text', 'textarea', 'number', 'dropdown', 'switch', 'checkbox', 'relation']) {
|
||||
expect(isRegistered(type), type).toBe(true)
|
||||
expect(needsRecord(type), type).toBe(false)
|
||||
}
|
||||
expect(isRegistered(RELATION_MANAGER)).toBe(false)
|
||||
expect(needsRecord(RELATION_MANAGER)).toBe(true)
|
||||
})
|
||||
|
||||
it('lets toggle cards and the relation manager render their own label', () => {
|
||||
expect(ownsLabel('switch')).toBe(true)
|
||||
expect(ownsLabel('checkbox')).toBe(true)
|
||||
expect(ownsLabel(RELATION_MANAGER)).toBe(true)
|
||||
expect(ownsLabel('text')).toBe(false)
|
||||
expect(ownsLabel('relation')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('control attributes', () => {
|
||||
it('passes only allow-listed Winter attributes, lower-cased, and drops null and false', () => {
|
||||
const field = formField('name')
|
||||
field.attributes = {
|
||||
placeholder: 'Type…',
|
||||
MaxLength: 20,
|
||||
readonly: true,
|
||||
spellcheck: false,
|
||||
pattern: null,
|
||||
onclick: 'alert(1)',
|
||||
onfocus: 'x',
|
||||
style: 'color:red',
|
||||
class: 'evil',
|
||||
id: 'hijack',
|
||||
autofocus: true,
|
||||
}
|
||||
expect(controlAttributes(field)).toEqual({ placeholder: 'Type…', maxlength: 20, readonly: true })
|
||||
})
|
||||
|
||||
it('answers no attributes without a map', () => {
|
||||
expect(controlAttributes(formField('code'))).toEqual({})
|
||||
})
|
||||
|
||||
it('switches the border to danger when invalid', () => {
|
||||
expect(controlClass(true)).toContain('border-danger')
|
||||
expect(controlClass(false)).toContain('border-border-strong')
|
||||
expect(controlClass(undefined)).not.toContain('border-danger')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggle values', () => {
|
||||
it.each([
|
||||
[true, true],
|
||||
[1, true],
|
||||
['1', true],
|
||||
['true', true],
|
||||
['yes', true],
|
||||
[false, false],
|
||||
[0, false],
|
||||
['0', false],
|
||||
['', false],
|
||||
['False', false],
|
||||
[null, false],
|
||||
[undefined, false],
|
||||
])('%j is %s', (value, on) => {
|
||||
expect(toggleOn(value)).toBe(on)
|
||||
})
|
||||
|
||||
it('keeps a numeric stored value numeric and sends booleans otherwise', () => {
|
||||
expect(toggleValue(0, true)).toBe(1)
|
||||
expect(toggleValue(1, false)).toBe(0)
|
||||
expect(toggleValue(false, true)).toBe(true)
|
||||
expect(toggleValue('1', false)).toBe(false)
|
||||
expect(toggleValue(undefined, true)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('control module', () => {
|
||||
it('is what the registry re-exports, so field controls never import the registry', () => {
|
||||
expect(controlAttributes).toBe(control.controlAttributes)
|
||||
expect(controlClass).toBe(control.controlClass)
|
||||
expect(toggleOn).toBe(control.toggleOn)
|
||||
expect(toggleValue).toBe(control.toggleValue)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user