From 7afad732c718ed79b3973d4fae0c8beaa934cfe8 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 6 Oct 2026 21:09:48 +0200 Subject: [PATCH] feat(cabana): accept preset on mltext fields - compilePresetKey admits a text or mltext target - checkPresets admits a text or mltext source - TestPresetML pins every text/mltext pairing and the refusals --- modules/cabana/form_schema.go | 19 ++- modules/cabana/phase121_form_test.go | 4 +- modules/cabana/phase121_schema_boot_test.go | 4 +- modules/cabana/preset_ml_test.go | 179 ++++++++++++++++++++ 4 files changed, 193 insertions(+), 13 deletions(-) create mode 100644 modules/cabana/preset_ml_test.go diff --git a/modules/cabana/form_schema.go b/modules/cabana/form_schema.go index f6798e9..1a081e1 100644 --- a/modules/cabana/form_schema.go +++ b/modules/cabana/form_schema.go @@ -638,17 +638,18 @@ func compileWidgetKeys(typ string, values map[string]ast.Node, field *FormField) } // compilePresetKey decodes `preset` (D-27): the name of the field this text -// field follows while the administrator has not edited it, on create only. It -// is a string (the source field; type slug) or a mapping with the keys field -// and type, where type is slug or exact. The source is checked against the -// form in checkPresets. +// or mltext field follows while the administrator has not edited it, on +// create only. It is a string (the source field; type slug) or a mapping with +// the keys field and type, where type is slug or exact. An mltext pair +// follows per locale in the admin SPA. The source is checked against the form +// in checkPresets. func compilePresetKey(typ string, values map[string]ast.Node, field *FormField) error { node, ok := values["preset"] if !ok { return nil } - if typ != "text" { - return fmt.Errorf("preset is only valid on type: text") + if typ != "text" && typ != "mltext" { + return fmt.Errorf("preset is only valid on type: text or mltext") } preset := FieldPreset{Type: "slug"} switch n := node.(type) { @@ -687,7 +688,7 @@ func compilePresetKey(typ string, values map[string]ast.Node, field *FormField) } // checkPresets checks every preset of a form against its fields: the source -// must be another text field of the same form. +// must be another text or mltext field of the same form. func checkPresets(fields []FormField) error { types := make(map[string]string, len(fields)) for _, field := range fields { @@ -705,8 +706,8 @@ func checkPresets(fields []FormField) error { if !ok { return fmt.Errorf("field %s: preset field %s is not a field of this form", field.Name, source) } - if typ != "text" { - return fmt.Errorf("field %s: preset field %s must be a text field", field.Name, source) + if typ != "text" && typ != "mltext" { + return fmt.Errorf("field %s: preset field %s must be a text or mltext field", field.Name, source) } } return nil diff --git a/modules/cabana/phase121_form_test.go b/modules/cabana/phase121_form_test.go index b0061db..2cd65db 100644 --- a/modules/cabana/phase121_form_test.go +++ b/modules/cabana/phase121_form_test.go @@ -372,9 +372,9 @@ func TestPresetSchema(t *testing.T) { {"mapping with exact", " preset: name\n", " preset:\n field: name\n type: exact\n", ""}, {"unsupported type", " preset: name\n", " preset:\n field: name\n type: camel\n", "preset type camel is not supported (want slug or exact)"}, {"unknown key", " preset: name\n", " preset:\n field: name\n prefix: x\n", "preset: unknown field prefix"}, - {"not a text target", " type: checkbox\n default: true\n", " type: checkbox\n default: true\n preset: name\n", "preset is only valid on type: text"}, + {"not a text target", " type: checkbox\n default: true\n", " type: checkbox\n default: true\n preset: name\n", "preset is only valid on type: text or mltext"}, {"unknown source", " preset: name\n", " preset: title\n", "field slug: preset field title is not a field of this form"}, - {"source is not text", " preset: name\n", " preset: notify\n", "field slug: preset field notify must be a text field"}, + {"source is not text", " preset: name\n", " preset: notify\n", "field slug: preset field notify must be a text or mltext field"}, {"itself", " preset: name\n", " preset: slug\n", "field slug: preset names the field itself"}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/modules/cabana/phase121_schema_boot_test.go b/modules/cabana/phase121_schema_boot_test.go index 5fe8493..4834fe2 100644 --- a/modules/cabana/phase121_schema_boot_test.go +++ b/modules/cabana/phase121_schema_boot_test.go @@ -154,11 +154,11 @@ func TestPhase121BootErrors(t *testing.T) { {name: "an unknown preset key", files: map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", " preset:\n field: name\n prefix: x\n")}, want: "preset: unknown field prefix", file: rosterFieldsFile}, {name: "preset on a field that is not text", files: map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: checkbox\n default: true\n preset: name\n")}, - want: "preset is only valid on type: text", file: rosterFieldsFile}, + want: "preset is only valid on type: text or mltext", file: rosterFieldsFile}, {name: "preset from an unknown field", files: map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", " preset: title\n")}, want: "field slug: preset field title is not a field of this form", file: rosterFieldsFile}, {name: "preset from a field that is not text", files: map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", " preset: notify\n")}, - want: "field slug: preset field notify must be a text field", file: rosterFieldsFile}, + want: "field slug: preset field notify must be a text or mltext field", file: rosterFieldsFile}, {name: "preset from the field itself", files: map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", " preset: slug\n")}, want: "field slug: preset names the field itself", file: rosterFieldsFile}, // permissioneditor (D-16). diff --git a/modules/cabana/preset_ml_test.go b/modules/cabana/preset_ml_test.go new file mode 100644 index 0000000..cd82442 --- /dev/null +++ b/modules/cabana/preset_ml_test.go @@ -0,0 +1,179 @@ +package cabana + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestPresetML pins preset on multilingual fields: a text or mltext target +// may follow a text or mltext source; any other target or source type is +// refused, by CompileForm for the target and by checkPresets for the source. +func TestPresetML(t *testing.T) { + cases := []struct { + name string + yaml string + compileErr string + checkErr string + preset *FieldPreset + json []string + }{ + { + name: "mltext title to mltext slug", + yaml: `fields: + title: + type: mltext + label: Title + slug: + type: mltext + label: Slug + preset: + field: title + type: slug +`, + preset: &FieldPreset{Field: "title", Type: "slug"}, + json: []string{`"type":"mltext"`, `"preset":{"field":"title","type":"slug"}`}, + }, + { + name: "short form on an mltext slug", + yaml: `fields: + title: + type: mltext + label: Title + slug: + type: mltext + label: Slug + preset: title +`, + preset: &FieldPreset{Field: "title", Type: "slug"}, + }, + { + name: "exact copy between mltext fields", + yaml: `fields: + title: + type: mltext + label: Title + slug: + type: mltext + label: Slug + preset: + field: title + type: exact +`, + preset: &FieldPreset{Field: "title", Type: "exact"}, + }, + { + name: "text source to mltext target", + yaml: `fields: + title: + type: text + label: Title + slug: + type: mltext + label: Slug + preset: title +`, + preset: &FieldPreset{Field: "title", Type: "slug"}, + }, + { + name: "mltext source to text target", + yaml: `fields: + title: + type: mltext + label: Title + slug: + type: text + label: Slug + preset: title +`, + preset: &FieldPreset{Field: "title", Type: "slug"}, + }, + { + name: "mlmarkdown target is refused", + yaml: `fields: + title: + type: mltext + label: Title + slug: + type: mlmarkdown + label: Slug + preset: title +`, + compileErr: "preset is only valid on type: text or mltext", + }, + { + name: "markdown target is refused", + yaml: `fields: + title: + type: text + label: Title + slug: + type: markdown + label: Slug + preset: title +`, + compileErr: "preset is only valid on type: text or mltext", + }, + { + name: "mlmarkdown source is refused", + yaml: `fields: + body: + type: mlmarkdown + label: Body + slug: + type: mltext + label: Slug + preset: body +`, + checkErr: "field slug: preset field body must be a text or mltext field", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + schema, err := CompileForm("acme.demo", schemaController{model: "Widget"}, formFS(formConfig, tc.yaml)) + if tc.compileErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.compileErr) { + t.Fatalf("compile err = %v, want %q", err, tc.compileErr) + } + return + } + if err != nil { + t.Fatalf("compile: %v", err) + } + err = checkPresets(schema.Fields) + if tc.checkErr != "" { + if err == nil || err.Error() != tc.checkErr { + t.Fatalf("checkPresets err = %v, want %q", err, tc.checkErr) + } + return + } + if err != nil { + t.Fatalf("checkPresets: %v", err) + } + var slug *FormField + for i := range schema.Fields { + if schema.Fields[i].Name == "slug" { + slug = &schema.Fields[i] + } + } + if slug == nil { + t.Fatal("slug field missing") + } + if slug.Preset == nil || *slug.Preset != *tc.preset { + t.Fatalf("preset = %+v, want %+v", slug.Preset, tc.preset) + } + if len(tc.json) == 0 { + return + } + raw, err := json.Marshal(slug) + if err != nil { + t.Fatal(err) + } + for _, want := range tc.json { + if !strings.Contains(string(raw), want) { + t.Fatalf("json %s lacks %s", raw, want) + } + } + }) + } +}