feat(14.2.1-06): persist nested ML maps on relation-child create

RelationService looks up TranslationWriter like CRUDService so CreateChild can lift locale maps before fillChild, write Polish after the child PK, and return a hydrated title map.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-06 15:51:38 +02:00
parent f4bea33faf
commit 88a3a88876
5 changed files with 225 additions and 6 deletions

View File

@@ -0,0 +1,201 @@
package cabana
import (
"context"
"errors"
"reflect"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
const mlParentFormConfig = `name: journals
form: ~/plugins/acme/demo/models/journal/fields.yaml
modelClass: Journal
`
const mlParentListConfig = `modelClass: Journal
list: ~/plugins/acme/demo/models/journal/columns.yaml
recordsPerPage: 20
toolbar:
buttons: [create]
`
const mlParentFields = `fields:
entries:
type: relation-manager
relation: entries
`
const mlParentColumns = `columns:
id:
label: ID
`
const mlParentRelation = `entries:
label: Entries
view:
list:
columns:
title:
label: Title
toolbarButtons: create|update
manage:
form: $/acme/demo/models/entry/fields.yaml
`
const mlChildFields = `fields:
title:
label: Title
type: mltext
required: true
`
type mlJournal struct {
ID uint `gorm:"column:id;primaryKey"`
Entries []mlEntry `gorm:"-"`
}
func (mlJournal) TableName() string { return "cabana_ml_journals" }
func (mlJournal) Fillable() []string { return []string{} }
func (mlJournal) Rules() map[string]string { return map[string]string{} }
type mlEntry struct {
ID uint `gorm:"column:id;primaryKey"`
JournalID *uint `gorm:"column:journal_id"`
Title string `gorm:"column:title"`
}
func (mlEntry) TableName() string { return "cabana_ml_entries" }
func (mlEntry) Fillable() []string { return []string{"title"} }
func (mlEntry) Rules() map[string]string { return map[string]string{"title": "required"} }
type mlJournalController struct{}
func (mlJournalController) ID() string { return "acme.demo.journals" }
func (mlJournalController) ModelName() string { return "Journal" }
func (mlJournalController) ConfigDir() string { return "controllers/journals" }
func (mlJournalController) NewRecord() any { return &mlJournal{} }
func (mlJournalController) FormExtendQuery(_ context.Context, q *gorm.DB) *gorm.DB {
return q
}
func (mlJournalController) AdminRelationContracts() []RelationContract {
return []RelationContract{{
Name: "entries",
Kind: RelationHasMany,
NewRelated: func() any { return &mlEntry{} },
ForeignKey: "journal_id",
Columns: map[string]string{"title": "title"},
}}
}
var (
_ pact.AdminController = mlJournalController{}
_ pact.AdminRecordSource = mlJournalController{}
_ pact.FormExtendQuery = mlJournalController{}
_ AdminRelationContractProvider = mlJournalController{}
)
func mlRelationFS() fstest.MapFS {
return fstest.MapFS{
"controllers/journals/config_list.yaml": &fstest.MapFile{Data: []byte(mlParentListConfig)},
"controllers/journals/config_form.yaml": &fstest.MapFile{Data: []byte(mlParentFormConfig)},
"controllers/journals/config_relation.yaml": &fstest.MapFile{Data: []byte(mlParentRelation)},
"models/journal/columns.yaml": &fstest.MapFile{Data: []byte(mlParentColumns)},
"models/journal/fields.yaml": &fstest.MapFile{Data: []byte(mlParentFields)},
"models/entry/fields.yaml": &fstest.MapFile{Data: []byte(mlChildFields)},
}
}
func mlRelationCompiled(t *testing.T) *CompiledController {
t.Helper()
reg, err := compileRegistry([]controllerRef{{
plugin: formPlugin{fsys: mlRelationFS()},
ctl: mlJournalController{},
}})
if err != nil {
t.Fatalf("registry: %v", err)
}
cc, ok := reg.Get("acme.demo.journals")
if !ok || cc.Form == nil || cc.Relations["entries"] == nil || cc.Relations["entries"].child == nil {
t.Fatalf("compiled journal missing entries form: %+v", cc)
}
return cc
}
func TestRelationChildMLNestedSave(t *testing.T) {
_, db := newListService(t)
if err := db.Migrator().DropTable(&mlEntry{}, &mlJournal{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&mlJournal{}, &mlEntry{}); err != nil {
t.Fatal(err)
}
parent := mlJournal{}
if err := db.Create(&parent).Error; err != nil {
t.Fatal(err)
}
writer := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}
svc := RelationService{DB: db, writer: writer}
cc := mlRelationCompiled(t)
childForm := cc.Relations["entries"].child
projected := ProjectWritableFields(childForm, map[string]any{
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
"extra": map[string]any{"nested": true},
})
if len(projected) != 0 {
t.Fatalf("unlifted nested maps reached projection: %#v", projected)
}
created, err := svc.CreateChild(context.Background(), cc, "entries", parent.ID, RecordInput{Body: map[string]any{
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
"extra": map[string]any{"nested": true},
}})
if err != nil {
t.Fatalf("create: %v", err)
}
got, ok := created.Data["title"].(map[string]string)
wantTitle := map[string]string{"en": "Hello", "pl": "Witaj"}
if !ok || !reflect.DeepEqual(got, wantTitle) {
t.Fatalf("create title = %#v", created.Data["title"])
}
var row mlEntry
if err := db.First(&row).Error; err != nil {
t.Fatal(err)
}
if row.Title != "Hello" {
t.Fatalf("host title = %q", row.Title)
}
if writer.attrs["en"] != nil {
t.Fatalf("default locale duplicated: %#v", writer.attrs["en"])
}
if writer.attrs["pl"]["title"] != "Witaj" {
t.Fatalf("Polish attributes = %#v", writer.attrs)
}
before := int64(0)
if err := db.Model(&mlEntry{}).Count(&before).Error; err != nil {
t.Fatal(err)
}
_, err = svc.CreateChild(context.Background(), cc, "entries", parent.ID, RecordInput{Body: map[string]any{
"title": map[string]any{"en": "Hello", "de": "Hallo"},
}})
var ve *ValidationError
if err == nil || !errors.As(err, &ve) {
t.Fatalf("undeclared locale err = %v", err)
}
after := int64(0)
if err := db.Model(&mlEntry{}).Count(&after).Error; err != nil {
t.Fatal(err)
}
if after != before {
t.Fatalf("undeclared locale inserted a child: before=%d after=%d", before, after)
}
}