From 4e37d877f16b690c4a06115af53be55fe71acf08 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 6 Oct 2026 15:53:24 +0200 Subject: [PATCH] feat(14.2.1-06): hydrate relation-child update and show ML maps UpdateChild and ShowChild now lift, apply, and hydrate nested locales the same way as host CRUD, so a child form keeps English on the column and Polish through the writer. Co-authored-by: Cursor --- docs/backend/forms.md | 2 +- modules/cabana/README.md | 6 ++--- modules/cabana/relation_child.go | 17 ++++++++++++-- modules/cabana/relation_child_ml_test.go | 30 ++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/docs/backend/forms.md b/docs/backend/forms.md index 1d2cb8b..2b3c4ab 100644 --- a/docs/backend/forms.md +++ b/docs/backend/forms.md @@ -345,7 +345,7 @@ fields: tab: acme.blog::lang.posts.tab_content ``` -A save of `{ "title": { "en": "Hello", "pl": "Witaj" } }` lifts the nested map before `cabana.ProjectWritableFields` drops nested values. The default locale fills the host column; other locales are written through a published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. The default locale is not duplicated into the translation store. Show and save then run `hydrateMLRecord`: each declared ML field in the record payload is a map of every enabled locale (default from the host column, others from `TranslationWriter.TranslatedExact`; a missing non-default code is an empty string, never D-11 fallback). A non-string value, an undeclared locale, a missing default locale, or a nested map on a field that is not declared multilingual is a 422 on that field. Generic nested objects on other field types are still dropped. Cabana never imports a translate plugin and never exposes a public translate-write or translate-read route: if no writer is published, a nested ML map is a 422. +A save of `{ "title": { "en": "Hello", "pl": "Witaj" } }` lifts the nested map before `cabana.ProjectWritableFields` drops nested values. The default locale fills the host column; other locales are written through a published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. The default locale is not duplicated into the translation store. Relation-child create and update on `cabana.RelationService` use the same lift and apply path as controller save; child show hydrates through `hydrateMLRecord` like a host GET. Show and save then run `hydrateMLRecord`: each declared ML field in the record payload is a map of every enabled locale (default from the host column, others from `TranslationWriter.TranslatedExact`; a missing non-default code is an empty string, never D-11 fallback). A non-string value, an undeclared locale, a missing default locale, or a nested map on a field that is not declared multilingual is a 422 on that field. Generic nested objects on other field types are still dropped. Cabana never imports a translate plugin and never exposes a public translate-write or translate-read route: if no writer is published, a nested ML map is a 422. ## What a save may write diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 631d4f6..6c7904f 100644 --- a/modules/cabana/README.md +++ b/modules/cabana/README.md @@ -24,7 +24,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte - Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: ` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot. - Form-only fields: a controller implementing `pact.FormVirtualFields` lists fields of its `fields.yaml` that are not columns of the form. They are exempt from the column binding, never filled into the model and never part of a record response; the values an administrator submits reach the Form hooks through `cabana.VirtualFieldsFromContext`, only for fields whose `context` allows the operation, and a nested value is a 422 on the field. `type: password` is a masked field that must be listed this way, so a password is sent in a save body and never comes back. A controller implementing `pact.FormRules` supplies the validation rules per operation (`create` or `update`), which replace the model's `Rules()` for admin saves; a rule on a virtual field is checked against the submitted value, never against a model column of the same name. Neither is available on settings forms or relation forms. - Permission editor: a `type: permissioneditor` field with `mode: radio` (allow `1`, inherit, deny `-1`) or `mode: checkbox` (allow `1`) edits a record's permission set as a JSON object of code to integer. The controller implements `cabana.PermissionEditorProvider`: it returns the offered `cabana.PermissionOption` list per request (served on the field as `permissionOptions`, with `locked` for permissions the administrator may not change) and reads and stores the record's values, so the storage shape is the plugin's. A save answers 422 on the field for a value that is not an object of integers, a code that is not offered or a value outside the mode's set, and 403 `forbidden` when a locked code's value changes; stored codes that are not offered are kept. The widget fill contract is unchanged: a widget still writes scalar fields only. -- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected. `type: mltext` and `type: mlmarkdown` take a JSON object of locale code to string. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. There is no standalone translate-write or translate-read route. An unknown field type, including an unknown `ml*` type, fails boot. +- Markdown and multilingual fields: `type: markdown` edits source on a host text column; `cabana.RenderMarkdown` turns that source into HTML with the pinned goldmark engine and no unsafe HTML, and leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected. `type: mltext` and `type: mlmarkdown` take a JSON object of locale code to string. The form schema's `cabana.FormMeta.EnabledLocales` lists every enabled code when a `cabana.TranslationWriter` is published; create seeds an empty string per code, and Show/save replace the host scalar with that map via `hydrateMLRecord` and `TranslationWriter.TranslatedExact` (missing non-default codes stay empty; D-11 fallback is not applied). A GET host string is merged onto the seed so sibling locales are not dropped. The default locale fills the host column; other locales reach a plugin-published `cabana.TranslationWriter` after the host row has a primary key, still inside the controller's permissioned save transaction. Relation-child create, update and show on `cabana.RelationService` use the same lift, apply and `hydrateMLRecord` path as controller save, still with no standalone translate-write route. An unknown field type, including an unknown `ml*` type, fails boot. - Preset fields: `preset` on a `type: text` field (a source field name, or a mapping with `field` and `type`, `slug` or `exact`) makes the field follow another text field of the same form on the create screen until the administrator edits it. The schema reports it as `preset` (`cabana.FieldPreset`); the server does not fill the field. - Server-rendered partials: `headerPartial: ` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: ` in `fields.yaml` render the template `{ConfigDir}/_.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot. - Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation. @@ -217,9 +217,9 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.PermissionOption` | One permission a `type: permissioneditor` field offers: code, label, optional tab and comment, and `Locked`. | | `cabana.PermissionEditorProvider` | Controller capability behind a `type: permissioneditor` field: `AdminPermissionOptions`, `AdminPermissionValues` and `AdminSetPermissionValues`. | | `cabana.FormMeta` | Form (and optional list) schema meta: `Locale` is the UI locale of the response; `EnabledLocales` lists content locales for ML fields when a `cabana.TranslationWriter` is published (`json:"enabledLocales,omitempty"` so list schemas omit the key). | -| `cabana.TranslationWriter` | Optional plugin-published service that persists non-default locale values during a host save and reads exact stored translations through `TranslatedExact`. Cabana never imports a translate plugin; writes run only inside the permissioned, scoped save transaction after the host row has a primary key; Show/save hydration uses `hydrateMLRecord`. | +| `cabana.TranslationWriter` | Optional plugin-published service that persists non-default locale values during a host save and reads exact stored translations through `TranslatedExact`. Cabana never imports a translate plugin; writes run only inside the permissioned, scoped save transaction after the host row has a primary key, including relation-child records served by `cabana.RelationService`; Show/save hydration uses `hydrateMLRecord`. | | `cabana.TranslationWriter.TranslatedExact` | Returns the stored value for a field in a locale without D-11 fallback; `ok` is false when the non-default key is missing. The default locale still reads the host column. | -| `hydrateMLRecord` | Replaces host scalars of declared `mltext`/`mlmarkdown` fields on a Show/save record with a locale map for every enabled code. Not used from list row projection. | +| `hydrateMLRecord` | Replaces host scalars of declared `mltext`/`mlmarkdown` fields on a Show/save record — host CRUD and relation-child create, update and show — with a locale map for every enabled code. Not used from list row projection. | | `cabana.RenderMarkdown` | Converts markdown source to HTML with the pinned goldmark engine without unsafe HTML; leftover script or iframe tags, event handlers, or javascript, vbscript or data URLs are rejected. | | `cabana.FieldPreset` | A text field's `preset` in the form schema: the source field and the type, `slug` or `exact`. | | `cabana.WriteData` / `cabana.WriteError` / `cabana.WriteErrorDetails` | Write the admin success and error envelopes. | diff --git a/modules/cabana/relation_child.go b/modules/cabana/relation_child.go index b3bf570..092af55 100644 --- a/modules/cabana/relation_child.go +++ b/modules/cabana/relation_child.go @@ -290,7 +290,10 @@ func (s RelationService) ShowChild(ctx context.Context, cc *CompiledController, return err } result, err = projectFullRecord(ctx, tx, form, child) - return err + if err != nil { + return err + } + return hydrateMLRecord(ctx, tx, form, s.writer, child, result.Data, "update") }) if err != nil { return RecordResult{}, err @@ -324,6 +327,10 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController if err != nil { return err } + translations, err := liftMLValues(ctx, cr.child, in.Body, "update", s.writer, tx) + if err != nil { + return err + } if err := s.fillChild(ctx, tx, cr.child, child, in.Body, "update"); err != nil { return err } @@ -335,6 +342,9 @@ func (s RelationService) UpdateChild(ctx context.Context, cc *CompiledController if err := tx.WithContext(ctx).Save(child).Error; err != nil { return lifecycleFailure(cc, err) } + if err := applyMLTranslations(ctx, tx, s.writer, child, translations); err != nil { + return err + } if err := s.commitChildFiles(ctx, tx, cr, child, "update", in); err != nil { return err } @@ -344,7 +354,10 @@ func (s RelationService) UpdateChild(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, "update") }) if err != nil { return RecordResult{}, err diff --git a/modules/cabana/relation_child_ml_test.go b/modules/cabana/relation_child_ml_test.go index c185bd8..437bf0c 100644 --- a/modules/cabana/relation_child_ml_test.go +++ b/modules/cabana/relation_child_ml_test.go @@ -180,6 +180,36 @@ func TestRelationChildMLNestedSave(t *testing.T) { t.Fatalf("Polish attributes = %#v", writer.attrs) } + updated, err := svc.UpdateChild(context.Background(), cc, "entries", parent.ID, row.ID, RecordInput{Body: map[string]any{ + "title": map[string]any{"en": "Hello", "pl": "Cześć"}, + }}) + if err != nil { + t.Fatalf("update: %v", err) + } + got, ok = updated.Data["title"].(map[string]string) + wantUpdated := map[string]string{"en": "Hello", "pl": "Cześć"} + if !ok || !reflect.DeepEqual(got, wantUpdated) { + t.Fatalf("update title = %#v", updated.Data["title"]) + } + if err := db.First(&row, row.ID).Error; err != nil { + t.Fatal(err) + } + if row.Title != "Hello" { + t.Fatalf("host title after update = %q", row.Title) + } + if writer.attrs["pl"]["title"] != "Cześć" { + t.Fatalf("updated Polish attributes = %#v", writer.attrs) + } + + shown, err := svc.ShowChild(context.Background(), cc, "entries", parent.ID, row.ID) + if err != nil { + t.Fatalf("show: %v", err) + } + got, ok = shown.Data["title"].(map[string]string) + if !ok || !reflect.DeepEqual(got, wantUpdated) { + t.Fatalf("show title = %#v", shown.Data["title"]) + } + before := int64(0) if err := db.Model(&mlEntry{}).Count(&before).Error; err != nil { t.Fatal(err)