feat(16.2-01): compile, decode and load plugin field types in cabana
- compileFieldTypes builds a per-boot field type registry (alias, core and cross-plugin collisions, element prefix, assets, option keys) passed down the compile functions; settings and relation forms refuse plugin aliases - fields.yaml accepts a plugin alias with the general keys plus the type's options (typeOptions, element, translatable in the schema); CheckOptions runs at boot; CompileFormWithTypes for plugin tests - save decodes plugin values inside the transaction (FormFieldError is a 422, other errors opaque 500) and answers meta.notices (FieldNotice) only when a type reported something; show and save serve values through Load - GET /field-types lists the types with versioned asset URLs; OpenAPI, schema.d.ts, README and docs/backend/field-types.md updated
This commit is contained in:
106
docs/backend/field-types.md
Normal file
106
docs/backend/field-types.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: Plugin field types
|
||||
description: Register your own admin form field types from a plugin, with a custom element in the admin and Go code that decodes, loads and formats the value.
|
||||
section: backend
|
||||
order: 25
|
||||
---
|
||||
# Plugin field types
|
||||
|
||||
The built-in field types of a [form](forms.md) cover text, choices, relations, files and dates. When a plugin needs an editor of its own (a block editor, a map picker, a structured list), it registers a field type: a short alias any controller's `fields.yaml` can use as `type:`, a custom element the admin renders for the field, and Go functions that decide what is stored. The plugin ships no framework change and no admin build of the framework: [cabana](../../modules/cabana/README.md) owns the routes, the permissions, the record scope and the transaction, and the type only carries its own logic.
|
||||
|
||||
## Registering a type
|
||||
|
||||
A plugin implements `pact.HasFormFieldTypes` and returns one `pact.FormFieldType` per alias:
|
||||
|
||||
```go src=modules/cabana/example_field_type_test.go#NotesPlugin.FormFieldTypes
|
||||
// FormFieldTypes registers the notes field type: a custom element with one
|
||||
// option, rows, whose Decode trims the submitted text.
|
||||
func (NotesPlugin) FormFieldTypes() []pact.FormFieldType {
|
||||
return []pact.FormFieldType{{
|
||||
Name: "notes",
|
||||
Element: "acme-blog-notes",
|
||||
Scripts: []string{"assets/js/notes.js"},
|
||||
Options: []string{"rows"},
|
||||
CheckOptions: func(options map[string]any) error {
|
||||
if rows, ok := options["rows"].(int); ok && rows > 40 {
|
||||
return errors.New("rows must be at most 40")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Decode: func(ctx context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
||||
text, ok := in.Value.(string)
|
||||
if !ok {
|
||||
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"acme.blog::lang.notes.invalid"}}
|
||||
}
|
||||
return pact.FormFieldDecoded{Value: strings.TrimSpace(text)}, nil
|
||||
},
|
||||
}}
|
||||
}
|
||||
```
|
||||
|
||||
`pact.HasFormFieldTypes` is implemented by the plugin itself, like `pact.HasAdminControllers`, not by a controller. A form then uses the alias:
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
notes:
|
||||
label: acme.blog::lang.post.notes
|
||||
type: notes
|
||||
rows: 6
|
||||
```
|
||||
|
||||
The admin loads every registered type's scripts once after sign-in, and keeps its stylesheets enabled on every page, so the element works in any controller's form. `GET <prefix>/api/v1/field-types` lists the types with their script and stylesheet URLs; each URL carries a content hash, and the files are served from the plugin's embedded `assets/` tree like [controller assets](partials-and-widgets.md#plugin-javascript-and-css).
|
||||
|
||||
## Aliases and boot checks
|
||||
|
||||
Every rule below stops the start-up with a message naming the plugin and the alias:
|
||||
|
||||
- The alias is lowercase letters and digits starting with a letter (`notes`, `geo2`).
|
||||
- It is not a built-in field type (`text`, `markdown`, `mlmarkdown`, `widget`, `partial` and the others in the [field type table](forms.md#field-types)).
|
||||
- No other plugin registers the same alias; the message names both plugins.
|
||||
- `Element` is a valid custom-element name that starts with the plugin's prefix: `acme.blog` gives `acme-blog-`.
|
||||
- `Decode` is set.
|
||||
- `Scripts` (`.js`, `.mjs`) and `Styles` (`.css`) are clean paths under `assets/` that exist in the plugin's `AdminFS`; a plugin that declares files implements `pact.AdminAssets`.
|
||||
- Each `Options` key is letters and digits starting with a letter, is declared once and is not a general field key such as `label`, `options` or `mode`.
|
||||
|
||||
Plugin field types work in controller forms. A settings form and a relation manager's forms refuse a plugin alias with "plugin field type `<name>` is supported in controller forms only".
|
||||
|
||||
## Options
|
||||
|
||||
A plugin-typed field accepts the general keys `label`, `comment`, `span`, `type`, `required`, `tab`, `context`, `attributes`, `size` and `default`, plus the type's own `Options`. Any other key stops the start-up with "unknown field". The option values are decoded into JSON values (string, boolean, number, list or map) and sent to the admin as the field's `typeOptions`; the element receives them as its `options` attribute.
|
||||
|
||||
`CheckOptions`, when set, checks a field's options at start-up, as the `rows` check in the example above does. An error stops it and names the field and the type.
|
||||
|
||||
The form schema carries a plugin field with `element`, `translatable` (only when true) and `typeOptions` (only when the field sets options). `cabana.CompileFormWithTypes` compiles a form with one plugin's types outside a running application, which is how a plugin tests its `fields.yaml`.
|
||||
|
||||
## Decode, notices and refusals
|
||||
|
||||
A save hands each submitted plugin field value to the type's `Decode`, inside the save transaction, after cabana loaded the record through the controller's `pact.FormExtendQuery` scope and before the record is filled. `pact.FormFieldValue` carries the transaction (`DB`), the loaded record on update or the new model on create (`Record`), the field name, its options and the submitted `Value` (any JSON value). The value `Decode` returns in `pact.FormFieldDecoded` is the value written to the column; the field binds to a model column of the same name like any value field, and `required` works on it.
|
||||
|
||||
`Decode` answers in one of three ways:
|
||||
|
||||
- It accepts the value: `pact.FormFieldDecoded{Value: v}`. The save answers exactly as a save without plugin field types.
|
||||
- It accepts the value but changes it, and tells the administrator: `Notices` carries one `pact.FormFieldNotice` per message, for example what a sanitizer removed. The save goes through, and the create or update response carries the messages in `meta.notices`:
|
||||
|
||||
```json
|
||||
{"data": {"id": 7, "notes": "..."}, "meta": {"labels": {}, "notices": [{"field": "notes", "message": "2 elements were removed: style, iframe"}]}}
|
||||
```
|
||||
|
||||
`Message` is a phrase key or text, localized in the request locale with `Params`; a `Count` above zero selects the plural form of the phrase and sets `:count`. A save whose `Decode` calls return no notice has no `notices` key at all.
|
||||
- It refuses the value: a `*pact.FormFieldError` is a 422 `validation_failed` on the field with the error's messages (phrase keys or text, localized), and nothing is written. Use it for a value the type cannot accept at all, such as a number where it expects text.
|
||||
|
||||
Any other error from `Decode` is logged and answered as the opaque 500 of a failed lifecycle hook; the plugin's error text never reaches the client.
|
||||
|
||||
## Load
|
||||
|
||||
`Load`, when set, turns the stored value into the value the element receives. It runs on the record response of `GET .../{id}` and of every create and update, with the same `pact.FormFieldValue` (the read transaction, the record, the stored `Value`). The save response therefore always carries the stored value, and the admin reloads it into the form after a save, so the administrator sees what `Decode` kept. Without `Load` the stored value is sent as it is. A `Load` error is an opaque 500.
|
||||
|
||||
## The element
|
||||
|
||||
The admin creates the type's element for the field and talks to it through one property, attributes and events, nothing else:
|
||||
|
||||
- The `value` property holds the field's value: any JSON value for a type that is not translatable.
|
||||
- The attributes `field-name`, `record-id` (empty on the create form), `locale` (empty for a type that is not translatable), `ui-locale` (the admin's interface language), `options` (the JSON of `typeOptions`), `disabled` (present when the field is read-only) and `invalid` (present while the field has an error).
|
||||
- The element dispatches a bubbling `summer-change` event with `detail.value` when the administrator edits the value, and only then; the admin takes `detail.value` as the field's new value.
|
||||
- The admin sets `value` only when the form value differs from the element's current `value`, so setting the value the element just emitted never resets it. An element must still accept a `value` it did not emit, such as the stored value after a save.
|
||||
|
||||
The element never receives a token, a cookie, a function or a Vue instance, and it makes no requests of its own. It styles itself only through the admin's public `--c-*` CSS variables, with every selector scoped under its own tag, because its stylesheet is enabled on every admin page.
|
||||
@@ -102,7 +102,7 @@ for _, f := range form.Fields {
|
||||
| `mltextarea` | A multi-line text field with one editor per locale; it takes `size` as `textarea` does. See [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
|
||||
| `mlmarkdown` | A markdown field with one editor per locale; see [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
|
||||
|
||||
The WinterCMS widgets that are not in this list (the rich editor, the code editor, the color picker, the media finder, the repeater, the tag list and the others) are not provided. A field with one of those types stops the start-up.
|
||||
The WinterCMS widgets that are not in this list (the rich editor, the code editor, the color picker, the media finder, the repeater, the tag list and the others) are not provided. A field with one of those types stops the start-up, unless a plugin registers that alias as its own field type: plugins add field types with a custom element and Go code that decides what is stored; see [Plugin field types](field-types.md).
|
||||
|
||||
### Field options
|
||||
|
||||
|
||||
Reference in New Issue
Block a user