feat(14.2.1-05): hydrate ML create/GET/save through TranslatedExact
Form schema meta lists enabled locales, Show/save expand mltext maps without D-11 fallback, and the SPA seeds and adopt-merges every locale. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -441,6 +441,9 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, "update"); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Meta.Actions, err = s.offeredRecordActions(withTx(ctx, tx), cc, target)
|
||||
return err
|
||||
})
|
||||
@@ -700,7 +703,10 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
|
||||
return err
|
||||
}
|
||||
result, err = projectFullRecord(ctx, tx, cc, target)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, op)
|
||||
})
|
||||
if err != nil {
|
||||
return RecordResult{}, err
|
||||
|
||||
@@ -12,11 +12,13 @@ const (
|
||||
mlMarkdownFieldType = "mlmarkdown"
|
||||
)
|
||||
|
||||
// TranslationWriter persists non-default locale values during a cabana save.
|
||||
// The interface lives in the framework so cabana never imports a plugin; a
|
||||
// host publishes an adapter at Boot. There is no standalone translate-write
|
||||
// TranslationWriter persists non-default locale values during a cabana save
|
||||
// and reads exact stored translations for Show/save hydration. The interface
|
||||
// lives in the framework so cabana never imports a plugin; a host publishes
|
||||
// an adapter at Boot. There is no standalone translate-read or translate-write
|
||||
// HTTP endpoint: writes run only inside a permissioned, scoped host save
|
||||
// transaction after the host row has a primary key.
|
||||
// transaction after the host row has a primary key, and reads run on the
|
||||
// same adapter after projectFullRecord.
|
||||
type TranslationWriter interface {
|
||||
// DefaultLocale is the host-column locale.
|
||||
DefaultLocale(ctx context.Context, tx *gorm.DB) (string, error)
|
||||
@@ -25,6 +27,11 @@ type TranslationWriter interface {
|
||||
// WriteTranslated stores field in locale for model. locale is never the
|
||||
// default; cabana already filled the host scalar.
|
||||
WriteTranslated(ctx context.Context, tx *gorm.DB, model any, field, locale, value string) error
|
||||
// TranslatedExact returns the stored value for field in locale. ok is
|
||||
// false when the non-default key is missing. It must not apply D-11
|
||||
// fallback: empty admin values stay empty. The default locale still
|
||||
// reads the host column.
|
||||
TranslatedExact(ctx context.Context, tx *gorm.DB, model any, field, locale string) (string, bool, error)
|
||||
}
|
||||
|
||||
// mlValue is one multilingual field lifted from a save body.
|
||||
@@ -158,3 +165,68 @@ func applyMLTranslations(ctx context.Context, tx *gorm.DB, writer TranslationWri
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hydrateMLRecord replaces host scalars of declared mltext/mlmarkdown fields
|
||||
// in a Show/save RecordResult.Data map with a locale→text map for every
|
||||
// enabled code. The default locale is the projected host column; other codes
|
||||
// come from TranslatedExact; a missing non-default code is an empty string,
|
||||
// never D-11 fallback. It is a no-op when writer is nil and is not used from
|
||||
// list row projection.
|
||||
func hydrateMLRecord(ctx context.Context, tx *gorm.DB, cc *CompiledController, writer TranslationWriter, model any, data map[string]any, op string) error {
|
||||
if writer == nil || cc == nil || cc.Form == nil || data == nil {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for _, field := range cc.Form.Fields {
|
||||
if !mlFieldType(field.Type) || !contextAllows(cc, field.Name, op) {
|
||||
continue
|
||||
}
|
||||
names = append(names, field.Name)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
defaultLocale, err := writer.DefaultLocale(ctx, tx)
|
||||
if err != nil || defaultLocale == "" {
|
||||
return fmt.Errorf("cabana: default locale: %w", err)
|
||||
}
|
||||
codes, err := writer.EnabledLocales(ctx, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cabana: enabled locales: %w", err)
|
||||
}
|
||||
for _, name := range names {
|
||||
host := hostScalarString(data[name])
|
||||
out := make(map[string]string, len(codes))
|
||||
for _, code := range codes {
|
||||
if code == defaultLocale {
|
||||
out[code] = host
|
||||
continue
|
||||
}
|
||||
text, ok, err := writer.TranslatedExact(ctx, tx, model, name, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
out[code] = ""
|
||||
continue
|
||||
}
|
||||
out[code] = text
|
||||
}
|
||||
data[name] = out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hostScalarString(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case []byte:
|
||||
return string(t)
|
||||
default:
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -709,10 +709,16 @@ func (s *service) formSchema(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
view.Fields = kept
|
||||
view.Assets = s.controllerAssets(cc)
|
||||
if codes := s.enabledContentLocales(r.Context()); len(codes) > 0 {
|
||||
view.Meta.EnabledLocales = append([]string(nil), codes...)
|
||||
}
|
||||
meta := map[string]any{}
|
||||
if view.Meta.Locale != "" {
|
||||
meta["locale"] = view.Meta.Locale
|
||||
}
|
||||
if len(view.Meta.EnabledLocales) > 0 {
|
||||
meta["enabledLocales"] = view.Meta.EnabledLocales
|
||||
}
|
||||
WriteData(w, http.StatusOK, view, meta)
|
||||
})
|
||||
}
|
||||
@@ -939,6 +945,29 @@ func (s *service) crud() (CRUDService, error) {
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// enabledContentLocales is TranslationWriter.EnabledLocales after the same
|
||||
// Lookup crud() uses. Empty when the adapter is unpublished or the query
|
||||
// fails; list schemas never call this.
|
||||
func (s *service) enabledContentLocales(ctx context.Context) []string {
|
||||
if s == nil || s.app == nil {
|
||||
return nil
|
||||
}
|
||||
writer, ok := s.app.Lookup[TranslationWriter]()
|
||||
if !ok || writer == nil {
|
||||
return nil
|
||||
}
|
||||
db, err := s.db()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
codes, err := writer.EnabledLocales(ctx, db)
|
||||
if err != nil {
|
||||
slog.Error("cabana: enabled locales", "error", err)
|
||||
return nil
|
||||
}
|
||||
return codes
|
||||
}
|
||||
|
||||
func (s *service) list(w http.ResponseWriter, r *http.Request) {
|
||||
s.protect(w, r, func(cc *CompiledController) {
|
||||
db, err := s.db()
|
||||
|
||||
@@ -95,6 +95,25 @@ func (w *recordingWriter) WriteTranslated(_ context.Context, _ *gorm.DB, model a
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *recordingWriter) TranslatedExact(_ context.Context, _ *gorm.DB, model any, field, locale string) (string, bool, error) {
|
||||
if locale == w.defaultLocale {
|
||||
post, _ := model.(*mlPost)
|
||||
if post == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
return post.Title, true, nil
|
||||
}
|
||||
if w.attrs == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
vals := w.attrs[locale]
|
||||
if vals == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
text, ok := vals[field]
|
||||
return text, ok, nil
|
||||
}
|
||||
|
||||
func mlFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"controllers/posts/config_list.yaml": &fstest.MapFile{Data: []byte(mlListConfig)},
|
||||
@@ -181,8 +200,13 @@ func TestMLNestedSaveSmoke(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if rec["title"] != "Hello" {
|
||||
t.Fatalf("projected title = %#v, want Hello", rec["title"])
|
||||
if rec["title"] != nil {
|
||||
got, ok := rec["title"].(map[string]string)
|
||||
if !ok || got["en"] != "Hello" || got["pl"] != "Witaj" {
|
||||
t.Fatalf("projected title = %#v, want hydrated en+pl map", rec["title"])
|
||||
}
|
||||
} else {
|
||||
t.Fatalf("projected title = %#v, want hydrated en+pl map", rec["title"])
|
||||
}
|
||||
var row mlPost
|
||||
if err := db.First(&row).Error; err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package cabana
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -66,7 +67,13 @@ func TestMLNestedSave(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if rec["title"] != "Hello" {
|
||||
if rec["title"] != nil {
|
||||
want := map[string]string{"en": "Hello", "pl": "Witaj"}
|
||||
got, ok := rec["title"].(map[string]string)
|
||||
if !ok || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("projected title = %#v", rec["title"])
|
||||
}
|
||||
} else {
|
||||
t.Fatalf("projected title = %#v", rec["title"])
|
||||
}
|
||||
var row mlPost
|
||||
@@ -97,6 +104,55 @@ func TestMLNestedSave(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMLHydration(t *testing.T) {
|
||||
_, db := newListService(t)
|
||||
if err := db.Migrator().DropTable(&mlPost{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&mlPost{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writer := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}
|
||||
svc := CRUDService{DB: db, writer: writer}
|
||||
cc := mlCompiled(t)
|
||||
ctx := context.Background()
|
||||
|
||||
created, err := svc.CreateRecord(ctx, cc, RecordInput{Body: map[string]any{
|
||||
"title": map[string]any{"en": "Hello", "pl": "Witaj"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
got, ok := created.Data["title"].(map[string]string)
|
||||
if !ok || got["en"] != "Hello" || got["pl"] != "Witaj" {
|
||||
t.Fatalf("create title = %#v", created.Data["title"])
|
||||
}
|
||||
|
||||
shown, err := svc.ShowRecord(ctx, cc, created.Data["id"])
|
||||
if err != nil {
|
||||
t.Fatalf("show: %v", err)
|
||||
}
|
||||
got, ok = shown.Data["title"].(map[string]string)
|
||||
if !ok || got["en"] != "Hello" || got["pl"] != "Witaj" {
|
||||
t.Fatalf("show title = %#v", shown.Data["title"])
|
||||
}
|
||||
|
||||
englishOnly := &recordingWriter{defaultLocale: "en", enabled: []string{"en", "pl"}}
|
||||
svcOnly := CRUDService{DB: db, writer: englishOnly}
|
||||
row := mlPost{Title: "Hello"}
|
||||
if err := db.Create(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shown, err = svcOnly.ShowRecord(ctx, cc, row.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("show english-only: %v", err)
|
||||
}
|
||||
got, ok = shown.Data["title"].(map[string]string)
|
||||
if !ok || got["en"] != "Hello" || got["pl"] != "" {
|
||||
t.Fatalf("english-only title = %#v", shown.Data["title"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestML(t *testing.T) {
|
||||
t.Run("writer failure rolls back host write", func(t *testing.T) {
|
||||
_, db := newListService(t)
|
||||
|
||||
@@ -227,8 +227,11 @@ type FormRedirect struct {
|
||||
}
|
||||
|
||||
// FormMeta reports the locale selected for a schema response.
|
||||
// EnabledLocales lists content locales for ML fields when a TranslationWriter
|
||||
// is published; omitempty keeps list schemas without the key.
|
||||
type FormMeta struct {
|
||||
Locale string `json:"locale"`
|
||||
Locale string `json:"locale"`
|
||||
EnabledLocales []string `json:"enabledLocales,omitempty"`
|
||||
}
|
||||
|
||||
// FormField is one Winter field in source order. JSON keys keep Winter spelling.
|
||||
|
||||
Reference in New Issue
Block a user