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) } }