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