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:
Jakub Zych
2026-10-14 15:47:30 +02:00
parent 43d2fdd54a
commit 349bad5596
30 changed files with 1089 additions and 29 deletions

View 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>'])
})
})