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:
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
201
modules/cabana/relation_child_ml_test.go
Normal file
201
modules/cabana/relation_child_ml_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user