From 424fe9ccc06d34436e148add6334d1c2206723c2 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Tue, 6 Oct 2026 15:41:46 +0200 Subject: [PATCH] feat(14.2.1-05): cover mlmarkdown, child adopt, and regen admin artifacts MLMarkdownField and relation-child forms share the enabled-locale inject and adopt merge; OpenAPI, types, docs, and boardwalk dist match. Co-authored-by: Cursor --- admin/openapi/admin.json | 6 ++++ admin/src/api/schema.d.ts | 1 + .../form/fields/MLMarkdownField.vue | 8 +++-- .../relation/RelationChildModal.vue | 20 ++++++++--- .../relation/RelationPickerModal.vue | 2 +- admin/tests/form/MLFields.test.ts | 33 +++++++++++++++++-- admin/tests/form/formState.test.ts | 26 +++++++++++++-- docs/backend/admin-controllers.md | 2 +- docs/backend/forms.md | 4 +-- .../boardwalk/dist/assets/index-B08bxLq5.js | 9 +++++ .../boardwalk/dist/assets/index-CT96jdWe.js | 9 ----- modules/boardwalk/dist/index.html | 2 +- modules/cabana/README.md | 7 ++-- modules/cabana/http.go | 3 ++ 14 files changed, 104 insertions(+), 28 deletions(-) create mode 100644 modules/boardwalk/dist/assets/index-B08bxLq5.js delete mode 100644 modules/boardwalk/dist/assets/index-CT96jdWe.js diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index 47a6ed7..b51aa26 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -913,6 +913,12 @@ }, "cabana.FormMeta": { "properties": { + "enabledLocales": { + "items": { + "type": "string" + }, + "type": "array" + }, "locale": { "type": "string" } diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index f5fb1cb..30e6641 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -4506,6 +4506,7 @@ export interface components { update: components["schemas"]["cabana.MessageForms"]; }; "cabana.FormMeta": { + enabledLocales?: string[]; locale: string; }; "cabana.FormOption": { diff --git a/admin/src/components/form/fields/MLMarkdownField.vue b/admin/src/components/form/fields/MLMarkdownField.vue index b32ed8f..61ec000 100644 --- a/admin/src/components/form/fields/MLMarkdownField.vue +++ b/admin/src/components/form/fields/MLMarkdownField.vue @@ -1,16 +1,18 @@ + diff --git a/modules/cabana/README.md b/modules/cabana/README.md index 1bd7ec4..631d4f6 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 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 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. There is no standalone translate-write or translate-read 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. @@ -216,7 +216,10 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS } | `cabana.VirtualFieldsFromContext` | The submitted values of the form's virtual fields (`pact.FormVirtualFields`), from the context of a Form hook during a create or update; a copy, keyed by field name. | | `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.TranslationWriter` | Optional plugin-published service that persists non-default locale values during a host save. Cabana never imports a translate plugin; writes run only inside the permissioned, scoped save transaction after the host row has a primary key. | +| `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.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. | | `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/http.go b/modules/cabana/http.go index bfb50bb..747a578 100644 --- a/modules/cabana/http.go +++ b/modules/cabana/http.go @@ -527,6 +527,9 @@ func (s *service) relationSchema(w http.ResponseWriter, r *http.Request) { if locale := schemaLocale(r.Context(), tr); locale != "" { meta["locale"] = locale } + if codes := s.enabledContentLocales(r.Context()); len(codes) > 0 { + meta["enabledLocales"] = append([]string(nil), codes...) + } WriteData(w, http.StatusOK, view, meta) }) }