feat(18.1-01): task 2 taglist string mode, customTags and all save sites
- string mode: split on the separator (comma or whitespace), normalised, stored joined by a comma or one space; null stores an empty string - customTags false refuses a tag outside options (taglist.not_allowed) - taglist checked and hydrated on relation forms and settings forms; settings bind taglist columns; required checked on the tags - admin: TaglistField string mode and closed option lists, preview renders the tags read-only as a comma-separated list; rebuilt dist - docs: Taglist section covers both modes, separator and customTags
This commit is contained in:
@@ -8,7 +8,7 @@ import { isPluginField, rendererForField } from './registry'
|
||||
// One field of the preview screen (UI-SPEC S3, D-11): a label and its value
|
||||
// as a dt/dd pair. Values are text, never disabled inputs, so they keep full
|
||||
// contrast and are read in order. Controls that already have a read-only
|
||||
// mode (datepicker, fileupload, permissioneditor, money), partials and plugin
|
||||
// mode (datepicker, fileupload, permissioneditor, money, taglist), partials and plugin
|
||||
// field types (their element gets `disabled`) are rendered through their own
|
||||
// component with the field marked read-only.
|
||||
const props = defineProps<{
|
||||
@@ -21,7 +21,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
/** Types rendered by their own control in its read-only mode. */
|
||||
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor', 'repeater', 'money'])
|
||||
const ownControl = new Set(['datepicker', 'fileupload', 'partial', 'permissioneditor', 'richeditor', 'repeater', 'money', 'taglist'])
|
||||
|
||||
const controlId = computed(() => `${props.idPrefix ?? 'preview'}-${props.field.name}`)
|
||||
const labelId = computed(() => `${controlId.value}-label`)
|
||||
|
||||
@@ -6,14 +6,17 @@ import { controlAttributes, controlClass, type FieldControlProps } from '../cont
|
||||
|
||||
// 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.
|
||||
// input that adds the trimmed text on Enter or on typing the separator (a
|
||||
// comma; a space in string mode with separator space); empty text and a tag
|
||||
// already in the list are ignored, and with customTags false so is a tag
|
||||
// outside the field's options. The options are offered as suggestions
|
||||
// through a datalist. In array mode the value is a list of strings; in
|
||||
// string mode it is one string of tags joined by a comma or a single space.
|
||||
// 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 emit = defineEmits<{ 'update:modelValue': [value: string[] | string] }>()
|
||||
|
||||
const readOnly = computed(() => {
|
||||
const attribute = props.field.attributes?.readonly
|
||||
@@ -22,13 +25,28 @@ const readOnly = computed(() => {
|
||||
const attrs = computed(() => controlAttributes(props.field))
|
||||
const listId = computed(() => `${props.controlId}-options`)
|
||||
const suggestions = computed(() => (props.field.options ?? []).map((option) => option.label))
|
||||
const arrayMode = computed(() => props.field.mode === 'array')
|
||||
const spaced = computed(() => !arrayMode.value && props.field.separator === 'space')
|
||||
/** The character typed to add a tag. */
|
||||
const separatorChar = computed(() => (spaced.value ? ' ' : ','))
|
||||
const closed = computed(() => props.field.customTags === false)
|
||||
|
||||
/** The tags of a stored value: a list of strings; anything else is no tags. */
|
||||
/** The tags of a stored value: a list of strings, or a separator-joined string in string mode. */
|
||||
function tagsOf(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
let items: unknown[] = []
|
||||
if (Array.isArray(value)) {
|
||||
items = value
|
||||
} else if (typeof value === 'string' && !arrayMode.value) {
|
||||
items = spaced.value ? value.split(/\s+/) : value.split(',')
|
||||
}
|
||||
return value.filter((tag): tag is string => typeof tag === 'string' && tag.trim() !== '')
|
||||
const out: string[] = []
|
||||
for (const item of items) {
|
||||
const tag = typeof item === 'string' ? item.trim() : ''
|
||||
if (tag !== '' && !out.includes(tag)) {
|
||||
out.push(tag)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const tags = ref<string[]>(tagsOf(props.modelValue))
|
||||
@@ -46,16 +64,16 @@ watch(
|
||||
|
||||
function commit(next: string[]): void {
|
||||
tags.value = next
|
||||
const value = [...next]
|
||||
const value = arrayMode.value ? [...next] : next.join(spaced.value ? ' ' : ',')
|
||||
lastEmitted = value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
/** Adds the draft's trimmed text as a tag unless it is empty or present. */
|
||||
/** Adds the draft's trimmed text as a tag unless it is empty, present or not offered. */
|
||||
function addDraft(): void {
|
||||
const tag = draft.value.trim()
|
||||
draft.value = ''
|
||||
if (tag === '' || tags.value.includes(tag)) {
|
||||
if (tag === '' || tags.value.includes(tag) || (closed.value && !suggestions.value.includes(tag))) {
|
||||
return
|
||||
}
|
||||
commit([...tags.value, tag])
|
||||
@@ -67,11 +85,11 @@ function remove(index: number): void {
|
||||
|
||||
function onInput(event: Event): void {
|
||||
const text = (event.target as HTMLInputElement).value
|
||||
if (!text.includes(',')) {
|
||||
if (!text.includes(separatorChar.value)) {
|
||||
draft.value = text
|
||||
return
|
||||
}
|
||||
const parts = text.split(',')
|
||||
const parts = text.split(separatorChar.value)
|
||||
const rest = parts.pop() ?? ''
|
||||
for (const part of parts) {
|
||||
draft.value = part
|
||||
@@ -99,13 +117,12 @@ function onKeydown(event: KeyboardEvent): void {
|
||||
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 v-for="(tag, index) in tags" :key="tag">
|
||||
<span v-if="index > 0" class="sr-only">, </span>
|
||||
<span 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>
|
||||
</template>
|
||||
<span v-else class="text-muted" data-empty>—</span>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 PreviewField from '../../src/components/form/PreviewField.vue'
|
||||
import TaglistField from '../../src/components/form/fields/TaglistField.vue'
|
||||
import { langFixture } from '../fixtures/typed'
|
||||
import { resetState } from '../helpers'
|
||||
@@ -115,4 +116,39 @@ describe('TaglistField', () => {
|
||||
expect(wrapper.find('b').exists()).toBe(false)
|
||||
expect(chips(wrapper)).toEqual(['<b>bold</b>'])
|
||||
})
|
||||
|
||||
it('splits and joins a string-mode value on its space separator', async () => {
|
||||
const wrapper = mountField({ modelValue: 'x y', field: { mode: 'string', separator: 'space', options: [] } })
|
||||
expect(chips(wrapper)).toEqual(['x', 'y'])
|
||||
await type(wrapper, 'z ')
|
||||
expect(emitted(wrapper)).toEqual(['x y z'])
|
||||
})
|
||||
|
||||
it('joins a string-mode value with a comma by default', async () => {
|
||||
const wrapper = mountField({ modelValue: 'a,b', field: { mode: 'string', separator: 'comma' } })
|
||||
expect(chips(wrapper)).toEqual(['a', 'b'])
|
||||
await type(wrapper, 'c')
|
||||
await input(wrapper).trigger('keydown', { key: 'Enter' })
|
||||
expect(emitted(wrapper)).toEqual(['a,b,c'])
|
||||
})
|
||||
|
||||
it('refuses a tag outside the options when customTags is false', async () => {
|
||||
const wrapper = mountField({ modelValue: [], field: { customTags: false } })
|
||||
await type(wrapper, 'delta')
|
||||
await input(wrapper).trigger('keydown', { key: 'Enter' })
|
||||
expect(emitted(wrapper)).toEqual([])
|
||||
await type(wrapper, 'beta')
|
||||
await input(wrapper).trigger('keydown', { key: 'Enter' })
|
||||
expect(emitted(wrapper)).toEqual([['beta']])
|
||||
})
|
||||
|
||||
it('renders the tags joined as text on the preview screen', () => {
|
||||
const wrapper = mount(PreviewField, {
|
||||
props: { field: taglistField(), value: ['alpha', '<i>beta</i>'] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
expect(wrapper.find('[data-taglist-readonly]').text()).toBe('alpha, <i>beta</i>')
|
||||
expect(wrapper.find('i').exists()).toBe(false)
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -509,7 +509,12 @@ In the admin SPA the field is a text input in decimal mode, right-aligned in tab
|
||||
|
||||
## 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.
|
||||
`type: taglist` edits a list of tags, as the WinterCMS tag list does. It has two modes:
|
||||
|
||||
- `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.
|
||||
- `mode: string`, the default as in WinterCMS: the value travels as one string of tags joined by the separator (`"alpha,beta"`, or `"alpha beta"` with `separator: space`) and is stored as that string in a `string` (or `*string`) column.
|
||||
|
||||
Any other column type stops the start-up naming the field.
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
@@ -518,20 +523,34 @@ fields:
|
||||
type: taglist
|
||||
mode: array
|
||||
options: [Support, Reports, Export]
|
||||
customTags: false
|
||||
keywords:
|
||||
label: acme.shop::lang.plans.keywords
|
||||
type: taglist
|
||||
separator: space
|
||||
```
|
||||
|
||||
The field takes the generic keys plus:
|
||||
|
||||
| Key | Meaning |
|
||||
|-----|---------|
|
||||
| `mode` | `array` stores a JSON array of strings. |
|
||||
| `mode` | `string` (the default) stores a separator-joined string; `array` stores a JSON array of strings. |
|
||||
| `separator` | String mode only: `comma` (the default) joins the stored tags with `,`, `space` with a single space. Refused in array mode. |
|
||||
| `options` | A YAML list of tags offered as suggestions while typing. A mapping or a method name stops the start-up. |
|
||||
| `customTags` | `true` (the default) accepts any tag; `false` accepts only the tags listed in `options`. |
|
||||
|
||||
`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.
|
||||
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. In array mode `[" alpha ", "beta", "alpha", ""]` stores `["alpha","beta"]`, and `null` or an empty list stores `[]`, never NULL. In string mode the string is split on the separator (a comma, or any run of whitespace for `space`), normalised and joined again, so `"x y x"` with `separator: space` stores `"x y"`, and `null` stores an empty string. A save answers a 422 on the field with one of two `backend::lang.taglist` messages:
|
||||
|
||||
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.
|
||||
| Value | Answer |
|
||||
|-------|--------|
|
||||
| Not a list of strings in array mode (a string, a number, a list holding a number), or not a string in string mode | `taglist.invalid`, with `:field` |
|
||||
| With `customTags: false`, a tag that is not one of the `options` | `taglist.not_allowed`, with `:tag` naming the first such tag |
|
||||
|
||||
A `required: true` taglist needs at least one tag. The record GET serves the stored list in array mode (a NULL or unreadable column is served as an empty list) and the stored string in string mode. The same checks run on settings forms and relation forms.
|
||||
|
||||
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 the separator (a comma, or a space with `separator: space`); empty text, a tag already in the list and, with `customTags: false`, a tag outside the options are ignored, and the options are offered as suggestions. Read-only and preview show the chips alone, read as a comma-separated list by screen readers, or an em dash when empty.
|
||||
|
||||
## What a save may write
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -7,7 +7,7 @@
|
||||
<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-DBhf-zrM.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-CpHJmq2q.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CS4sv1WG.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -47,7 +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.
|
||||
- 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; in array mode null or an empty list stores `[]`, in string mode the tags are split on the separator and stored joined by `,` or a single space (null stores an empty string). A value of the wrong shape is a 422 on the field with `backend::lang.taglist.invalid`, and with `customTags: false` a tag outside `options` is a 422 with `backend::lang.taglist.not_allowed` (`:tag`), on controller forms, settings forms and relation forms alike; a taglist in a repeater row fails boot. The record GET serves the stored list (array mode) or string (string mode). 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.
|
||||
|
||||
@@ -70,13 +70,16 @@ func (catalogBadges) RequiredPermissions() []string { return []string{"acme.cata
|
||||
func (catalogBadges) NewRecord() any { return &catalogBadge{} }
|
||||
|
||||
// catalogFSPlugin is the acme.catalog fixture served from another tree,
|
||||
// optionally with another record model.
|
||||
// optionally with another record model and settings pages.
|
||||
type catalogFSPlugin struct {
|
||||
catalogPlugin
|
||||
fsys fs.FS
|
||||
newRecord func() any
|
||||
settings []pact.SettingsItem
|
||||
}
|
||||
|
||||
func (p catalogFSPlugin) Settings() []pact.SettingsItem { return p.settings }
|
||||
|
||||
func (p catalogFSPlugin) AdminFS() fs.FS { return p.fsys }
|
||||
func (p catalogFSPlugin) AdminControllers() []pact.AdminController {
|
||||
if p.newRecord != nil {
|
||||
@@ -207,7 +210,10 @@ func expectCatalog422(t *testing.T, e *conformEnv, body map[string]any, field, m
|
||||
// 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).
|
||||
// field with taglist.invalid (Polish, the request locale of the env). A
|
||||
// string-mode value is split on its separator, normalised and stored joined
|
||||
// by it; null stores an empty string. With customTags false a tag outside
|
||||
// the options is a 422 with taglist.not_allowed naming the tag.
|
||||
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)
|
||||
@@ -242,4 +248,40 @@ func TestTaglistFieldSmoke(t *testing.T) {
|
||||
if text := storedTagsText(t, e, dataID(t, rec.Body.Bytes())); text == nil || *text != `[]` {
|
||||
t.Fatalf("null stored %v, want []", text)
|
||||
}
|
||||
|
||||
// String mode, separator space: split on whitespace, normalised, joined
|
||||
// by one space, served as stored.
|
||||
rec = e.send(t, http.MethodPost, "/acme/catalog/badges", map[string]any{"name": "Words", "keywords": "x y x"}, true)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("keywords create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
id = dataID(t, rec.Body.Bytes())
|
||||
var badge catalogBadge
|
||||
if err := e.db.First(&badge, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if badge.Keywords != "x y" {
|
||||
t.Fatalf("stored keywords = %q, want \"x y\"", badge.Keywords)
|
||||
}
|
||||
show = e.send(t, http.MethodGet, fmt.Sprintf("/acme/catalog/badges/%d", id), nil, true)
|
||||
if err := json.Unmarshal(show.Body.Bytes(), &record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(record.Data["keywords"]); got != `"x y"` {
|
||||
t.Fatalf("served keywords = %s, want \"x y\"", got)
|
||||
}
|
||||
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/catalog/badges/%d", id), map[string]any{"name": "Words", "keywords": nil}, true)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("keywords null status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if err := e.db.First(&badge, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if badge.Keywords != "" {
|
||||
t.Fatalf("null keywords stored %q, want empty", badge.Keywords)
|
||||
}
|
||||
expectCatalog422(t, e, map[string]any{"name": "Bad", "keywords": []any{"x"}}, "keywords", "Pole keywords musi być listą tagów.")
|
||||
|
||||
// customTags false: only the options are accepted.
|
||||
expectCatalog422(t, e, map[string]any{"name": "Bad", "tags": []any{"alpha", "delta"}}, "tags", "Tag delta jest niedozwolony.")
|
||||
}
|
||||
|
||||
@@ -30,7 +30,8 @@ const (
|
||||
|
||||
// Phrase keys of the taglist field's save errors (backend::lang.taglist.*).
|
||||
const (
|
||||
msgTaglistInvalid = "backend::lang.taglist.invalid"
|
||||
msgTaglistInvalid = "backend::lang.taglist.invalid"
|
||||
msgTaglistNotAllowed = "backend::lang.taglist.not_allowed"
|
||||
)
|
||||
|
||||
// taglistKeys are valid only on `type: taglist`.
|
||||
@@ -192,12 +193,24 @@ func normaliseTags(tags []string) []string {
|
||||
}
|
||||
|
||||
// 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 "".
|
||||
// normalised tags: null is no tags; in array mode the value must be a list
|
||||
// of strings, in string mode a string split on the separator (a comma, or
|
||||
// any whitespace for space). It returns the phrase key of a refused value,
|
||||
// or "".
|
||||
func taglistValue(field FormField, value any) ([]string, string) {
|
||||
if value == nil {
|
||||
return []string{}, ""
|
||||
}
|
||||
if field.Mode != taglistModeArray {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, msgTaglistInvalid
|
||||
}
|
||||
if field.Separator == separatorSpace {
|
||||
return normaliseTags(strings.Fields(text)), ""
|
||||
}
|
||||
return normaliseTags(strings.Split(text, ",")), ""
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, msgTaglistInvalid
|
||||
@@ -213,13 +226,52 @@ func taglistValue(field FormField, value any) ([]string, string) {
|
||||
return normaliseTags(tags), ""
|
||||
}
|
||||
|
||||
// taglistJoin is the stored string of string-mode tags: joined with a comma,
|
||||
// or a single space for separator space (WinterCMS getSaveValue).
|
||||
func taglistJoin(field FormField, tags []string) string {
|
||||
if field.Separator == separatorSpace {
|
||||
return strings.Join(tags, " ")
|
||||
}
|
||||
return strings.Join(tags, ",")
|
||||
}
|
||||
|
||||
// taglistOptionValues are the tags a taglist field's options offer.
|
||||
func taglistOptionValues(field FormField) map[string]bool {
|
||||
out := make(map[string]bool, len(field.Options))
|
||||
for _, option := range field.Options {
|
||||
var tag string
|
||||
if err := json.Unmarshal(option.Value.raw, &tag); err == nil {
|
||||
out[tag] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// disallowedTag is the first tag outside the field's options when the field
|
||||
// declares customTags false, or "".
|
||||
func disallowedTag(field FormField, tags []string) string {
|
||||
if field.CustomTags == nil || *field.CustomTags {
|
||||
return ""
|
||||
}
|
||||
allowed := taglistOptionValues(field)
|
||||
for _, tag := range tags {
|
||||
if !allowed[tag] {
|
||||
return tag
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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.
|
||||
// taglist value of fields that allows (field name) accepts and replaces it
|
||||
// with what the column stores: in array mode the normalised list as its
|
||||
// JSON text (an empty list is `[]`, never null), which the column's
|
||||
// sql.Scanner reads; in string mode the normalised tags joined by the
|
||||
// separator (an empty string for none). A value of the wrong shape is a 422
|
||||
// on the field with taglist.invalid; with customTags false a tag outside the
|
||||
// options is a 422 with taglist.not_allowed naming the tag. 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
|
||||
@@ -236,9 +288,6 @@ func checkTaglistValues(ctx context.Context, tr *phrasebook.Translator, fields [
|
||||
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 {
|
||||
@@ -252,18 +301,23 @@ func checkTaglistValues(ctx context.Context, tr *phrasebook.Translator, fields [
|
||||
}
|
||||
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 tag := disallowedTag(field, tags); tag != "" {
|
||||
fail(field.Name, message(msgTaglistNotAllowed, map[string]string{"field": field.Name, "tag": tag}))
|
||||
continue
|
||||
}
|
||||
if field.Required && len(tags) == 0 {
|
||||
fail(field.Name, "The "+field.Name+" field is required.")
|
||||
continue
|
||||
}
|
||||
if field.Mode != taglistModeArray {
|
||||
body[field.Name] = taglistJoin(field, tags)
|
||||
continue
|
||||
}
|
||||
text, err := json.Marshal(tags)
|
||||
if err != nil {
|
||||
fail(field.Name, message(msgTaglistInvalid, map[string]string{"field": field.Name}))
|
||||
@@ -280,7 +334,7 @@ func checkTaglistValues(ctx context.Context, tr *phrasebook.Translator, fields [
|
||||
// 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.
|
||||
// empty list. A string-mode value is served as stored.
|
||||
func hydrateTaglistFields(fields []FormField, data map[string]any) {
|
||||
if data == nil {
|
||||
return
|
||||
|
||||
@@ -103,6 +103,12 @@ func (s RelationService) fillChild(ctx context.Context, tx *gorm.DB, form *Compi
|
||||
if err := checkMoneyValues(ctx, s.tr, form, body, op); err != nil {
|
||||
return err
|
||||
}
|
||||
if form != nil && form.Form != nil {
|
||||
allows := func(name string) bool { return contextAllows(form, name, op) }
|
||||
if err := checkTaglistValues(ctx, s.tr, form.Form.Fields, body, allows, op == "create"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
projected := projectOperation(form, body, op)
|
||||
if err := lagoon.Fill(model, fillAllowed(form, model, op), projected, false); err != nil {
|
||||
var typed *lagoon.FillTypeError
|
||||
|
||||
@@ -206,6 +206,9 @@ func compileRelationForm(pluginID string, ctl pact.AdminController, fsys fs.FS,
|
||||
if err := compileMoneyFields(pluginID, cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := compileTaglistFields(pluginID, cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cc, nil
|
||||
}
|
||||
if err := checkFormDates(pluginID, cc); err != nil {
|
||||
|
||||
@@ -126,7 +126,7 @@ func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS
|
||||
// A password field is a write-only secret on a settings form (D-13):
|
||||
// bound like a scalar, checked against lagoon.Encrypted, never served.
|
||||
secret := field.Type == passwordFieldType
|
||||
if (!scalarFormField(field.Type) && !secret) || protectedFillKey(field.Name) {
|
||||
if (!scalarFormField(field.Type) && !secret && field.Type != taglistType) || protectedFillKey(field.Name) {
|
||||
continue
|
||||
}
|
||||
if _, ok := columns[field.Name]; !ok {
|
||||
@@ -145,6 +145,11 @@ func compileSettingWithTypes(pluginID string, item pact.SettingsItem, fsys fs.FS
|
||||
return nil, fmt.Errorf("cabana: setting %s: %w", item.Code, err)
|
||||
}
|
||||
}
|
||||
if field.Type == taglistType {
|
||||
if err := checkTaglistType(model, field); err != nil {
|
||||
return nil, fmt.Errorf("cabana: setting %s: %w", item.Code, err)
|
||||
}
|
||||
}
|
||||
if secret {
|
||||
if err := checkSecretType(model, field); err != nil {
|
||||
return nil, fmt.Errorf("cabana: setting %s: %w", item.Code, err)
|
||||
@@ -183,9 +188,11 @@ func (s SettingsService) Get(ctx context.Context, setting *CompiledSetting) (Set
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return SettingsResult{}, err
|
||||
}
|
||||
data := withoutSecrets(setting, projectSetting(setting, model))
|
||||
hydrateTaglistFields(setting.Form.Fields, data)
|
||||
return SettingsResult{
|
||||
Exists: exists,
|
||||
Data: withoutSecrets(setting, projectSetting(setting, model)),
|
||||
Data: data,
|
||||
Meta: settingsMeta(ctx, s.Tr, secretStates(setting, model, s.Config), nil),
|
||||
}, nil
|
||||
}
|
||||
@@ -196,6 +203,11 @@ func (s SettingsService) Put(ctx context.Context, setting *CompiledSetting, body
|
||||
if s.DB == nil {
|
||||
return SettingsResult{}, errors.New("cabana: database is not configured")
|
||||
}
|
||||
// D-17: a taglist value is normalised and checked first, so an
|
||||
// array-mode list reaches the nested-value gate as its JSON text.
|
||||
if err := checkTaglistValues(ctx, s.Tr, setting.Form.Fields, body, nil, false); err != nil {
|
||||
return SettingsResult{}, err
|
||||
}
|
||||
if err := requireSettingInputs(setting, body); err != nil {
|
||||
return SettingsResult{}, err
|
||||
}
|
||||
@@ -269,9 +281,11 @@ func (s SettingsService) Put(ctx context.Context, setting *CompiledSetting, body
|
||||
return err
|
||||
}
|
||||
}
|
||||
data := withoutSecrets(setting, after)
|
||||
hydrateTaglistFields(setting.Form.Fields, data)
|
||||
result = SettingsResult{
|
||||
Exists: true,
|
||||
Data: withoutSecrets(setting, after),
|
||||
Data: data,
|
||||
Meta: settingsMeta(ctx, s.Tr, secretStates(setting, model, s.Config), notices),
|
||||
}
|
||||
return nil
|
||||
@@ -384,7 +398,8 @@ func settingRules(setting *CompiledSetting, model any) map[string]string {
|
||||
}
|
||||
}
|
||||
for _, field := range setting.Form.Fields {
|
||||
if field.Required {
|
||||
// A taglist's required is checked on its tags (checkTaglistValues).
|
||||
if field.Required && field.Type != taglistType {
|
||||
rules[field.Name] = mergeRequired(rules[field.Name])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// Smoke tests of the cabana taglist field (Phase 18.1 D-17), Docker-free:
|
||||
@@ -33,6 +35,24 @@ func (catalogPlainTags) Fillable() []string {
|
||||
}
|
||||
func (catalogPlainTags) Rules() map[string]string { return map[string]string{} }
|
||||
|
||||
// catalogPrefs is a settings model with a taglist in each mode.
|
||||
type catalogPrefs struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Tags lagoon.Jsonable[[]string] `gorm:"column:tags;type:text"`
|
||||
Keywords string `gorm:"column:keywords"`
|
||||
}
|
||||
|
||||
func (catalogPrefs) TableName() string { return "acme_catalog_prefs" }
|
||||
func (catalogPrefs) Fillable() []string { return []string{"tags", "keywords"} }
|
||||
func (catalogPrefs) Rules() map[string]string { return map[string]string{} }
|
||||
|
||||
// catalogPrefsSettings is the settings page over catalogPrefs.
|
||||
func catalogPrefsSettings() []pact.SettingsItem {
|
||||
return []pact.SettingsItem{{Code: "prefs", Label: "Preferences", Category: "Acme", Icon: "settings", Model: "Prefs",
|
||||
Permissions: []string{"acme.catalog.access"}, Form: "models/prefs/fields.yaml",
|
||||
NewModel: func() any { return &catalogPrefs{} }}}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -68,6 +88,13 @@ func TestTaglistFieldCompile(t *testing.T) {
|
||||
t.Fatalf("array mode carries a separator: %s", raw)
|
||||
}
|
||||
|
||||
if keywords := schema.Fields[2]; keywords.Name != "keywords" || keywords.Mode != "string" || keywords.Separator != "space" {
|
||||
t.Fatalf("keywords = %+v", keywords)
|
||||
}
|
||||
if tags.CustomTags == nil || *tags.CustomTags || !strings.Contains(string(raw), `"customTags":false`) {
|
||||
t.Fatalf("tags customTags = %v: %s", tags.CustomTags, 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}))
|
||||
@@ -101,6 +128,27 @@ func TestTaglistFieldCompile(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a taglist inside a repeater", func(t *testing.T) {
|
||||
fields := "fields:\n name:\n type: text\n rows:\n type: repeater\n form:\n fields:\n labels:\n type: taglist\n"
|
||||
_, err := cabana.CompileForm("acme.catalog", catalogBadges{}, catalogOverlay(t, map[string]string{catalogFieldsPath: fields}))
|
||||
if err == nil || !strings.Contains(err.Error(), "field labels: type taglist is not supported in a repeater") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a settings form with a taglist", func(t *testing.T) {
|
||||
prefs := "fields:\n tags:\n type: taglist\n mode: array\n keywords:\n type: taglist\n"
|
||||
err := activateCatalog(t, catalogFSPlugin{fsys: catalogOverlay(t, map[string]string{"models/prefs/fields.yaml": prefs}), settings: catalogPrefsSettings()})
|
||||
if err != nil {
|
||||
t.Fatalf("settings: %v", err)
|
||||
}
|
||||
wrong := "fields:\n keywords:\n type: taglist\n mode: array\n"
|
||||
err = activateCatalog(t, catalogFSPlugin{fsys: catalogOverlay(t, map[string]string{"models/prefs/fields.yaml": wrong}), settings: catalogPrefsSettings()})
|
||||
if err == nil || !strings.Contains(err.Error(), "field keywords: taglist mode array needs a JSON column") {
|
||||
t.Fatalf("settings string column in array mode: %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") {
|
||||
|
||||
@@ -9,3 +9,9 @@ fields:
|
||||
mode: array
|
||||
span: full
|
||||
options: [alpha, beta, gamma]
|
||||
customTags: false
|
||||
keywords:
|
||||
label: Keywords
|
||||
type: taglist
|
||||
mode: string
|
||||
separator: space
|
||||
|
||||
@@ -146,6 +146,7 @@ money:
|
||||
currency: "Currency: :code"
|
||||
taglist:
|
||||
invalid: "The :field field must be a list of tags."
|
||||
not_allowed: "The tag :tag is not allowed."
|
||||
add: "Add a tag"
|
||||
remove: "Remove :tag"
|
||||
secret:
|
||||
|
||||
@@ -156,6 +156,7 @@ money:
|
||||
currency: "Waluta: :code"
|
||||
taglist:
|
||||
invalid: "Pole :field musi być listą tagów."
|
||||
not_allowed: "Tag :tag jest niedozwolony."
|
||||
add: "Dodaj tag"
|
||||
remove: "Usuń :tag"
|
||||
secret:
|
||||
|
||||
Reference in New Issue
Block a user