docs(32-01): form extensions in admin-controllers, cabana and pact READMEs

- admin-controllers: placement, values, Load/Save, permissions,
  widgets, partials and boot errors, with the ExtrasPlugin snippet
- Example_formExtension compiles an owner form with the extension
- coming-from-wintercms maps extendFormFields, addFields, addTabFields
  and removeField to the form extension
- cabana README feature bullet and Activate row
This commit is contained in:
2026-10-26 22:05:22 +01:00
parent e7423bd21f
commit fa1af69376
5 changed files with 232 additions and 7 deletions

View File

@@ -534,17 +534,89 @@ Contributions are compiled only when the admin is active, that is when some plug
## Adding fields to another controller's form
The same `pact.AdminActionExtension` carries form fields: set `Form` to a `pact.AdminFormExtension`. The extension describes its fields in a fields document, the `fields.yaml` format plus three placement keys, and stores their values itself through `Load` and `Save`. The owner's model, columns and tables are never touched.
A plugin can also place fields and tabs in another controller's form, such as a blog post form it does not own, and keep their values in its own tables. This is WinterCMS's `extendFormFields` with real placement. The same `pact.AdminActionExtension` carries it: set `Form` to a `pact.AdminFormExtension`.
The fields come in a fields document, the `fields.yaml` format plus three keys per field (`before:`, `after:`, `zone:`) and an optional `tabs:` list:
```yaml
# extensions/posts.yaml in the acme.extra plugin's AdminFS
tabs:
- name: acme.extras::lang.tab.publishing # the tab key
after: acme.blog::lang.tab.content # an owner tab; before: works too
fields:
subtitle:
label: acme.extra::lang.subtitle
label: acme.extras::lang.subtitle
type: text
after: title # the owner's field; before: works the same way
span: left
after: title # an owner field
featured:
label: acme.extras::lang.featured
type: switch
tab: acme.extras::lang.tab.publishing # end of that tab
```
The plugin returns an extension with `Controller: "acme.blog.posts"` and `Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Load: ..., Save: ...}`, where `Load` takes a `pact.AdminFormLoad` and returns the values by field name, and `Save` takes a `pact.AdminFormSave` and returns a `pact.AdminFormSaveResult`.
`Fields` names the document's path in the plugin's `AdminFS` (resolved like `config_form.yaml`'s `form:`); `FieldsYAML` takes the document's bytes instead, for fields a plugin builds at boot, such as one tab per registered content type. `Load` returns the values for a record by field name and `Save` stores them:
The admin places `subtitle` between `title` and the field that followed it, and the form schema sends the fields in that order. On save, the extension's keys leave the request body before anything else reads it, so they never reach `Fill` or the owner's columns; after the owner row is written, `Save` runs inside the same transaction with `pact.AdminFormSave.Values`, and an error rolls the whole save back. On show, `Load` returns the values by field name.
```go src=modules/cabana/example_form_extension_test.go#ExtrasPlugin.AdminActionExtensions
// AdminActionExtensions adds fields and a tab to the acme.blog posts form.
func (ExtrasPlugin) AdminActionExtensions() []pact.AdminActionExtension {
return []pact.AdminActionExtension{{
Controller: "acme.blog.posts",
Form: &pact.AdminFormExtension{
Fields: "extensions/posts.yaml", // in this plugin's AdminFS
Hide: []string{"summary"},
Permissions: []string{"acme.extras.edit"},
Load: func(ctx context.Context, in pact.AdminFormLoad) (map[string]any, error) {
var row ExtraValue
err := in.DB.Where("post_id = ?", in.ID).Limit(1).Find(&row).Error
return map[string]any{"subtitle": row.Subtitle, "featured": row.Featured}, err
},
Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
subtitle, _ := in.Values["subtitle"].(string)
if len(subtitle) > 120 {
return pact.AdminFormSaveResult{}, pact.AdminFormErrors{"subtitle": {"acme.extras::lang.subtitle_too_long"}}
}
id, err := strconv.ParseUint(in.ID, 10, 64)
if err != nil {
return pact.AdminFormSaveResult{}, err
}
featured, _ := in.Values["featured"].(bool)
row := ExtraValue{PostID: uint(id), Subtitle: subtitle, Featured: featured}
// in.DB is the record's save transaction: an error rolls back
// the owner's row too.
return pact.AdminFormSaveResult{}, in.DB.Save(&row).Error
},
},
}}
}
```
### Placement
Every field and tab of a compiled form has a stable name: the field key, and the tab key, which is the raw `tab:` value of the owner's `fields.yaml` (a phrase key or literal text). `default` names the tab of fields without a `tab:`.
- A field goes `after:` or `before:` its anchor field. Without an anchor it goes to the end of its tab, the default tab when it has no `tab:`. A field with an anchor and no `tab:` joins the anchor's tab; with both, they must agree.
- A new tab is declared under `tabs:` and goes `after:` or `before:` an anchor tab, or after the last tab without one. `after: default` is allowed; `before: default` stops the boot, because the admin always renders the default tab first. A form without tabs that gains one renders its own fields under the default tab ("Basic"), then the new tab.
- `zone:` takes `tabs` (the default) only for now. `outside` and `secondary` (`pact.AdminFormZoneOutside`, `pact.AdminFormZoneSecondary`) are reserved and stop the boot with "is not supported yet".
- Extensions apply in plugin activation order (`Requires()` first). Several extensions anchored at the same point keep that order. An anchor may name a field or tab another plugin's extension added only when that plugin is in the contributor's `Requires()`; the fields of one document may anchor on each other in any order.
- `Hide` lists owner fields to leave out of the form for every administrator: they leave the schema, the record and the save, and a tab that loses all its fields disappears. The owner's model must not require a hidden field.
- cabana compiles the order once at boot and sends the fields in that order. A form an extension lays out is regrouped by tab in first-appearance order, the default tab first, exactly as the admin renders tabs; a form no extension lays out (including one that only gets a `Save` hook) is compiled byte for byte as before. The admin renders what it receives and adds no ordering of its own, and `span` (left, right, full) keeps working next to inserted fields.
- Only top-level fields and tabs are placed; fields inside repeaters stay the owner's.
### Values, Load and Save
An extension adds value fields (`text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` with listed options, `markdown`, `mltext`, `mltextarea`, `mlmarkdown` and plugin field types), `widget` fields and `partial` fields. Types that bind to the owner's model are refused at boot, `fileupload` included: an extension owns no attachments, so a plugin field type with `Upload` stores files instead.
- On save, the extension's keys leave the request body before anything else reads it, so they never reach `Fill`, the owner's columns, its translation rows or its attachments. After the owner row is written (and its translations and after hooks have run), `Save` runs inside the same transaction with `pact.AdminFormSave`: the `Record` with its `ID`, `Created`, and `Values`, the submitted values of the extension's fields whose `context` allows the operation. `Save` runs on every create and update, with or without values.
- A translatable field (`mltext`, `mltextarea`, `mlmarkdown`, a translatable plugin field type) reaches `Save` as a `map[string]string` of locale to text, and `Load` returns it the same way. A plugin field type's `Decode` runs before `Save` (its notices are kept, a `pact.FormFieldError` is a 422 on the field) and its `Load` after the extension's `Load`.
- A `Save` error rolls the whole save back, the owner's row included. `pact.AdminFormErrors` (field name to messages, phrase keys or text) answers 422 with those lines on the fields; any other error is logged and answered with the generic 500 body. `pact.AdminFormSaveResult.Notices` are save notices per field, shown on the field like a field type's notices; a notice may name an owner field.
- On show and in the save response, `Load` returns the values by field name; keys that are not the extension's fields are dropped.
### Permissions, widgets and partials
`Permissions` (backend permission codes some plugin registers) and `Visible` (asked per request) decide who sees the extension. An administrator who fails either gets none of its fields in the form schema or the record; a save drops their posted keys for it and does not call its `Save`, so stored values stay as they are. Its widget actions and partials answer 404 for them.
A `widget` field's tag uses the contributing plugin's prefix (`acme-extras-...`), its `action` names one of the extension's `Actions` (a name the owner already registers stops the boot), and its `fill` keys name value fields of the same extension. Its element script comes from one of the plugin's field types (loaded on every admin page) or from the plugin's own `pact.AdminClientAssets`, which cabana adds to the target form's assets. A `partial` field renders `_<path>.htm` from `Dir` in the contributing plugin's `AdminFS` through `PartialData`, with the same view model rules as `pact.AdminPartialData`; a partial name the owner already uses stops the boot.
### Boot errors
The start-up stops, naming the plugin, the controller and the offending name, on: an unknown controller or one without a form; an unknown field or tab anchor; a duplicate field name (against the owner or an earlier extension) or tab key; two tab keys whose labels translate to the same text in some locale; an anchor cycle among one document's fields; an anchor on another plugin's field or tab outside `Requires()`; `before: default`; a zone other than `tabs`; a `Hide` of an unknown field or of a field another field references through `fill`, `dependsOn`, `trigger` or `preset`; a refused field type; value fields without `Save`; a widget action or partial name the owner already uses; both `Fields` and `FieldsYAML`; and any `fields.yaml` error in the document.

View File

@@ -26,6 +26,7 @@ Each row names the WinterCMS concept, the SummerCMS identifiers that replace it,
| Laravel collections: `map`, `filter`, `reduce`, `keyBy`, `groupBy`, `unique`, `partition`, `first` and `pluck` | Generic functions on plain slices and maps: `sieve.Map`, `sieve.TryMap`, `sieve.Filter`, `sieve.Reduce`, `sieve.KeyBy`, `sieve.GroupBy`, `sieve.Uniq`, `sieve.Partition` and `sieve.Find`. Pluck is `sieve.Map` with a field accessor, unique by key is `sieve.UniqBy`, `chunk` is the standard `slices.Chunk`, and sets are built with `sieve.SetOf` | [sieve](../../modules/sieve/README.md) |
| `fields.yaml` and `columns.yaml` | The same YAML, embedded in the plugin through `pact.AdminAssets` and compiled at boot into a `cabana.CompiledController` | [Forms](../backend/forms.md), [Lists and filters](../backend/lists-and-filters.md), [cabana](../../modules/cabana/README.md) |
| Backend controllers with the Form, List and Relation behaviours | A `pact.AdminController` returned from `pact.HasAdminControllers`; the generic admin API replaces the behaviours, and hooks such as `pact.FormBeforeCreate` and `pact.ListExtendQuery` replace behaviour overrides | [Admin controllers](../backend/admin-controllers.md), [cabana](../../modules/cabana/README.md) |
| `extendFormFields` (the `backend.form.extendFields` event) with `addFields`, `addTabFields` and `removeField` | A form extension: `pact.HasAdminActionExtensions` returning a `pact.AdminActionExtension` whose `Form` is a `pact.AdminFormExtension`. Its fields document places fields `before:` or `after:` an owner field and declares new tabs under `tabs:` (`addFields`, `addTabFields`, with real placement instead of the end of the form), `Hide` replaces `removeField`, and `Load` and `Save` keep the values in the plugin's own tables inside the record's save transaction | [Admin controllers](../backend/admin-controllers.md#adding-fields-to-another-controllers-form), [pact](../../modules/pact/README.md) |
| The Reorder behaviour and `config_reorder.yaml` | A `config_reorder.yaml` next to `config_list.yaml` and the `reorder` toolbar button: a drag-and-drop tree, or a flat list for records without a parent column; `pact.ReorderAfterSave` runs after a reorder save | [Lists and filters](../backend/lists-and-filters.md#tree-reorder), [cabana](../../modules/cabana/README.md) |
| Form widgets and `registerFormWidgets` | A plugin field type: `pact.HasFormFieldTypes` returning `pact.FormFieldType` values, usable as `type:` in any controller's `fields.yaml`; a one-off element with a server action is a `type: widget` field | [Plugin field types](../backend/field-types.md), [Partials and widgets](../backend/partials-and-widgets.md#form-widgets) |
| HTML cleaning of rich editor content | Every `richeditor` value is sanitized on save by `seaglass.Sanitize`, an allowlist policy; plugin code calls `seaglass.SanitizeReport` to learn what was removed | [Rich text](../backend/forms.md#rich-text), [seaglass](../../modules/seaglass/README.md) |