--- 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 /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 `` 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.