feat(18.1-01): task 1 taglist field in array mode end to end

- cabana: core type taglist (mode string|array, separator, customTags,
  options as a list of tags), column type gate, save-time normalisation
  (trim, drop empties, dedupe) and taglist.invalid 422, record hydration
- FormField gains separator and customTags (omitempty); OpenAPI and
  schema.d.ts regenerated
- admin: TaglistField chip input with suggestions, registry entry, vitest
- acme.catalog fixture with TestTaglistFieldCompile and TestTaglistFieldSmoke
- docs: forms.md Taglist section, cabana README; rebuilt dist
This commit is contained in:
Jakub Zych
2026-10-14 15:47:30 +02:00
parent 43d2fdd54a
commit 349bad5596
30 changed files with 1089 additions and 29 deletions

View File

@@ -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.