diff --git a/modules/cabana/crud.go b/modules/cabana/crud.go index 45a9868..ba183fc 100644 --- a/modules/cabana/crud.go +++ b/modules/cabana/crud.go @@ -30,8 +30,8 @@ type CRUDService struct { // commit; tr localizes the file limit messages. Both may be nil. bucket *blob.Bucket tr *phrasebook.Translator - // writer persists non-default locale values for mltext/mlmarkdown fields. - // Nil when the translate plugin is not mounted. + // writer persists non-default locale values for mltext/mltextarea/mlmarkdown + // fields. Nil when the translate plugin is not mounted. writer TranslationWriter } @@ -1224,7 +1224,7 @@ func modelColumns(model any) map[string]struct{} { func scalarFormField(typ string) bool { switch typ { case "text", "textarea", "number", "checkbox", "switch", "dropdown", "datepicker", - "markdown", "mltext", "mlmarkdown": + "markdown", "mltext", "mltextarea", "mlmarkdown": return true default: return false diff --git a/modules/cabana/field_ml.go b/modules/cabana/field_ml.go index 2f3ba7e..854c8cb 100644 --- a/modules/cabana/field_ml.go +++ b/modules/cabana/field_ml.go @@ -10,6 +10,7 @@ import ( const ( mlTextFieldType = "mltext" + mlTextareaFieldType = "mltextarea" mlMarkdownFieldType = "mlmarkdown" ) @@ -41,11 +42,17 @@ type mlValue struct { 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 { - 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 // 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 @@ -167,8 +174,8 @@ func applyMLTranslations(ctx context.Context, tx *gorm.DB, writer TranslationWri return nil } -// hydrateMLRecord replaces host scalars of declared mltext/mlmarkdown fields -// in a Show/save RecordResult.Data map with a locale→text map for every +// hydrateMLRecord replaces host scalars of declared mltext/mltextarea/mlmarkdown +// 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 // 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 diff --git a/modules/cabana/form_schema.go b/modules/cabana/form_schema.go index 1a081e1..f24cffe 100644 --- a/modules/cabana/form_schema.go +++ b/modules/cabana/form_schema.go @@ -26,7 +26,7 @@ var ( "switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {}, "widget": {}, "partial": {}, "fileupload": {}, "datepicker": {}, "password": {}, "permissioneditor": {}, - "markdown": {}, "mltext": {}, "mlmarkdown": {}, + "markdown": {}, "mltext": {}, "mltextarea": {}, "mlmarkdown": {}, } formSpans = map[string]struct{}{ "left": {}, "right": {}, "full": {}, "auto": {}, "row": {}, diff --git a/modules/cabana/ml_textarea_test.go b/modules/cabana/ml_textarea_test.go new file mode 100644 index 0000000..ab26f62 --- /dev/null +++ b/modules/cabana/ml_textarea_test.go @@ -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) + } + }) + } +} diff --git a/modules/cabana/preset_ml_test.go b/modules/cabana/preset_ml_test.go index cd82442..6470f51 100644 --- a/modules/cabana/preset_ml_test.go +++ b/modules/cabana/preset_ml_test.go @@ -127,6 +127,32 @@ func TestPresetML(t *testing.T) { `, 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 { t.Run(tc.name, func(t *testing.T) { diff --git a/modules/cabana/relation.go b/modules/cabana/relation.go index 3a5b1d4..f36f81b 100644 --- a/modules/cabana/relation.go +++ b/modules/cabana/relation.go @@ -187,7 +187,7 @@ type RelationService struct { // 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 - // optional plugin adapter CRUDService uses for mltext/mlmarkdown. + // optional plugin adapter CRUDService uses for mltext/mltextarea/mlmarkdown. bucket *blob.Bucket tr *phrasebook.Translator writer TranslationWriter