diff --git a/modules/cabana/http.go b/modules/cabana/http.go index 747a578..6d63351 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -656,7 +656,13 @@ func (s *service) relations() (RelationService, error) { if err != nil { return RelationService{}, err } - return RelationService{DB: db, bucket: s.bucket(), tr: s.translator()}, nil + svc := RelationService{DB: db, bucket: s.bucket(), tr: s.translator()} + if s.app != nil { + if w, ok := s.app.Lookup[TranslationWriter](); ok { + svc.writer = w + } + } + return svc, nil } // relationsFor is the relation service of one request, carrying its diff --git a/modules/cabana/ml_smoke_test.go b/modules/cabana/ml_smoke_test.go index 1a235da..3ae5f0b 100644 --- a/modules/cabana/ml_smoke_test.go +++ b/modules/cabana/ml_smoke_test.go @@ -80,11 +80,11 @@ func (w *recordingWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, e } func (w *recordingWriter) WriteTranslated(_ context.Context, _ *gorm.DB, model any, field, locale, value string) error { - post, _ := model.(*mlPost) - if post == nil || post.ID == 0 { + id := pkUint(model) + if id == 0 { return &ValidationError{Details: map[string]any{field: []string{"translation writer ran before the host row had an id"}}} } - w.ids = append(w.ids, post.ID) + w.ids = append(w.ids, id) if w.attrs == nil { w.attrs = map[string]map[string]string{} } diff --git a/modules/cabana/relation.go b/modules/cabana/relation.go index a78d3f6..3a5b1d4 100644 --- a/modules/cabana/relation.go +++ b/modules/cabana/relation.go @@ -186,9 +186,11 @@ type RelationService struct { SessionKey string // bucket deletes the blobs of child files a save removes, after commit; - // tr localizes date bound messages. Both may be nil. + // tr localizes date bound messages. Both may be nil. writer is the same + // optional plugin adapter CRUDService uses for mltext/mlmarkdown. bucket *blob.Bucket tr *phrasebook.Translator + writer TranslationWriter } func (s RelationSchema) MarshalJSON() ([]byte, error) { diff --git a/modules/cabana/relation_child.go b/modules/cabana/relation_child.go index 1a5de2c..b3bf570 100644 --- a/modules/cabana/relation_child.go +++ b/modules/cabana/relation_child.go @@ -165,6 +165,10 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController return lifecycleFailure(cc, err) } } + translations, err := liftMLValues(ctx, cr.child, in.Body, "create", s.writer, tx) + if err != nil { + return err + } if err := s.fillChild(ctx, tx, cr.child, child, in.Body, "create"); err != nil { return err } @@ -176,6 +180,9 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController if err := tx.WithContext(ctx).Create(child).Error; err != nil { return lifecycleFailure(cc, err) } + if err := applyMLTranslations(ctx, tx, s.writer, child, translations); err != nil { + return err + } switch { case parent.unsaved(): // The child exists now, unattached; the parent's first save @@ -198,7 +205,10 @@ func (s RelationService) CreateChild(ctx context.Context, cc *CompiledController } } result, err = projectFullRecord(ctx, tx, cr.child, child) - return err + if err != nil { + return err + } + return hydrateMLRecord(ctx, tx, cr.child, s.writer, child, result.Data, "create") }) if err != nil { return RecordResult{}, err diff --git a/modules/cabana/relation_child_ml_test.go b/modules/cabana/relation_child_ml_test.go new file mode 100644 index 0000000..c185bd8 --- /dev/null +++ b/modules/cabana/relation_child_ml_test.go @@ -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) + } +}