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:
@@ -1318,6 +1318,10 @@
|
||||
"description": "CurrencyFrom names the sibling column of the model whose value is the\nmoney field's currency code; the admin follows that field's value.",
|
||||
"type": "string"
|
||||
},
|
||||
"customTags": {
|
||||
"description": "CustomTags is a taglist field's customTags as fields.yaml declares it;\nnil means the default, true. With false only the options are accepted.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/components/schemas/cabana.jsonScalar"
|
||||
},
|
||||
@@ -1417,7 +1421,7 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"mode": {
|
||||
"description": "Mode is the fileupload mode (image or file, default file), the\ndatepicker mode (date, datetime or time, default datetime) or the\npermissioneditor mode (radio or checkbox).",
|
||||
"description": "Mode is the fileupload mode (image or file, default file), the\ndatepicker mode (date, datetime or time, default datetime), the\npermissioneditor mode (radio or checkbox) or the taglist mode (string,\nthe default, or array).",
|
||||
"type": "string"
|
||||
},
|
||||
"multiple": {
|
||||
@@ -1475,6 +1479,10 @@
|
||||
"description": "Scale is the most fraction digits a money value may carry, 2 to 4\n(default 4). A value with more digits is refused, never rounded.",
|
||||
"type": "integer"
|
||||
},
|
||||
"separator": {
|
||||
"description": "Separator joins the tags of a `type: taglist` field in string mode:\ncomma (the default) or space. Empty in array mode.",
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
15
admin/src/api/schema.d.ts
vendored
15
admin/src/api/schema.d.ts
vendored
@@ -5608,6 +5608,11 @@ export interface components {
|
||||
* money field's currency code; the admin follows that field's value.
|
||||
*/
|
||||
currencyFrom?: string;
|
||||
/**
|
||||
* @description CustomTags is a taglist field's customTags as fields.yaml declares it;
|
||||
* nil means the default, true. With false only the options are accepted.
|
||||
*/
|
||||
customTags?: boolean;
|
||||
default?: components["schemas"]["cabana.jsonScalar"];
|
||||
/**
|
||||
* @description Deferrable is true on a relation-manager field whose relation can be
|
||||
@@ -5675,8 +5680,9 @@ export interface components {
|
||||
minItems?: number;
|
||||
/**
|
||||
* @description Mode is the fileupload mode (image or file, default file), the
|
||||
* datepicker mode (date, datetime or time, default datetime) or the
|
||||
* permissioneditor mode (radio or checkbox).
|
||||
* datepicker mode (date, datetime or time, default datetime), the
|
||||
* permissioneditor mode (radio or checkbox) or the taglist mode (string,
|
||||
* the default, or array).
|
||||
*/
|
||||
mode?: string;
|
||||
multiple?: boolean;
|
||||
@@ -5716,6 +5722,11 @@ export interface components {
|
||||
* (default 4). A value with more digits is refused, never rounded.
|
||||
*/
|
||||
scale?: number;
|
||||
/**
|
||||
* @description Separator joins the tags of a `type: taglist` field in string mode:
|
||||
* comma (the default) or space. Empty in array mode.
|
||||
*/
|
||||
separator?: string;
|
||||
size?: string;
|
||||
span?: string;
|
||||
tab?: string;
|
||||
|
||||
156
admin/src/components/form/fields/TaglistField.vue
Normal file
156
admin/src/components/form/fields/TaglistField.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { X } from '@lucide/vue'
|
||||
import { t } from '../../../app/i18n'
|
||||
import { controlAttributes, controlClass, type FieldControlProps } from '../control'
|
||||
|
||||
// Tag list control for `type: taglist` (Phase 18.1 D-17, WinterCMS TagList).
|
||||
// The tags render as chips, each with a remove button, followed by a text
|
||||
// input that adds the trimmed text on Enter or on typing a comma; empty
|
||||
// text and a tag already in the list are ignored. The field's options are
|
||||
// offered as suggestions through a datalist. In array mode the value is a
|
||||
// list of strings. A value set from outside (a load, a reset after save)
|
||||
// replaces the chips; the control's own emits do not, so an untouched value
|
||||
// is never re-emitted. Tags render through text interpolation only.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
|
||||
|
||||
const readOnly = computed(() => {
|
||||
const attribute = props.field.attributes?.readonly
|
||||
return !!props.field.readOnly || (attribute !== undefined && attribute !== null && attribute !== false)
|
||||
})
|
||||
const attrs = computed(() => controlAttributes(props.field))
|
||||
const listId = computed(() => `${props.controlId}-options`)
|
||||
const suggestions = computed(() => (props.field.options ?? []).map((option) => option.label))
|
||||
|
||||
/** The tags of a stored value: a list of strings; anything else is no tags. */
|
||||
function tagsOf(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value.filter((tag): tag is string => typeof tag === 'string' && tag.trim() !== '')
|
||||
}
|
||||
|
||||
const tags = ref<string[]>(tagsOf(props.modelValue))
|
||||
const draft = ref('')
|
||||
let lastEmitted: unknown
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (value !== lastEmitted) {
|
||||
tags.value = tagsOf(value)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function commit(next: string[]): void {
|
||||
tags.value = next
|
||||
const value = [...next]
|
||||
lastEmitted = value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
/** Adds the draft's trimmed text as a tag unless it is empty or present. */
|
||||
function addDraft(): void {
|
||||
const tag = draft.value.trim()
|
||||
draft.value = ''
|
||||
if (tag === '' || tags.value.includes(tag)) {
|
||||
return
|
||||
}
|
||||
commit([...tags.value, tag])
|
||||
}
|
||||
|
||||
function remove(index: number): void {
|
||||
commit(tags.value.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
function onInput(event: Event): void {
|
||||
const text = (event.target as HTMLInputElement).value
|
||||
if (!text.includes(',')) {
|
||||
draft.value = text
|
||||
return
|
||||
}
|
||||
const parts = text.split(',')
|
||||
const rest = parts.pop() ?? ''
|
||||
for (const part of parts) {
|
||||
draft.value = part
|
||||
addDraft()
|
||||
}
|
||||
draft.value = rest
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
addDraft()
|
||||
} else if (event.key === 'Backspace' && draft.value === '' && tags.value.length > 0) {
|
||||
remove(tags.value.length - 1)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="readOnly"
|
||||
:id="controlId"
|
||||
data-taglist-readonly
|
||||
:aria-describedby="describedBy || undefined"
|
||||
class="flex min-h-input flex-wrap items-center gap-1.5 rounded-control border border-border bg-subtle px-3.5 py-2"
|
||||
>
|
||||
<template v-if="tags.length > 0">
|
||||
<span
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
data-taglist-chip
|
||||
class="inline-flex items-center rounded-full bg-surface px-2.5 py-0.5 text-[13px] text-text"
|
||||
>{{ tag }}</span
|
||||
>
|
||||
</template>
|
||||
<span v-else class="text-muted" data-empty>—</span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:class="controlClass(invalid)"
|
||||
class="flex min-h-input flex-wrap items-center gap-1.5 py-1.5 focus-within:border-primary focus-within:shadow-[0_0_0_3px_var(--c-ring)]"
|
||||
data-taglist
|
||||
>
|
||||
<span
|
||||
v-for="(tag, index) in tags"
|
||||
:key="tag"
|
||||
data-taglist-chip
|
||||
class="inline-flex items-center gap-1 rounded-full bg-subtle py-0.5 pr-1 pl-2.5 text-[13px] text-text"
|
||||
>
|
||||
<span>{{ tag }}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-taglist-remove
|
||||
:aria-label="t('backend::lang.taglist.remove', { tag })"
|
||||
class="inline-flex size-5 items-center justify-center rounded-full text-muted hover:bg-border hover:text-text"
|
||||
@click="remove(index)"
|
||||
>
|
||||
<X :size="12" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
:id="controlId"
|
||||
v-bind="attrs"
|
||||
:name="field.name"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:list="suggestions.length > 0 ? listId : undefined"
|
||||
:placeholder="t('backend::lang.taglist.add')"
|
||||
:value="draft"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
class="min-w-[8rem] flex-1 bg-transparent py-1 outline-none focus-visible:shadow-none"
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
@blur="addDraft"
|
||||
/>
|
||||
<datalist v-if="suggestions.length > 0" :id="listId">
|
||||
<option v-for="option in suggestions" :key="option" :value="option" />
|
||||
</datalist>
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,6 +30,9 @@
|
||||
// module always evaluates first and the binding is complete here.
|
||||
// Phase 18 adds the money control (D-07): an exact decimal string value
|
||||
// with a currency suffix, formatted by locale only for reading.
|
||||
// Phase 18.1 adds the taglist control (D-17): tags edited as chips with an
|
||||
// input that adds a tag on Enter or a comma, the field's options offered as
|
||||
// suggestions; the value is a list of strings (array mode).
|
||||
import type { Component } from 'vue'
|
||||
import type { FormField } from '../../api/types'
|
||||
import CheckboxField from './fields/CheckboxField.vue'
|
||||
@@ -51,6 +54,7 @@ import TextField from './fields/TextField.vue'
|
||||
import TextareaField from './fields/TextareaField.vue'
|
||||
import MarkdownField from './fields/MarkdownField.vue'
|
||||
import MoneyField from './fields/MoneyField.vue'
|
||||
import TaglistField from './fields/TaglistField.vue'
|
||||
import MLTextField from './fields/MLTextField.vue'
|
||||
import MLTextareaField from './fields/MLTextareaField.vue'
|
||||
import MLMarkdownField from './fields/MLMarkdownField.vue'
|
||||
@@ -93,6 +97,7 @@ const renderers = new Map<string, Component>([
|
||||
['richeditor', RichEditorField],
|
||||
['repeater', RepeaterField],
|
||||
['money', MoneyField],
|
||||
['taglist', TaglistField],
|
||||
])
|
||||
|
||||
/** Types whose control shows the label itself (toggle cards, relation manager). */
|
||||
|
||||
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