feat(18.1-01): task 1 taglist field in array mode end to end
- cabana: core type taglist (mode string|array, separator, customTags, options as a list of tags), column type gate, save-time normalisation (trim, drop empties, dedupe) and taglist.invalid 422, record hydration - FormField gains separator and customTags (omitempty); OpenAPI and schema.d.ts regenerated - admin: TaglistField chip input with suggestions, registry entry, vitest - acme.catalog fixture with TestTaglistFieldCompile and TestTaglistFieldSmoke - docs: forms.md Taglist section, cabana README; rebuilt dist
This commit is contained in:
118
admin/tests/form/TaglistField.test.ts
Normal file
118
admin/tests/form/TaglistField.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
// Taglist control (Phase 18.1 D-17), the component on its own. No
|
||||
// application names appear in framework tests.
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { enableAutoUnmount, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import type { FormField } from '../../src/api/types'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import TaglistField from '../../src/components/form/fields/TaglistField.vue'
|
||||
import { langFixture } from '../fixtures/typed'
|
||||
import { resetState } from '../helpers'
|
||||
|
||||
const strings = {
|
||||
'backend::lang.taglist.add': { other: 'Add a tag' },
|
||||
'backend::lang.taglist.remove': { other: 'Remove :tag' },
|
||||
}
|
||||
|
||||
function taglistField(extra: Partial<FormField> = {}): FormField {
|
||||
return {
|
||||
name: 'tags',
|
||||
type: 'taglist',
|
||||
label: 'Tags',
|
||||
mode: 'array',
|
||||
options: [
|
||||
{ value: 'alpha', label: 'alpha' },
|
||||
{ value: 'beta', label: 'beta' },
|
||||
],
|
||||
...extra,
|
||||
} as FormField
|
||||
}
|
||||
|
||||
function mountField(props: Record<string, unknown> = {}) {
|
||||
const field = { ...taglistField(), ...((props.field as Partial<FormField>) ?? {}) } as FormField
|
||||
return mount(TaglistField, {
|
||||
props: { modelValue: null, controlId: 'f-tags', ...props, field },
|
||||
attachTo: document.body,
|
||||
})
|
||||
}
|
||||
|
||||
const input = (wrapper: VueWrapper) => wrapper.find<HTMLInputElement>('input')
|
||||
const chips = (wrapper: VueWrapper) => wrapper.findAll('[data-taglist-chip]').map((chip) => chip.text())
|
||||
const emitted = (wrapper: VueWrapper) => (wrapper.emitted('update:modelValue') ?? []).map((args) => args[0])
|
||||
|
||||
async function type(wrapper: VueWrapper, text: string): Promise<void> {
|
||||
input(wrapper).element.value = text
|
||||
await input(wrapper).trigger('input')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetState()
|
||||
setBundle({ ...langFixture.data, ...strings }, 'en')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('TaglistField', () => {
|
||||
it('renders a loaded list as chips and does not re-emit it untouched', async () => {
|
||||
const wrapper = mountField({ modelValue: ['a', 'b'] })
|
||||
await nextTick()
|
||||
expect(chips(wrapper)).toEqual(['a', 'b'])
|
||||
expect(emitted(wrapper)).toEqual([])
|
||||
expect(wrapper.find('datalist').findAll('option').map((o) => o.attributes('value'))).toEqual(['alpha', 'beta'])
|
||||
expect(input(wrapper).attributes('placeholder')).toBe('Add a tag')
|
||||
})
|
||||
|
||||
it('adds the typed tag on Enter and ignores a duplicate or empty text', async () => {
|
||||
const wrapper = mountField({ modelValue: ['a', 'b'] })
|
||||
await type(wrapper, ' c ')
|
||||
await input(wrapper).trigger('keydown', { key: 'Enter' })
|
||||
expect(emitted(wrapper)).toEqual([['a', 'b', 'c']])
|
||||
expect(input(wrapper).element.value).toBe('')
|
||||
await type(wrapper, 'a')
|
||||
await input(wrapper).trigger('keydown', { key: 'Enter' })
|
||||
await type(wrapper, ' ')
|
||||
await input(wrapper).trigger('keydown', { key: 'Enter' })
|
||||
expect(emitted(wrapper)).toEqual([['a', 'b', 'c']])
|
||||
})
|
||||
|
||||
it('adds a tag on typing a comma', async () => {
|
||||
const wrapper = mountField({ modelValue: [] })
|
||||
await type(wrapper, 'x,')
|
||||
expect(emitted(wrapper)).toEqual([['x']])
|
||||
expect(input(wrapper).element.value).toBe('')
|
||||
})
|
||||
|
||||
it('removes a chip through its labelled remove button', async () => {
|
||||
const wrapper = mountField({ modelValue: ['a', 'b'] })
|
||||
const remove = wrapper.find('[data-taglist-remove]')
|
||||
expect(remove.attributes('aria-label')).toBe('Remove a')
|
||||
await remove.trigger('click')
|
||||
expect(emitted(wrapper)).toEqual([['b']])
|
||||
expect(chips(wrapper)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('replaces the chips when the value changes from outside', async () => {
|
||||
const wrapper = mountField({ modelValue: ['a'] })
|
||||
await wrapper.setProps({ modelValue: ['z', 'y'] })
|
||||
expect(chips(wrapper)).toEqual(['z', 'y'])
|
||||
})
|
||||
|
||||
it('renders read-only chips without an input or remove buttons', async () => {
|
||||
const wrapper = mountField({ modelValue: ['a', 'b'], field: { readOnly: true } })
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-taglist-remove]').exists()).toBe(false)
|
||||
expect(chips(wrapper)).toEqual(['a', 'b'])
|
||||
const empty = mountField({ modelValue: [], field: { readOnly: true } })
|
||||
expect(empty.find('[data-empty]').text()).toBe('—')
|
||||
})
|
||||
|
||||
it('renders a tag holding markup as text', async () => {
|
||||
const wrapper = mountField({ modelValue: ['<b>bold</b>'] })
|
||||
expect(wrapper.find('b').exists()).toBe(false)
|
||||
expect(chips(wrapper)).toEqual(['<b>bold</b>'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user