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:
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