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>'])
|
||||
})
|
||||
})
|
||||
@@ -104,12 +104,13 @@ for _, f := range form.Fields {
|
||||
| `richeditor` | A WYSIWYG HTML editor for a host text column, sanitized on every save; see [Rich text](#rich-text). |
|
||||
| `repeater` | A list of rows, each with its own small form, stored as JSON in one column; see [Repeater](#repeater). |
|
||||
| `money` | An exact decimal amount with a currency suffix, never a float and never rounded; see [Money](#money). |
|
||||
| `taglist` | A list of tags edited as chips, stored as a JSON array or as one separator-joined string; see [Taglist](#taglist). |
|
||||
|
||||
The WinterCMS widgets that are not in this list (the code editor, the color picker, the media finder, the tag list and the others) are not provided. A field with one of those types stops the start-up, unless a plugin registers that alias as its own field type: plugins add field types with a custom element and Go code that decides what is stored; see [Plugin field types](field-types.md).
|
||||
The WinterCMS widgets that are not in this list (the code editor, the color picker, the media finder and the others) are not provided. A field with one of those types stops the start-up, unless a plugin registers that alias as its own field type: plugins add field types with a custom element and Go code that decides what is stored; see [Plugin field types](field-types.md).
|
||||
|
||||
### Field options
|
||||
|
||||
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields, `preset` on text and mltext fields, `dependsOn` on dropdowns (see [Dependent dropdowns](#dependent-dropdowns)) and `currency`, `currencyFrom`, `scale` and `max` on money fields (see [Money](#money)). WinterCMS keys outside this set, such as `readOnly`, `disabled` or `trigger`, are refused.
|
||||
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields, `preset` on text and mltext fields, `dependsOn` on dropdowns (see [Dependent dropdowns](#dependent-dropdowns)), `currency`, `currencyFrom`, `scale` and `max` on money fields (see [Money](#money)), and `mode`, `separator` and `customTags` on taglist fields (see [Taglist](#taglist)). WinterCMS keys outside this set, such as `readOnly`, `disabled` or `trigger`, are refused.
|
||||
|
||||
`context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted, and `context: preview` shows a field only on the [preview screen](#preview-screen). The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds.
|
||||
|
||||
@@ -504,7 +505,34 @@ A save checks every submitted money value before the transaction and answers a 4
|
||||
|
||||
`null` is accepted and stored as NULL (or the column type's empty value); `required: true` refuses it through the usual rule. A value the administrator did not edit is sent back as it was loaded, so opening and saving a form with a stored `85.4193` writes `85.4193` again, never `85.42`. The same checks run on settings forms and relation forms.
|
||||
|
||||
In the admin SPA the field is a text input in decimal mode, right-aligned in tabular numerals, with the ISO code as a suffix (omitted while a `currencyFrom` sibling is still empty). A loaded value is shown with its trailing zeros trimmed down to two decimals and the locale's decimal separator; typing accepts a comma or a dot and ignores spaces, and emits the canonical dot string. The error line shows on blur for a malformed or too-precise text, and the text is still sent as typed so the server's answer is the gate. Read-only and preview show the locale-formatted amount with the code (`PLN 39.84`, `39,84 PLN`) or an em dash when empty.
|
||||
In the admin SPA the field is a text input in decimal mode, right-aligned in tabular numerals, with the ISO code as a suffix (omitted while a `currencyFrom` sibling is still empty). A loaded value is shown with its trailing zeros trimmed down to two decimals and the locale's decimal separator; typing accepts a comma or a dot and ignores spaces, and emits the canonical dot string. The error line shows on blur for a malformed or too-precise text, and the text is still sent as typed so the server's answer is the gate. Read-only and preview show the locale-formatted amount with the code (`PLN 39.84`, `39,84 PLN`) or an em dash when empty.
|
||||
|
||||
## Taglist
|
||||
|
||||
`type: taglist` edits a list of tags, as the WinterCMS tag list does. In `mode: array` the value travels as a JSON array of strings (`["alpha", "beta"]`) and is stored as that JSON array in one column: the column is a type whose pointer implements `sql.Scanner` and that implements `driver.Valuer`, such as `lagoon.Jsonable[[]string]` over a `text` column. Any other column type stops the start-up naming the field.
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
features:
|
||||
label: acme.shop::lang.plans.features
|
||||
type: taglist
|
||||
mode: array
|
||||
options: [Support, Reports, Export]
|
||||
```
|
||||
|
||||
The field takes the generic keys plus:
|
||||
|
||||
| Key | Meaning |
|
||||
|-----|---------|
|
||||
| `mode` | `array` stores a JSON array of strings. |
|
||||
| `options` | A YAML list of tags offered as suggestions while typing. A mapping or a method name stops the start-up. |
|
||||
|
||||
`nameFrom`, `emptyOption`, `dependsOn`, `preset` and the money keys are refused on a taglist field, `separator` and `customTags` are refused on every other type, and a taglist inside a [repeater](#repeater) row stops the start-up.
|
||||
|
||||
A save normalises every submitted tag list before the transaction: each tag is trimmed, empty tags are dropped and a repeated tag is kept once, at its first position, so `[" alpha ", "beta", "alpha", ""]` stores `["alpha","beta"]`. `null` and an empty list store `[]`, never NULL. A value that is not a list of strings (a string, a number, a list holding a number) is a 422 on the field with `backend::lang.taglist.invalid`. A `required: true` taglist needs at least one tag. The record GET serves the stored list; a NULL or unreadable column is served as an empty list.
|
||||
|
||||
In the admin SPA the field shows its tags as chips, each with a remove button, followed by an input that adds the typed text as a tag on Enter or on typing a comma; empty text and a tag already in the list are ignored, and the options are offered as suggestions. Read-only shows the chips alone, or an em dash when empty.
|
||||
|
||||
## What a save may write
|
||||
|
||||
The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). Declared `mltext`, `mltextarea` and `mlmarkdown` locale maps are the exception: they are lifted first, as [Markdown and multilingual fields](#markdown-and-multilingual-fields). A [repeater](#repeater)'s list of rows is the other exception: it is decoded row by row before the projection. The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field. A controller implementing `pact.FormRules` supplies its own rule set per operation, which replaces the model's rules for admin saves.
|
||||
|
||||
File diff suppressed because one or more lines are too long
1
modules/boardwalk/dist/assets/index-CS4sv1WG.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-CS4sv1WG.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -7,8 +7,8 @@
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="48x48" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-DYW0Mjwc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-c6AgbZf1.css">
|
||||
<script type="module" crossorigin src="./assets/index-DBhf-zrM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CS4sv1WG.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -47,6 +47,7 @@ Use cases:
|
||||
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
|
||||
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
|
||||
- Money fields: a `type: money` field in `fields.yaml` edits an exact decimal amount that travels as a decimal string with a dot and at most `scale` (2 to 4, default 4) significant fraction digits (trailing zeros beyond the scale are accepted unchanged), or null; the server never parses it into a float, never rounds it and never truncates it, and fills the accepted string into a string column or a type with its own `sql.Scanner` and `driver.Valuer` unchanged. A field declares exactly one of `currency` (an ISO 4217 code) or `currencyFrom` (a sibling string column holding the code) and may declare `max` (the column ceiling); two sources, none, a scale outside 2..4, a max beyond the scale, a column of another type or a currencyFrom that is not a string column fails boot naming the field. A save answers 422 on the field with `backend::lang.money.invalid`, `money.too_precise` (`:scale`) or `money.too_large` (`:max`, compared as scaled digit strings), on controller forms, settings forms and relation forms alike. The schema serves `currency`, `currencyFrom`, `scale` and `max` on the field. A list column `type: money` takes the same `currency` or `currencyFrom` key, is never searchable and not sortable by default, and every list row carries the `currencyFrom` column's value under its own key even when it is not a declared column; the admin right-aligns the locale-formatted amount with its ISO code.
|
||||
- Tag lists: a `type: taglist` field in `fields.yaml` edits a list of tags (WinterCMS TagList). `mode: array` binds a JSON column (a type with `sql.Scanner` and `driver.Valuer`, such as `lagoon.Jsonable[[]string]`) and travels as a JSON array of strings; `mode: string`, the default, binds a string column. `options` is a YAML list of suggested tags; `separator` (`comma` or `space`, string mode only) and `customTags` are refused on other types, and an unknown mode or separator, options that are not a list, the money keys, `nameFrom`, `emptyOption`, `dependsOn`, `preset` or a column of the wrong Go type fails boot naming the field. A save trims every tag, drops empty ones and keeps a repeated tag once; null or an empty list stores `[]`, and a value that is not a list of strings is a 422 on the field with `backend::lang.taglist.invalid`. The record GET serves the stored list. The schema serves `mode`, `options`, `separator` and `customTags` on the field; the admin edits the tags as chips.
|
||||
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
|
||||
- Refusals: a lifecycle hook, a relation hook or a bulk, record, toolbar or widget action that returns a `cabana.ForbiddenError` is answered 403 `forbidden` with the error's `Message` and `Details` (a field name to a list of messages), both translated in the request locale; an empty `Message` stays empty. The surrounding transaction is rolled back. Every other error that is not a `cabana.ValidationError` stays the opaque 500, logged on the server.
|
||||
- Danger record actions: `pact.AdminRecordAction.Danger` is served as `danger` on the record response's `cabana.RecordAction` (absent when false, so existing payloads are unchanged); the admin's confirm dialog then uses its danger variant.
|
||||
@@ -295,6 +296,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
| `hydrateMLRecord` | Replaces host scalars of declared `mltext`/`mltextarea`/`mlmarkdown` fields on a Show/save record — host CRUD and relation-child create, update and show — with a locale map for every enabled code. Not used from list row projection. |
|
||||
| `cabana.RenderMarkdown` | Converts markdown source to HTML with the pinned goldmark engine without unsafe HTML; leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected. |
|
||||
| `cabana.FieldPreset` | A text or mltext field's `preset` in the form schema: the source field and the type, `slug` or `exact`. |
|
||||
| `cabana.FormField.Separator` / `cabana.FormField.CustomTags` | A `type: taglist` field's string-mode `separator` (`comma` or `space`, empty in array mode) and its `customTags` as `fields.yaml` declares it (nil means the default, true); both omitted from the JSON when unset. |
|
||||
| `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. |
|
||||
| `cabana.ValidationError` / `cabana.ListValidationError` | Field-level `validation_failed` errors. A `pact.AdminAction` may return a `cabana.ValidationError` to answer 422. |
|
||||
| `cabana.ForbiddenError` | A write controller code refuses: a hook or an action returns it and the route answers 403 `forbidden` with its localized `Message` and `Details`; the transaction is rolled back. |
|
||||
|
||||
245
modules/cabana/catalog_smoke_test.go
Normal file
245
modules/cabana/catalog_smoke_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// catalogDir is the neutral acme.catalog fixture plugin tree of the Phase
|
||||
// 18.1 cabana features (D-17): the taglist field and the flat reorder. No
|
||||
// application names appear in framework tests.
|
||||
const catalogDir = "testdata/catalog"
|
||||
|
||||
// catalogBadge is the fixture model of the badges controller: a name, a
|
||||
// taglist in array mode over a JSON column, a taglist in string mode over a
|
||||
// string column and a sort column. The table has no parent_id column.
|
||||
type catalogBadge struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
Tags lagoon.Jsonable[[]string] `gorm:"column:tags;type:text"`
|
||||
Keywords string `gorm:"column:keywords"`
|
||||
SortOrder int `gorm:"column:sort_order"`
|
||||
}
|
||||
|
||||
func (catalogBadge) TableName() string { return "acme_catalog_badges" }
|
||||
func (catalogBadge) Fillable() []string {
|
||||
return []string{"name", "tags", "keywords", "sort_order"}
|
||||
}
|
||||
func (catalogBadge) Rules() map[string]string { return map[string]string{"name": "required"} }
|
||||
|
||||
// catalogModels are the fixture tables, dropped and recreated per env.
|
||||
func catalogModels() []any { return []any{&catalogBadge{}} }
|
||||
|
||||
// catalogPlugin registers the badges controller.
|
||||
type catalogPlugin struct{}
|
||||
|
||||
func (catalogPlugin) ID() string { return "acme.catalog" }
|
||||
func (catalogPlugin) Requires() []string { return nil }
|
||||
func (catalogPlugin) Register(*backpack.App) error { return nil }
|
||||
func (catalogPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (catalogPlugin) AdminFS() fs.FS { return os.DirFS(catalogDir) }
|
||||
func (catalogPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{catalogBadges{}}
|
||||
}
|
||||
func (catalogPlugin) Permissions() []pact.Permission {
|
||||
return []pact.Permission{{Code: "acme.catalog.access", Roles: []string{"developer"}}}
|
||||
}
|
||||
|
||||
// catalogBadges is the badges controller.
|
||||
type catalogBadges struct{}
|
||||
|
||||
func (catalogBadges) ID() string { return "acme.catalog.badges" }
|
||||
func (catalogBadges) ModelName() string { return "Badge" }
|
||||
func (catalogBadges) ConfigDir() string { return "controllers/badges" }
|
||||
func (catalogBadges) RequiredPermissions() []string { return []string{"acme.catalog.access"} }
|
||||
func (catalogBadges) NewRecord() any { return &catalogBadge{} }
|
||||
|
||||
// catalogFSPlugin is the acme.catalog fixture served from another tree,
|
||||
// optionally with another record model.
|
||||
type catalogFSPlugin struct {
|
||||
catalogPlugin
|
||||
fsys fs.FS
|
||||
newRecord func() any
|
||||
}
|
||||
|
||||
func (p catalogFSPlugin) AdminFS() fs.FS { return p.fsys }
|
||||
func (p catalogFSPlugin) AdminControllers() []pact.AdminController {
|
||||
if p.newRecord != nil {
|
||||
return []pact.AdminController{catalogBadgesWith{newRecord: p.newRecord}}
|
||||
}
|
||||
return p.catalogPlugin.AdminControllers()
|
||||
}
|
||||
|
||||
// catalogBadgesWith is the badges controller over another record model.
|
||||
type catalogBadgesWith struct {
|
||||
catalogBadges
|
||||
newRecord func() any
|
||||
}
|
||||
|
||||
func (c catalogBadgesWith) NewRecord() any { return c.newRecord() }
|
||||
|
||||
// catalogOverlay is the acme.catalog fixture tree with files replaced; an
|
||||
// empty value removes the file.
|
||||
func catalogOverlay(t *testing.T, files map[string]string) fstest.MapFS {
|
||||
t.Helper()
|
||||
out := fstest.MapFS{}
|
||||
err := fs.WalkDir(os.DirFS(catalogDir), ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(catalogDir, path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out[path] = &fstest.MapFile{Data: raw}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, data := range files {
|
||||
if data == "" {
|
||||
delete(out, path)
|
||||
continue
|
||||
}
|
||||
out[path] = &fstest.MapFile{Data: []byte(data)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// catalogFile is one file of the fixture tree.
|
||||
func catalogFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(catalogDir, path))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// activateCatalog boots the admin of p without a database.
|
||||
func activateCatalog(t *testing.T, p party.Plugin) error {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-catalog-boot\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{p})
|
||||
return err
|
||||
}
|
||||
|
||||
// newCatalogEnv boots the acme.catalog fixture alone on Postgres and signs
|
||||
// an administrator in.
|
||||
func newCatalogEnv(t *testing.T) *conformEnv {
|
||||
t.Helper()
|
||||
gdb := adminGorm(t)
|
||||
if err := gdb.Migrator().DropTable(catalogModels()...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(catalogModels()...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
login := fmt.Sprintf("catalog-%d", time.Now().UnixNano())
|
||||
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
|
||||
h, err := paymentsAssemble(t, gdb, []party.Plugin{catalogPlugin{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := &conformEnv{h: h, db: gdb, login: login}
|
||||
if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": login, "password": adminTestPassword}, false); rec.Code != http.StatusOK {
|
||||
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// storedTagsText is the tags column of badge id as the database holds it.
|
||||
func storedTagsText(t *testing.T, e *conformEnv, id uint) *string {
|
||||
t.Helper()
|
||||
var text *string
|
||||
if err := e.db.Raw("SELECT tags FROM acme_catalog_badges WHERE id = ?", id).Scan(&text).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// expectCatalog422 posts body to the badges controller and wants a 422
|
||||
// whose detail on field contains message.
|
||||
func expectCatalog422(t *testing.T, e *conformEnv, body map[string]any, field, message string) {
|
||||
t.Helper()
|
||||
rec := e.send(t, http.MethodPost, "/acme/catalog/badges", body, true)
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("want 422 on %s, status=%d body=%s", field, rec.Code, rec.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Error struct {
|
||||
Details map[string][]string `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("422 body: %v\n%s", err, rec.Body.String())
|
||||
}
|
||||
msgs := out.Error.Details[field]
|
||||
if len(msgs) != 1 || !strings.Contains(msgs[0], message) {
|
||||
t.Fatalf("422 details on %s = %v, want %q; body=%s", field, msgs, message, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaglistFieldSmoke checks the taglist save path end to end on
|
||||
// Postgres: an array-mode list is trimmed, emptied of blanks and duplicates
|
||||
// and stored as exactly that JSON array, the record GET serves the list,
|
||||
// null stores [], and a value that is not a list of strings is a 422 on the
|
||||
// field with taglist.invalid (Polish, the request locale of the env).
|
||||
func TestTaglistFieldSmoke(t *testing.T) {
|
||||
e := newCatalogEnv(t)
|
||||
rec := e.send(t, http.MethodPost, "/acme/catalog/badges", map[string]any{"name": "First", "tags": []any{" alpha ", "beta", "alpha", ""}}, true)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
id := dataID(t, rec.Body.Bytes())
|
||||
if text := storedTagsText(t, e, id); text == nil || *text != `["alpha","beta"]` {
|
||||
t.Fatalf("stored tags = %v, want [\"alpha\",\"beta\"]", text)
|
||||
}
|
||||
show := e.send(t, http.MethodGet, fmt.Sprintf("/acme/catalog/badges/%d", id), nil, true)
|
||||
if show.Code != http.StatusOK {
|
||||
t.Fatalf("show status=%d body=%s", show.Code, show.Body.String())
|
||||
}
|
||||
var record struct {
|
||||
Data map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(show.Body.Bytes(), &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(record.Data["tags"]); got != `["alpha","beta"]` {
|
||||
t.Fatalf("served tags = %s, want [\"alpha\",\"beta\"]", got)
|
||||
}
|
||||
|
||||
expectCatalog422(t, e, map[string]any{"name": "Bad", "tags": "alpha"}, "tags", "Pole tags musi być listą tagów.")
|
||||
expectCatalog422(t, e, map[string]any{"name": "Bad", "tags": []any{1}}, "tags", "Pole tags musi być listą tagów.")
|
||||
|
||||
rec = e.send(t, http.MethodPost, "/acme/catalog/badges", map[string]any{"name": "Empty", "tags": nil}, true)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("null create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if text := storedTagsText(t, e, dataID(t, rec.Body.Bytes())); text == nil || *text != `[]` {
|
||||
t.Fatalf("null stored %v, want []", text)
|
||||
}
|
||||
}
|
||||
@@ -565,6 +565,9 @@ func (actionConflict) Error() string { return "cabana: action does not apply" }
|
||||
func projectFullRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, model any) (RecordResult, error) {
|
||||
data := projectRecord(cc, model)
|
||||
hydrateRepeaterFields(cc, data)
|
||||
if cc != nil && cc.Form != nil {
|
||||
hydrateTaglistFields(cc.Form.Fields, data)
|
||||
}
|
||||
meta, err := projectRelationFields(ctx, tx, cc, model, data)
|
||||
if err != nil {
|
||||
return RecordResult{}, err
|
||||
@@ -625,6 +628,15 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
if err := checkMoneyValues(ctx, s.tr, cc, in.Body, op); err != nil {
|
||||
return RecordResult{}, err
|
||||
}
|
||||
// D-17: a taglist value is normalised (trimmed, no empties, no
|
||||
// duplicates) and checked before the projection; an array-mode list
|
||||
// reaches its JSON column as JSON text.
|
||||
if cc != nil && cc.Form != nil {
|
||||
allows := func(name string) bool { return contextAllows(cc, name, op) }
|
||||
if err := checkTaglistValues(ctx, s.tr, cc.Form.Fields, in.Body, allows, !update); err != nil {
|
||||
return RecordResult{}, err
|
||||
}
|
||||
}
|
||||
// D-07: repeater rows are decoded in place (only nested keys, coerced
|
||||
// scalars, per-row required, row bounds) before the projection, which
|
||||
// lets a repeater's list through.
|
||||
@@ -1121,8 +1133,9 @@ func mergedRules(ctx context.Context, cc *CompiledController, model any, op stri
|
||||
// Relation fields are not writable columns. required stays on the
|
||||
// schema for the client, but it cannot be checked by Fill. A virtual
|
||||
// field is checked against its submitted value.
|
||||
// A repeater's required is checked on its rows (liftRepeaterValues).
|
||||
if field.Required && field.Type != repeaterType && (valueFormField(field) || cc.virtual[field.Name]) && contextAllows(cc, field.Name, op) {
|
||||
// A repeater's required is checked on its rows (liftRepeaterValues),
|
||||
// a taglist's on its tags (checkTaglistValues).
|
||||
if field.Required && field.Type != repeaterType && field.Type != taglistType && (valueFormField(field) || cc.virtual[field.Name]) && contextAllows(cc, field.Name, op) {
|
||||
out[field.Name] = mergeRequired(out[field.Name])
|
||||
}
|
||||
}
|
||||
@@ -1286,7 +1299,7 @@ func scalarFormField(typ string) bool {
|
||||
// valueFormField reports whether a controller form field binds to its model
|
||||
// column: a scalar built-in type or a plugin field type.
|
||||
func valueFormField(field FormField) bool {
|
||||
return scalarFormField(field.Type) || field.fieldType != nil || field.Type == repeaterType
|
||||
return scalarFormField(field.Type) || field.fieldType != nil || field.Type == repeaterType || field.Type == taglistType
|
||||
}
|
||||
|
||||
func protectedFillKey(key string) bool {
|
||||
|
||||
@@ -93,7 +93,7 @@ func TestDatepickerSmokeCompile(t *testing.T) {
|
||||
"minDate on time": {" day:\n type: datepicker\n mode: time\n minDate: 2026-01-01\n", "minDate is not valid with mode: time"},
|
||||
"min after max": {" starts_at:\n type: datepicker\n minDate: 2026-02-01\n maxDate: 2026-01-01\n", "is after maxDate"},
|
||||
"key on another type": {" title:\n type: text\n firstDay: 1\n", "firstDay is only valid on type: datepicker"},
|
||||
"mode on another type": {" title:\n type: text\n mode: date\n", "mode is only valid on type: fileupload, datepicker or permissioneditor"},
|
||||
"mode on another type": {" title:\n type: text\n mode: date\n", "mode is only valid on type: fileupload, datepicker, permissioneditor or taglist"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := activateFields(t, datepickerFields(tc.field))
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestDatepickerCompile(t *testing.T) {
|
||||
"ignoreTimezone not bool": {field("starts_at", " ignoreTimezone: maybe\n"), "ignoreTimezone:"},
|
||||
"ignoreTimezone on date": {field("released_on", " mode: date\n ignoreTimezone: true\n"), "ignoreTimezone is only valid with mode: datetime"},
|
||||
"key on another type": {" name2:\n type: text\n firstDay: 1\n", "firstDay is only valid on type: datepicker"},
|
||||
"mode on another type": {" name2:\n type: text\n mode: date\n", "mode is only valid on type: fileupload, datepicker or permissioneditor"},
|
||||
"mode on another type": {" name2:\n type: text\n mode: date\n", "mode is only valid on type: fileupload, datepicker, permissioneditor or taglist"},
|
||||
"date on a datetime": {field("starts_at", " mode: date\n"), "field starts_at: datepicker mode date needs a lagoon.Date or *lagoon.Date column, found *time.Time"},
|
||||
"datetime on a date": {field("released_on", " mode: datetime\n"), "datepicker mode datetime needs a time.Time or *time.Time column, found lagoon.Date"},
|
||||
"time on a date": {field("released_on", " mode: time\n"), "datepicker mode time needs a lagoon.TimeOfDay or *lagoon.TimeOfDay column, found lagoon.Date"},
|
||||
|
||||
@@ -76,8 +76,8 @@ func compileFileuploadKeys(typ string, values map[string]ast.Node, field *FormFi
|
||||
return fmt.Errorf("%s is only valid on type: fileupload", key)
|
||||
}
|
||||
}
|
||||
if _, ok := values["mode"]; ok && typ != "datepicker" && typ != permissionFieldType {
|
||||
return fmt.Errorf("mode is only valid on type: fileupload, datepicker or permissioneditor")
|
||||
if _, ok := values["mode"]; ok && typ != "datepicker" && typ != permissionFieldType && typ != taglistType {
|
||||
return fmt.Errorf("mode is only valid on type: fileupload, datepicker, permissioneditor or taglist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
312
modules/cabana/field_taglist.go
Normal file
312
modules/cabana/field_taglist.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/towel"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
)
|
||||
|
||||
// taglistType is the core tag list field type (WinterCMS TagList, Phase
|
||||
// 18.1 D-17). In `mode: array` its value travels as a JSON array of strings
|
||||
// and is stored in a JSON column (a type with its own sql.Scanner and
|
||||
// driver.Valuer, such as lagoon.Jsonable[[]string]); in `mode: string`, the
|
||||
// WinterCMS default, it travels as one string of tags joined by the
|
||||
// separator and is stored in a string column.
|
||||
const taglistType = "taglist"
|
||||
|
||||
// The taglist modes and separators (WinterCMS keys and values).
|
||||
const (
|
||||
taglistModeString = "string"
|
||||
taglistModeArray = "array"
|
||||
separatorComma = "comma"
|
||||
separatorSpace = "space"
|
||||
)
|
||||
|
||||
// Phrase keys of the taglist field's save errors (backend::lang.taglist.*).
|
||||
const (
|
||||
msgTaglistInvalid = "backend::lang.taglist.invalid"
|
||||
)
|
||||
|
||||
// taglistKeys are valid only on `type: taglist`.
|
||||
var taglistKeys = []string{"separator", "customTags"}
|
||||
|
||||
// taglistRefusedKeys are generic field keys with no meaning on a taglist
|
||||
// field, and the money keys.
|
||||
var taglistRefusedKeys = []string{"nameFrom", "emptyOption", "dependsOn", "preset", "currency", "currencyFrom", "scale", "max"}
|
||||
|
||||
// compileTaglistKeys decodes the keys of a `type: taglist` field: mode
|
||||
// (string, the default, or array), separator (comma, the default, or space;
|
||||
// string mode only), customTags (a boolean, default true) and options (a
|
||||
// YAML list of non-empty strings offered as suggestions). separator and
|
||||
// customTags are refused on every other type.
|
||||
func compileTaglistKeys(typ string, values map[string]ast.Node, field *FormField) error {
|
||||
if typ != taglistType {
|
||||
for _, key := range taglistKeys {
|
||||
if _, ok := values[key]; ok {
|
||||
return fmt.Errorf("%s is only valid on type: taglist", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, key := range taglistRefusedKeys {
|
||||
if _, ok := values[key]; ok {
|
||||
return fmt.Errorf("%s is not valid on type: taglist", key)
|
||||
}
|
||||
}
|
||||
field.Mode = taglistModeString
|
||||
if node, ok := values["mode"]; ok {
|
||||
mode, err := nodeString(node)
|
||||
if err != nil || (mode != taglistModeString && mode != taglistModeArray) {
|
||||
return fmt.Errorf("mode %q must be string or array on type: taglist", nodeText(node))
|
||||
}
|
||||
field.Mode = mode
|
||||
}
|
||||
if node, ok := values["separator"]; ok {
|
||||
if field.Mode == taglistModeArray {
|
||||
return fmt.Errorf("separator is only valid in mode: string on type: taglist")
|
||||
}
|
||||
sep, err := nodeString(node)
|
||||
if err != nil || (sep != separatorComma && sep != separatorSpace) {
|
||||
return fmt.Errorf("separator %q must be comma or space on type: taglist", nodeText(node))
|
||||
}
|
||||
field.Separator = sep
|
||||
} else if field.Mode == taglistModeString {
|
||||
field.Separator = separatorComma
|
||||
}
|
||||
if node, ok := values["customTags"]; ok {
|
||||
custom, err := nodeBool(node)
|
||||
if err != nil {
|
||||
return fmt.Errorf("customTags: %w", err)
|
||||
}
|
||||
field.CustomTags = &custom
|
||||
}
|
||||
if node, ok := values["options"]; ok {
|
||||
seq, ok := node.(*ast.SequenceNode)
|
||||
if !ok {
|
||||
return fmt.Errorf("options must be a list of tags on type: taglist")
|
||||
}
|
||||
items := sequenceValues(seq)
|
||||
options := make([]FormOption, 0, len(items))
|
||||
seen := map[string]bool{}
|
||||
for _, item := range items {
|
||||
tag, err := nodeString(unwrapNode(item))
|
||||
if err != nil || strings.TrimSpace(tag) == "" {
|
||||
return fmt.Errorf("options: %q is not a tag", nodeText(item))
|
||||
}
|
||||
tag = strings.TrimSpace(tag)
|
||||
if seen[tag] {
|
||||
return fmt.Errorf("options: duplicate tag %s", tag)
|
||||
}
|
||||
seen[tag] = true
|
||||
raw, err := json.Marshal(tag)
|
||||
if err != nil {
|
||||
return fmt.Errorf("options: %w", err)
|
||||
}
|
||||
options = append(options, FormOption{Value: jsonScalar{raw: raw}, Label: tag})
|
||||
}
|
||||
field.Options = options
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkTaglistType refuses a taglist field whose model column cannot hold
|
||||
// its value: in string mode a string (or *string) column, in array mode a
|
||||
// JSON column, that is a type whose pointer implements sql.Scanner and that
|
||||
// implements driver.Valuer (lagoon.Jsonable[[]string], or a named slice type
|
||||
// with those methods).
|
||||
func checkTaglistType(model any, field FormField) error {
|
||||
sf, ok := structFieldByColumn(model, field.Name)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %s is not a model column", field.Name)
|
||||
}
|
||||
if field.Mode == taglistModeArray {
|
||||
if !jsonColumnType(sf.Type) {
|
||||
return fmt.Errorf("field %s: taglist mode array needs a JSON column (sql.Scanner and driver.Valuer), found %s", field.Name, sf.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !stringColumnType(sf.Type) {
|
||||
return fmt.Errorf("field %s: taglist mode string needs a string column, found %s", field.Name, sf.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsonColumnType reports whether t can receive a JSON text through its
|
||||
// sql.Scanner and write it back through driver.Valuer.
|
||||
func jsonColumnType(t reflect.Type) bool {
|
||||
if t.Kind() == reflect.Pointer {
|
||||
t = t.Elem()
|
||||
}
|
||||
if t.Kind() == reflect.String {
|
||||
return false
|
||||
}
|
||||
ptr := reflect.PointerTo(t)
|
||||
return ptr.Implements(sqlScannerType) && (t.Implements(driverValuerType) || ptr.Implements(driverValuerType))
|
||||
}
|
||||
|
||||
// compileTaglistFields checks every taglist field of the controller's form
|
||||
// against its model column's Go type at boot.
|
||||
func compileTaglistFields(pluginID string, cc *CompiledController) error {
|
||||
if cc == nil || cc.Form == nil {
|
||||
return nil
|
||||
}
|
||||
var model any
|
||||
for _, field := range cc.Form.Fields {
|
||||
if field.Type != taglistType {
|
||||
continue
|
||||
}
|
||||
if model == nil {
|
||||
proto, err := newWritableModel(cc)
|
||||
if err != nil {
|
||||
return bootErr(pluginID, controllerID(cc), cc.Form.fieldsPath, fmt.Errorf("field %s: type taglist needs a fillable model", field.Name))
|
||||
}
|
||||
model = proto
|
||||
}
|
||||
if err := checkTaglistType(model, field); err != nil {
|
||||
return bootErr(pluginID, controllerID(cc), cc.Form.fieldsPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normaliseTags trims every tag, drops the empty ones and removes
|
||||
// duplicates, keeping the first occurrence. The result is never nil.
|
||||
func normaliseTags(tags []string) []string {
|
||||
out := make([]string, 0, len(tags))
|
||||
seen := make(map[string]bool, len(tags))
|
||||
for _, tag := range tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" || seen[tag] {
|
||||
continue
|
||||
}
|
||||
seen[tag] = true
|
||||
out = append(out, tag)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// taglistValue decodes one submitted taglist value of field into its
|
||||
// normalised tags: null is an empty list; in array mode the value must be a
|
||||
// list of strings. It returns the phrase key of a refused value, or "".
|
||||
func taglistValue(field FormField, value any) ([]string, string) {
|
||||
if value == nil {
|
||||
return []string{}, ""
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, msgTaglistInvalid
|
||||
}
|
||||
tags := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
tag, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, msgTaglistInvalid
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
return normaliseTags(tags), ""
|
||||
}
|
||||
|
||||
// checkTaglistValues decodes, before the save transaction, every submitted
|
||||
// array-mode taglist value of fields that allows (field name) accepts: the
|
||||
// normalised list replaces the body value as its JSON text (an empty list
|
||||
// is `[]`, never null), which the column's sql.Scanner reads. A value that
|
||||
// is not a list of strings is a 422 on the field with taglist.invalid; a
|
||||
// required taglist needs at least one tag when it is submitted, and on
|
||||
// create also when it is not.
|
||||
func checkTaglistValues(ctx context.Context, tr *phrasebook.Translator, fields []FormField, body map[string]any, allows func(string) bool, create bool) error {
|
||||
if body == nil {
|
||||
return nil
|
||||
}
|
||||
var details map[string]any
|
||||
fail := func(name, msg string) {
|
||||
if details == nil {
|
||||
details = map[string]any{}
|
||||
}
|
||||
details[name] = []string{msg}
|
||||
}
|
||||
var locCtx context.Context
|
||||
message := func(key string, params map[string]string) string {
|
||||
if locCtx == nil {
|
||||
locCtx = towel.WithLocale(ctx, schemaLocale(ctx, tr))
|
||||
}
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
return trParams(locCtx, tr, key, params)
|
||||
}
|
||||
for _, field := range fields {
|
||||
if field.Type != taglistType || (allows != nil && !allows(field.Name)) {
|
||||
continue
|
||||
}
|
||||
raw, present := body[field.Name]
|
||||
if !present {
|
||||
if field.Required && create {
|
||||
fail(field.Name, "The "+field.Name+" field is required.")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if field.Mode != taglistModeArray {
|
||||
continue
|
||||
}
|
||||
tags, key := taglistValue(field, raw)
|
||||
if key != "" {
|
||||
fail(field.Name, message(key, map[string]string{"field": field.Name}))
|
||||
continue
|
||||
}
|
||||
if field.Required && len(tags) == 0 {
|
||||
fail(field.Name, "The "+field.Name+" field is required.")
|
||||
continue
|
||||
}
|
||||
text, err := json.Marshal(tags)
|
||||
if err != nil {
|
||||
fail(field.Name, message(msgTaglistInvalid, map[string]string{"field": field.Name}))
|
||||
continue
|
||||
}
|
||||
body[field.Name] = string(text)
|
||||
}
|
||||
if len(details) > 0 {
|
||||
return &ValidationError{Details: details}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hydrateTaglistFields replaces the stored value of every array-mode
|
||||
// taglist field in a record response with its list of tags: a JSON column
|
||||
// is decoded from its JSON text; a missing, null or unparsable value is an
|
||||
// empty list.
|
||||
func hydrateTaglistFields(fields []FormField, data map[string]any) {
|
||||
if data == nil {
|
||||
return
|
||||
}
|
||||
for _, field := range fields {
|
||||
if field.Type != taglistType || field.Mode != taglistModeArray {
|
||||
continue
|
||||
}
|
||||
value, ok := data[field.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
data[field.Name] = storedTags(value)
|
||||
}
|
||||
}
|
||||
|
||||
// storedTags decodes a stored array-mode taglist value; anything that is not
|
||||
// a JSON list of strings is an empty list.
|
||||
func storedTags(value any) []string {
|
||||
raw, ok := storedJSON(value)
|
||||
if !ok {
|
||||
return []string{}
|
||||
}
|
||||
var tags []string
|
||||
if err := json.Unmarshal(raw, &tags); err != nil || tags == nil {
|
||||
return []string{}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
@@ -27,7 +27,7 @@ var (
|
||||
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
|
||||
"password": {}, "permissioneditor": {},
|
||||
"markdown": {}, "mltext": {}, "mltextarea": {}, "mlmarkdown": {},
|
||||
"richeditor": {}, "repeater": {}, "money": {},
|
||||
"richeditor": {}, "repeater": {}, "money": {}, "taglist": {},
|
||||
}
|
||||
formSpans = map[string]struct{}{
|
||||
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
|
||||
@@ -49,6 +49,7 @@ var (
|
||||
"form": {}, "maxItems": {}, "minItems": {}, "titleFrom": {},
|
||||
"groups": {}, "style": {},
|
||||
"currency": {}, "currencyFrom": {}, "scale": {}, "max": {},
|
||||
"separator": {}, "customTags": {},
|
||||
}
|
||||
// widgetKeys are valid only on `type: widget` (D-06).
|
||||
widgetKeys = []string{"widget", "action", "fill"}
|
||||
@@ -633,6 +634,9 @@ func compileFieldNode(name string, node ast.Node, types *fieldTypeRegistry) (For
|
||||
if err := compileDatepickerKeys(typ, values, &field); err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
if err := compileTaglistKeys(typ, values, &field); err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
if err := compileMoneyKeys(typ, values, &field); err != nil {
|
||||
return FormField{}, err
|
||||
}
|
||||
@@ -665,7 +669,8 @@ func compileFieldNode(name string, node ast.Node, types *fieldTypeRegistry) (For
|
||||
}
|
||||
field.Attributes = attrs
|
||||
}
|
||||
if node, ok := values["options"]; ok {
|
||||
// A taglist's options are a list of tags (compileTaglistKeys).
|
||||
if node, ok := values["options"]; ok && typ != taglistType {
|
||||
if err := compileOptions(node, &field); err != nil {
|
||||
return FormField{}, fmt.Errorf("options: %w", err)
|
||||
}
|
||||
|
||||
@@ -544,7 +544,7 @@ func TestPermissionEditorSmoke(t *testing.T) {
|
||||
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: radio\n default: 1\n")},
|
||||
"default is not valid on type: permissioneditor")
|
||||
rosterBootFails(t, map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: checkbox\n default: true\n mode: radio\n")},
|
||||
"mode is only valid on type: fileupload, datepicker or permissioneditor")
|
||||
"mode is only valid on type: fileupload, datepicker, permissioneditor or taglist")
|
||||
if err := rosterBoot(t, rosterTree(t, map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: checkbox\n")})); err != nil {
|
||||
t.Fatalf("mode: checkbox did not boot: %v", err)
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestPhase121BootErrors(t *testing.T) {
|
||||
{name: "permissioneditor with a default", files: map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: radio\n default: 1\n")},
|
||||
want: "default is not valid on type: permissioneditor", file: rosterFieldsFile},
|
||||
{name: "mode on another type", files: map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: checkbox\n default: true\n mode: radio\n")},
|
||||
want: "mode is only valid on type: fileupload, datepicker or permissioneditor", file: rosterFieldsFile},
|
||||
want: "mode is only valid on type: fileupload, datepicker, permissioneditor or taglist", file: rosterFieldsFile},
|
||||
// Relation fields (D-27 G3) and controller filter choices.
|
||||
{name: "WritableForeignKey on belongsToMany", plugin: relations(func(in []cabana.FieldRelationContract) { in[1].WritableForeignKey = true }),
|
||||
want: "field tags: WritableForeignKey is only valid on belongsTo"},
|
||||
|
||||
@@ -140,6 +140,9 @@ func compileRegistryWithTypes(items []controllerRef, types *fieldTypeRegistry) (
|
||||
if err := compileMoneyFields(item.plugin.ID(), compiled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := compileTaglistFields(item.plugin.ID(), compiled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := compilePermissionFields(item.plugin.ID(), compiled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -299,8 +299,9 @@ type FormField struct {
|
||||
// template {ConfigDir}/_{path}.htm (D-09).
|
||||
Path string `json:"path,omitempty"`
|
||||
// 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 `json:"mode,omitempty"`
|
||||
// Format is a datepicker's WinterCMS (PHP date) display format;
|
||||
// DisplayFormat is the same format in the SPA's moment-style tokens.
|
||||
@@ -330,6 +331,12 @@ type FormField struct {
|
||||
// Max is a money field's column ceiling as a decimal string; a value
|
||||
// above it in absolute magnitude is a 422 money.too_large.
|
||||
Max string `json:"max,omitempty"`
|
||||
// Separator joins the tags of a `type: taglist` field in string mode:
|
||||
// comma (the default) or space. Empty in array mode.
|
||||
Separator string `json:"separator,omitempty"`
|
||||
// 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 *bool `json:"customTags,omitempty"`
|
||||
// FileTypes are the allowed lower-case extensions of a fileupload field.
|
||||
FileTypes []string `json:"fileTypes,omitempty"`
|
||||
// MimeTypes are the allowed MIME patterns (or extensions) of a
|
||||
|
||||
110
modules/cabana/taglist_field_test.go
Normal file
110
modules/cabana/taglist_field_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
)
|
||||
|
||||
// Smoke tests of the cabana taglist field (Phase 18.1 D-17), Docker-free:
|
||||
// the boot gates of the keys and of the model column. The Postgres save
|
||||
// round trip is TestTaglistFieldSmoke in catalog_smoke_test.go; the fixture
|
||||
// (acme.catalog) lives there. Full unit coverage is plan 06. No application
|
||||
// names appear in framework tests.
|
||||
|
||||
// catalogFieldsPath is the badge fields.yaml of the fixture.
|
||||
const catalogFieldsPath = "models/badge/fields.yaml"
|
||||
|
||||
// catalogPlainTags is the badge model with a plain string tags column,
|
||||
// which an array-mode taglist cannot use.
|
||||
type catalogPlainTags struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
Tags string `gorm:"column:tags"`
|
||||
Keywords string `gorm:"column:keywords"`
|
||||
SortOrder int `gorm:"column:sort_order"`
|
||||
}
|
||||
|
||||
func (catalogPlainTags) TableName() string { return "acme_catalog_badges" }
|
||||
func (catalogPlainTags) Fillable() []string {
|
||||
return []string{"name", "tags", "keywords", "sort_order"}
|
||||
}
|
||||
func (catalogPlainTags) Rules() map[string]string { return map[string]string{} }
|
||||
|
||||
// TestTaglistFieldCompile checks the taglist boot gates: the fixture
|
||||
// compiles with its keys on the schema; a separator outside taglist or in
|
||||
// array mode, an unknown mode, options as a mapping, money keys on a
|
||||
// taglist and an array-mode column without sql.Scanner and driver.Valuer
|
||||
// each fail boot naming the field.
|
||||
func TestTaglistFieldCompile(t *testing.T) {
|
||||
if err := activateCatalog(t, catalogFSPlugin{fsys: catalogOverlay(t, nil)}); err != nil {
|
||||
t.Fatalf("fixture: %v", err)
|
||||
}
|
||||
schema, err := cabana.CompileForm("acme.catalog", catalogBadges{}, catalogOverlay(t, nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var tags cabana.FormField
|
||||
for _, field := range schema.Fields {
|
||||
if field.Name == "tags" {
|
||||
tags = field
|
||||
}
|
||||
}
|
||||
if tags.Type != "taglist" || tags.Mode != "array" || tags.Separator != "" || len(tags.Options) != 3 || tags.Options[0].Label != "alpha" {
|
||||
t.Fatalf("tags = %+v", tags)
|
||||
}
|
||||
raw, err := json.Marshal(tags)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`"type":"taglist"`, `"mode":"array"`, `{"value":"alpha","label":"alpha"}`} {
|
||||
if !strings.Contains(string(raw), want) {
|
||||
t.Fatalf("schema JSON lacks %s: %s", want, raw)
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(raw), `"separator"`) {
|
||||
t.Fatalf("array mode carries a separator: %s", raw)
|
||||
}
|
||||
|
||||
// A string-mode taglist takes the default comma separator.
|
||||
minimal := "fields:\n name:\n type: text\n keywords:\n type: taglist\n"
|
||||
schema, err = cabana.CompileForm("acme.catalog", catalogBadges{}, catalogOverlay(t, map[string]string{catalogFieldsPath: minimal}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f := schema.Fields[1]; f.Mode != "string" || f.Separator != "comma" || f.CustomTags != nil {
|
||||
t.Fatalf("minimal = %+v", f)
|
||||
}
|
||||
|
||||
refused := map[string]string{
|
||||
"separator on a text field": "fields:\n keywords:\n type: text\n separator: comma\n",
|
||||
"separator in array mode": "fields:\n keywords:\n type: taglist\n mode: array\n separator: space\n",
|
||||
"mode list": "fields:\n keywords:\n type: taglist\n mode: list\n",
|
||||
"options as a mapping": "fields:\n keywords:\n type: taglist\n options: {a: A}\n",
|
||||
"options as a method": "fields:\n keywords:\n type: taglist\n options: listTags\n",
|
||||
"money keys on taglist": "fields:\n keywords:\n type: taglist\n currency: PLN\n",
|
||||
"nameFrom on taglist": "fields:\n keywords:\n type: taglist\n nameFrom: name\n",
|
||||
"customTags not a boolean": "fields:\n keywords:\n type: taglist\n customTags: maybe\n",
|
||||
}
|
||||
for name, fields := range refused {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := cabana.CompileForm("acme.catalog", catalogBadges{}, catalogOverlay(t, map[string]string{catalogFieldsPath: fields}))
|
||||
if err == nil {
|
||||
t.Fatalf("compiled")
|
||||
}
|
||||
t.Log(err)
|
||||
if !strings.Contains(err.Error(), "field keywords") {
|
||||
t.Fatalf("error does not name the field: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a plain string column in array mode", func(t *testing.T) {
|
||||
err := activateCatalog(t, catalogFSPlugin{fsys: catalogOverlay(t, nil), newRecord: func() any { return &catalogPlainTags{} }})
|
||||
if err == nil || !strings.Contains(err.Error(), "field tags: taglist mode array needs a JSON column") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
4
modules/cabana/testdata/catalog/controllers/badges/config_form.yaml
vendored
Normal file
4
modules/cabana/testdata/catalog/controllers/badges/config_form.yaml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
name: Badge
|
||||
form: ~/plugins/acme/catalog/models/badge/fields.yaml
|
||||
modelClass: Badge
|
||||
defaultRedirect: acme/catalog/badges
|
||||
7
modules/cabana/testdata/catalog/controllers/badges/config_list.yaml
vendored
Normal file
7
modules/cabana/testdata/catalog/controllers/badges/config_list.yaml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
list: ~/plugins/acme/catalog/models/badge/columns.yaml
|
||||
modelClass: Badge
|
||||
title: Badges
|
||||
recordUrl: acme/catalog/badges/update/:id
|
||||
recordsPerPage: 20
|
||||
toolbar:
|
||||
buttons: [create]
|
||||
7
modules/cabana/testdata/catalog/models/badge/columns.yaml
vendored
Normal file
7
modules/cabana/testdata/catalog/models/badge/columns.yaml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
columns:
|
||||
name:
|
||||
label: Name
|
||||
searchable: true
|
||||
sort_order:
|
||||
label: Order
|
||||
sortable: true
|
||||
11
modules/cabana/testdata/catalog/models/badge/fields.yaml
vendored
Normal file
11
modules/cabana/testdata/catalog/models/badge/fields.yaml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
fields:
|
||||
name:
|
||||
label: Name
|
||||
type: text
|
||||
required: true
|
||||
tags:
|
||||
label: Tags
|
||||
type: taglist
|
||||
mode: array
|
||||
span: full
|
||||
options: [alpha, beta, gamma]
|
||||
@@ -144,6 +144,10 @@ money:
|
||||
too_precise: "Use at most :scale decimal places. Amounts are never rounded."
|
||||
too_large: "This amount is larger than the field can store (at most :max)."
|
||||
currency: "Currency: :code"
|
||||
taglist:
|
||||
invalid: "The :field field must be a list of tags."
|
||||
add: "Add a tag"
|
||||
remove: "Remove :tag"
|
||||
secret:
|
||||
stored: "A value is stored. Leave the field empty to keep it."
|
||||
env: "Nothing is stored here, so the value from the server configuration is used."
|
||||
|
||||
@@ -154,6 +154,10 @@ money:
|
||||
too_precise: "Użyj najwyżej :scale miejsc po przecinku. Kwoty nie są zaokrąglane."
|
||||
too_large: "Ta kwota jest większa, niż pole może zapisać (najwyżej :max)."
|
||||
currency: "Waluta: :code"
|
||||
taglist:
|
||||
invalid: "Pole :field musi być listą tagów."
|
||||
add: "Dodaj tag"
|
||||
remove: "Usuń :tag"
|
||||
secret:
|
||||
stored: "Wartość jest zapisana. Pozostaw pole puste, aby jej nie zmieniać."
|
||||
env: "Nic tu nie zapisano, więc używana jest wartość z konfiguracji serwera."
|
||||
|
||||
Reference in New Issue
Block a user