From f388d27604507a5b4c4e6226bdf09726e2041529 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 10 Oct 2026 16:45:05 +0200 Subject: [PATCH] fix(16.2): WR-06 hand a translatable Decode only strings liftMLValues validated only object-shaped submissions, so a scalar submitted for a translatable plugin field reached Decode as nil, a float64 or a bool although pact promises a string; a type that asserts a string panicked into an opaque 500. decodePluginFields now passes a string through, turns null into the empty string and refuses any other value with a 422 on the field before Decode runs. --- docs/backend/field-types.md | 2 +- modules/cabana/field_type.go | 10 +++++++ modules/cabana/field_type_test.go | 45 +++++++++++++++++++++++++++++++ modules/pact/README.md | 2 +- modules/pact/capabilities.go | 4 ++- 5 files changed, 60 insertions(+), 3 deletions(-) diff --git a/docs/backend/field-types.md b/docs/backend/field-types.md index a1b1f31..dadb74b 100644 --- a/docs/backend/field-types.md +++ b/docs/backend/field-types.md @@ -100,7 +100,7 @@ A notice is how a type that changes an accepted value tells the administrator wh ## Translatable types -A type with `Translatable: true` holds one string per content locale, like `mlmarkdown`. The admin sends and receives a map of locale to string, and cabana stores it through its own multilingual path: the default locale's value goes to the host column, every other locale through the published `cabana.TranslationWriter`, inside the same permissioned save transaction. The plugin implements no locale storage. +A type with `Translatable: true` holds one string per content locale, like `mlmarkdown`. The admin sends and receives a map of locale to string, and cabana stores it through its own multilingual path: the default locale's value goes to the host column, every other locale through the published `cabana.TranslationWriter`, inside the same permissioned save transaction. The plugin implements no locale storage. `Decode` of a translatable type always receives a string: a client that submits a plain value instead of a locale map has it decoded for the default locale, `null` as the empty string, and any other JSON value (a number, a boolean) is a 422 on the field before `Decode` runs. `Decode` runs once per submitted locale, with `Locale` set to that content locale and `Default` true for the host column's locale; its `Value` must be a string. Each locale's notices carry that locale. `Load` runs once per locale of the record response. `Translated` reads another attribute of the same record in the same locale, exactly and without fallback, which a type that keeps two columns in step (a source and a rendered copy, for example) uses to compare them. Without a translation writer a locale map is a 422, as for the built-in multilingual types. diff --git a/modules/cabana/field_type.go b/modules/cabana/field_type.go index dab99ff..61ed9b1 100644 --- a/modules/cabana/field_type.go +++ b/modules/cabana/field_type.go @@ -474,6 +474,16 @@ func decodePluginFields(ctx context.Context, in pluginDecodeInput) (pluginDecode return pluginDecodeResult{}, err } if value, ok := in.Projected[field.Name]; ok { + // pact.FormFieldValue promises a translatable type a string: a + // scalar submitted instead of a locale map is text, null is + // empty, anything else is refused before Decode runs (WR-06). + switch value.(type) { + case string: + case nil: + value = "" + default: + return pluginDecodeResult{}, &ValidationError{Details: fieldDetail(field.Name, "The "+field.Name+" field must be text or an object of locale codes to text.")} + } stored, keep, err := decodeOne(ctx, in, field, def, true, value, &out) if err != nil { return pluginDecodeResult{}, err diff --git a/modules/cabana/field_type_test.go b/modules/cabana/field_type_test.go index b4ffb3a..7dbe958 100644 --- a/modules/cabana/field_type_test.go +++ b/modules/cabana/field_type_test.go @@ -759,6 +759,51 @@ func TestFieldTypeDecode(t *testing.T) { } }) + // WR-06: pact promises a translatable type's Decode a string. + t.Run("a translatable Decode only ever receives a string", func(t *testing.T) { + var got []any + probe := ftNotesMLType() + probe.Decode = func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) { + got = append(got, in.Value) + text := in.Value.(string) // panics if the contract is broken + return pact.FormFieldDecoded{Value: text}, nil + } + occ := ftOnly(t, ftNotesField, ftNotesType(), probe) + for _, tc := range []struct { + name string + body any + want string + }{ + {"a string", "plain", "plain"}, + {"null", nil, ""}, + } { + got = nil + res, projected, _, err := ftSave(t, occ, ftWriter(), &ftNote{ID: 1}, map[string]any{"body": tc.body}, false) + if err != nil { + t.Fatalf("%s: err=%v", tc.name, err) + } + if len(got) != 1 || got[0] != tc.want || projected["body"] != tc.want || len(res.writes["body"]) != 1 { + t.Fatalf("%s: Decode got %#v, projected %#v, writes %v", tc.name, got, projected["body"], res.writes) + } + } + for name, body := range map[string]any{"a number": 3.0, "a boolean": true, "an integer": 7} { + got = nil + _, _, _, err := ftSave(t, occ, ftWriter(), &ftNote{ID: 1}, map[string]any{"body": body}, false) + var invalid *ValidationError + if !errors.As(err, &invalid) || fmt.Sprint(invalid.Details["body"]) != "[The body field must be text or an object of locale codes to text.]" { + t.Fatalf("%s: err=%v", name, err) + } + if len(got) != 0 { + t.Fatalf("%s: Decode ran with %#v", name, got) + } + rec := httptest.NewRecorder() + writeCRUDError(rec, err) + if rec.Code != http.StatusUnprocessableEntity { + t.Fatalf("%s: status=%d body=%s", name, rec.Code, rec.Body.String()) + } + } + }) + t.Run("Decode receives the save's record, options and locale", func(t *testing.T) { var got []pact.FormFieldValue probe := ftNotesMLType() diff --git a/modules/pact/README.md b/modules/pact/README.md index 050bb8e..ac6a0c8 100644 --- a/modules/pact/README.md +++ b/modules/pact/README.md @@ -121,7 +121,7 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand { | `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. | | `pact.HasFormFieldTypes` | Implemented by a plugin (not a controller) that registers admin form field types. | | `pact.FormFieldType` | One field type: the alias `Name`, the custom-element tag `Element`, `Translatable`, the `Scripts` and `Styles` under `assets/`, the `Options` keys, and the funcs `CheckOptions` (optional), `Decode` (required), `Load`, `ListValue` (`func(ctx context.Context, value any) string`) and `Upload` (optional). | -| `pact.FormFieldValue` | What `Decode` and `Load` receive: the transaction, the record, the field, its options, the content locale (translatable types), whether the value lives in the host column, the value and an exact reader of another attribute in the same locale. | +| `pact.FormFieldValue` | What `Decode` and `Load` receive: the transaction, the record, the field, its options, the content locale (translatable types), whether the value lives in the host column, the value (always a string for a translatable type) and an exact reader of another attribute in the same locale. | | `pact.FormFieldDecoded` | `Decode`'s answer: the `Value` to store (a string for a translatable type) and optional `Notices`. | | `pact.FormFieldNotice` | A message about a saved value: a phrase key or text with params; a positive `Count` selects the plural form. | | `pact.FormFieldUpload` | What `Upload` receives: the transaction, the scoped record (nil on the create form), the field, its options, the file name and the capped body. | diff --git a/modules/pact/capabilities.go b/modules/pact/capabilities.go index 5720451..55a9944 100644 --- a/modules/pact/capabilities.go +++ b/modules/pact/capabilities.go @@ -416,7 +416,9 @@ type FormFieldType struct { // translatable type and empty otherwise; Default is true when the value lives // in the host column (always true for a type that is not translatable). Value // is the submitted value (Decode) or the stored value (Load); it is a string -// for a translatable type and any JSON value otherwise. Translated, when set, +// for a translatable type (the framework refuses any other submitted value, +// null aside, which is the empty string, before Decode runs) and any JSON +// value otherwise. Translated, when set, // reads another attribute of Record in Locale exactly, without fallback; it is // nil for a type that is not translatable and when no translation writer is // published.