feat(cabana): add mltextarea field type
- mltextarea compiles (with size), binds as a writable scalar and goes through the mltext lift, translation write and hydrate paths - preset on or from an mltextarea field stays refused - tests: compile, Postgres round trip over a nullable host column with multi-line text, invalid locale maps, preset refusals
This commit is contained in:
@@ -30,8 +30,8 @@ type CRUDService struct {
|
|||||||
// commit; tr localizes the file limit messages. Both may be nil.
|
// commit; tr localizes the file limit messages. Both may be nil.
|
||||||
bucket *blob.Bucket
|
bucket *blob.Bucket
|
||||||
tr *phrasebook.Translator
|
tr *phrasebook.Translator
|
||||||
// writer persists non-default locale values for mltext/mlmarkdown fields.
|
// writer persists non-default locale values for mltext/mltextarea/mlmarkdown
|
||||||
// Nil when the translate plugin is not mounted.
|
// fields. Nil when the translate plugin is not mounted.
|
||||||
writer TranslationWriter
|
writer TranslationWriter
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1224,7 +1224,7 @@ func modelColumns(model any) map[string]struct{} {
|
|||||||
func scalarFormField(typ string) bool {
|
func scalarFormField(typ string) bool {
|
||||||
switch typ {
|
switch typ {
|
||||||
case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker",
|
case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker",
|
||||||
"markdown", "mltext", "mlmarkdown":
|
"markdown", "mltext", "mltextarea", "mlmarkdown":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
mlTextFieldType = "mltext"
|
mlTextFieldType = "mltext"
|
||||||
|
mlTextareaFieldType = "mltextarea"
|
||||||
mlMarkdownFieldType = "mlmarkdown"
|
mlMarkdownFieldType = "mlmarkdown"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,11 +42,17 @@ type mlValue struct {
|
|||||||
values map[string]string
|
values map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mlFieldType reports whether typ is a multilingual field type whose value is
|
||||||
|
// a locale map: mltext, mltextarea or mlmarkdown.
|
||||||
func mlFieldType(typ string) bool {
|
func mlFieldType(typ string) bool {
|
||||||
return typ == mlTextFieldType || typ == mlMarkdownFieldType
|
switch typ {
|
||||||
|
case mlTextFieldType, mlTextareaFieldType, mlMarkdownFieldType:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// liftMLValues takes declared mltext/mlmarkdown maps out of a save body
|
// liftMLValues takes declared mltext/mltextarea/mlmarkdown maps out of a save body
|
||||||
// before ProjectWritableFields drops nested values. Only fields present in
|
// before ProjectWritableFields drops nested values. Only fields present in
|
||||||
// the body whose context allows op are lifted. A value must be a JSON object
|
// the body whose context allows op are lifted. A value must be a JSON object
|
||||||
// of enabled locale code to string; unknown locales, extra keys, and
|
// of enabled locale code to string; unknown locales, extra keys, and
|
||||||
@@ -167,8 +174,8 @@ func applyMLTranslations(ctx context.Context, tx *gorm.DB, writer TranslationWri
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hydrateMLRecord replaces host scalars of declared mltext/mlmarkdown fields
|
// hydrateMLRecord replaces host scalars of declared mltext/mltextarea/mlmarkdown
|
||||||
// in a Show/save RecordResult.Data map with a locale→text map for every
|
// fields in a Show/save RecordResult.Data map with a locale→text map for every
|
||||||
// enabled code. The default locale is the projected host column; other codes
|
// enabled code. The default locale is the projected host column; other codes
|
||||||
// come from TranslatedExact; a missing non-default code is an empty string,
|
// come from TranslatedExact; a missing non-default code is an empty string,
|
||||||
// never D-11 fallback. It is a no-op when writer is nil and is not used from
|
// never D-11 fallback. It is a no-op when writer is nil and is not used from
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ var (
|
|||||||
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
|
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
|
||||||
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
|
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
|
||||||
"password": {}, "permissioneditor": {},
|
"password": {}, "permissioneditor": {},
|
||||||
"markdown": {}, "mltext": {}, "mlmarkdown": {},
|
"markdown": {}, "mltext": {}, "mltextarea": {}, "mlmarkdown": {},
|
||||||
}
|
}
|
||||||
formSpans = map[string]struct{}{
|
formSpans = map[string]struct{}{
|
||||||
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
|
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
|
||||||
|
|||||||
181
modules/cabana/ml_textarea_test.go
Normal file
181
modules/cabana/ml_textarea_test.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
const mlTextareaFields = `fields:
|
||||||
|
title:
|
||||||
|
label: Title
|
||||||
|
type: mltext
|
||||||
|
required: true
|
||||||
|
description:
|
||||||
|
label: Description
|
||||||
|
type: mltextarea
|
||||||
|
size: small
|
||||||
|
`
|
||||||
|
|
||||||
|
func mlTextareaCompiled(t *testing.T) *CompiledController {
|
||||||
|
t.Helper()
|
||||||
|
fsys := mlFS()
|
||||||
|
fsys["models/post/fields.yaml"] = &fstest.MapFile{Data: []byte(mlTextareaFields)}
|
||||||
|
reg, err := compileRegistry([]controllerRef{{
|
||||||
|
plugin: formPlugin{fsys: fsys},
|
||||||
|
ctl: mlNullableController{},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("registry: %v", err)
|
||||||
|
}
|
||||||
|
cc, ok := reg.Get("acme.demo.posts")
|
||||||
|
if !ok || cc.Form == nil {
|
||||||
|
t.Fatalf("compiled controller missing form: %+v", cc)
|
||||||
|
}
|
||||||
|
return cc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMLTextareaFieldType(t *testing.T) {
|
||||||
|
accepted := `fields:
|
||||||
|
title:
|
||||||
|
type: mltext
|
||||||
|
label: Title
|
||||||
|
excerpt:
|
||||||
|
type: mltextarea
|
||||||
|
label: Excerpt
|
||||||
|
size: small
|
||||||
|
`
|
||||||
|
schema, err := CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, accepted))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile mltextarea: %v", err)
|
||||||
|
}
|
||||||
|
var excerpt *FormField
|
||||||
|
for i := range schema.Fields {
|
||||||
|
if schema.Fields[i].Name == "excerpt" {
|
||||||
|
excerpt = &schema.Fields[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if excerpt == nil {
|
||||||
|
t.Fatal("excerpt field missing")
|
||||||
|
}
|
||||||
|
if excerpt.Type != "mltextarea" || excerpt.Size != "small" {
|
||||||
|
t.Fatalf("excerpt = type %q size %q, want mltextarea small", excerpt.Type, excerpt.Size)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(excerpt)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`"type":"mltextarea"`, `"size":"small"`} {
|
||||||
|
if !strings.Contains(string(raw), want) {
|
||||||
|
t.Fatalf("json %s lacks %s", raw, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, "fields:\n excerpt:\n type: mltextareas\n label: Excerpt\n"))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "unsupported type mltextareas") {
|
||||||
|
t.Fatalf("unknown type err = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !mlFieldType("mltextarea") {
|
||||||
|
t.Fatal("mlFieldType(mltextarea) = false")
|
||||||
|
}
|
||||||
|
if mlFieldType("textarea") {
|
||||||
|
t.Fatal("mlFieldType(textarea) = true")
|
||||||
|
}
|
||||||
|
if !scalarFormField("mltextarea") {
|
||||||
|
t.Fatal("scalarFormField(mltextarea) = false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMLTextareaRoundTrip(t *testing.T) {
|
||||||
|
_, db := newListService(t)
|
||||||
|
if err := db.Migrator().DropTable(&mlNullablePost{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&mlNullablePost{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writer := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}
|
||||||
|
svc := CRUDService{DB: db, writer: writer}
|
||||||
|
cc := mlTextareaCompiled(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
row := mlNullablePost{Title: "Hello"}
|
||||||
|
if err := db.Create(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
title := map[string]any{"en": "Hello", "pl": "Witaj"}
|
||||||
|
|
||||||
|
t.Run("nil host hydrates to empty strings", func(t *testing.T) {
|
||||||
|
shown, err := svc.ShowRecord(ctx, cc, row.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("show: %v", err)
|
||||||
|
}
|
||||||
|
got := mlLocaleMap(t, "show", shown.Data, "description")
|
||||||
|
if got["en"] != "" || got["pl"] != "" || len(got) != 2 {
|
||||||
|
t.Fatalf("nil description = %#v, want en and pl empty", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multi-line text saves per locale", func(t *testing.T) {
|
||||||
|
en := "Line one\nLine two"
|
||||||
|
pl := "Linia pierwsza\nLinia druga"
|
||||||
|
saved, err := svc.UpdateRecord(ctx, cc, row.ID, RecordInput{Body: map[string]any{
|
||||||
|
"title": title,
|
||||||
|
"description": map[string]any{"en": en, "pl": pl},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
if got := mlLocaleMap(t, "save", saved.Data, "description"); got["en"] != en || got["pl"] != pl {
|
||||||
|
t.Fatalf("saved description = %#v", got)
|
||||||
|
}
|
||||||
|
shown, err := svc.ShowRecord(ctx, cc, row.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("show: %v", err)
|
||||||
|
}
|
||||||
|
if got := mlLocaleMap(t, "show", shown.Data, "description"); got["en"] != en || got["pl"] != pl || len(got) != 2 {
|
||||||
|
t.Fatalf("shown description = %#v", got)
|
||||||
|
}
|
||||||
|
var stored mlNullablePost
|
||||||
|
if err := db.First(&stored, row.ID).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if stored.Description == nil || *stored.Description != en {
|
||||||
|
t.Fatalf("stored description = %v, want %q", stored.Description, en)
|
||||||
|
}
|
||||||
|
if writer.attrs["pl"]["description"] != pl {
|
||||||
|
t.Fatalf("Polish attributes = %#v", writer.attrs)
|
||||||
|
}
|
||||||
|
if _, dup := writer.attrs["en"]["description"]; dup {
|
||||||
|
t.Fatalf("default locale written to the translation store: %#v", writer.attrs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
invalid := []struct {
|
||||||
|
name string
|
||||||
|
value map[string]any
|
||||||
|
}{
|
||||||
|
{"undeclared locale", map[string]any{"en": "A", "de": "B"}},
|
||||||
|
{"non-string value", map[string]any{"en": "A", "pl": 3}},
|
||||||
|
{"missing default locale", map[string]any{"pl": "B"}},
|
||||||
|
}
|
||||||
|
for _, tc := range invalid {
|
||||||
|
t.Run(tc.name+" is a validation error", func(t *testing.T) {
|
||||||
|
_, err := svc.UpdateRecord(ctx, cc, row.ID, RecordInput{Body: map[string]any{
|
||||||
|
"title": title,
|
||||||
|
"description": tc.value,
|
||||||
|
}})
|
||||||
|
var verr *ValidationError
|
||||||
|
if !errors.As(err, &verr) {
|
||||||
|
t.Fatalf("err = %v, want *ValidationError", err)
|
||||||
|
}
|
||||||
|
if _, ok := verr.Details["description"]; !ok {
|
||||||
|
t.Fatalf("details = %#v, want description key", verr.Details)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,6 +127,32 @@ func TestPresetML(t *testing.T) {
|
|||||||
`,
|
`,
|
||||||
checkErr: "field slug: preset field body must be a text or mltext field",
|
checkErr: "field slug: preset field body must be a text or mltext field",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "mltextarea target is refused",
|
||||||
|
yaml: `fields:
|
||||||
|
title:
|
||||||
|
type: mltext
|
||||||
|
label: Title
|
||||||
|
excerpt:
|
||||||
|
type: mltextarea
|
||||||
|
label: Excerpt
|
||||||
|
preset: title
|
||||||
|
`,
|
||||||
|
compileErr: "preset is only valid on type: text or mltext",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mltextarea source is refused",
|
||||||
|
yaml: `fields:
|
||||||
|
excerpt:
|
||||||
|
type: mltextarea
|
||||||
|
label: Excerpt
|
||||||
|
slug:
|
||||||
|
type: mltext
|
||||||
|
label: Slug
|
||||||
|
preset: excerpt
|
||||||
|
`,
|
||||||
|
checkErr: "field slug: preset field excerpt must be a text or mltext field",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ type RelationService struct {
|
|||||||
|
|
||||||
// bucket deletes the blobs of child files a save removes, after commit;
|
// bucket deletes the blobs of child files a save removes, after commit;
|
||||||
// tr localizes date bound messages. Both may be nil. writer is the same
|
// tr localizes date bound messages. Both may be nil. writer is the same
|
||||||
// optional plugin adapter CRUDService uses for mltext/mlmarkdown.
|
// optional plugin adapter CRUDService uses for mltext/mltextarea/mlmarkdown.
|
||||||
bucket *blob.Bucket
|
bucket *blob.Bucket
|
||||||
tr *phrasebook.Translator
|
tr *phrasebook.Translator
|
||||||
writer TranslationWriter
|
writer TranslationWriter
|
||||||
|
|||||||
Reference in New Issue
Block a user