--- 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. ## Notices A notice is how a type that changes an accepted value tells the administrator what it changed. cabana collects the notices of every `Decode` call of a save, and only when the save commits does it localize them and put them in the response's `meta.notices`, one entry per message with the `field`, the content `locale` of a translatable type's value (absent for other types) and the localized `message`. A refused save (any 422 or other error) carries no notices. At most 10 notices are kept per field and locale, and each message is cut to 500 characters, so a value with thousands of removed parts cannot inflate the response. A notice may quote what the administrator submitted, such as a removed tag name; the admin shows it as text, never as markup. ## Translatable types A type with `Translatable: true` holds one string per content locale, like `mlmarkdown`. The admin sends and receives a map of locale to string, and cabana stores it through its own multilingual path: the default locale's value goes to the host column, every other locale through the published `cabana.TranslationWriter`, inside the same permissioned save transaction. The plugin implements no locale storage. `Decode` runs once per submitted locale, with `Locale` set to that content locale and `Default` true for the host column's locale; its `Value` must be a string. Each locale's notices carry that locale. `Load` runs once per locale of the record response. `Translated` reads another attribute of the same record in the same locale, exactly and without fallback, which a type that keeps two columns in step (a source and a rendered copy, for example) uses to compare them. Without a translation writer a locale map is a 422, as for the built-in multilingual types. ## Untouched values On update, cabana compares each submitted value with the value the form was served for that field and locale: the stored host value or translation (an empty string for a missing translation), passed through `Load`. When their JSON encodings are equal, the value is neither decoded nor written, neither to the host column nor as a translation, and it yields no notice. So a record whose stored value a type would rewrite (an imported document a sanitizer would change, for instance) is left byte for byte as it is until the administrator edits that field. On create nothing is skipped. An element should not re-serialize a value the administrator did not edit either, so the comparison holds. ## Companion columns in Form hooks A type sometimes implies a write to another column: a rendered copy next to its source, a word count, a search text. The type's `Decode` returns only the field's own value; a controller's Form hooks write the rest. `cabana.FieldWritesFromContext` returns, from the context of `pact.FormBeforeCreate`, `pact.FormAfterCreate`, `pact.FormBeforeUpdate` and `pact.FormAfterUpdate`, the values the save actually writes for plugin fields, keyed by field name, one `cabana.FieldWrite` per written locale with `Default` set for the host column's value. An untouched value is not listed, so a hook updates a companion column only for the locales that changed. A test of such a hook builds its context with `cabana.WithFieldWrites`. ## List columns A column in `columns.yaml` may name a plugin alias as its `type`. The list then shows the type's `ListValue` for the stored value (the host column's value for a translatable type), a plain string the admin renders as text. A type without `ListValue` shows the stored value as it is. ## Uploads A type that stores files (images in a document, say) sets `Upload`. The element never uploads anything itself: it asks the admin, which posts the file with the administrator's session to `POST /api/v1/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` as one multipart part named `file`. cabana checks the backend session, the `X-Requested-With` header, the controller's permissions, that the field has a plugin type with `Upload` and that its `context` allows the operation, then caps the body at `http.body_limits.upload_bytes`. `{id}` 0 is the create form; any other id loads the record through the controller's `pact.FormExtendQuery` scope inside a transaction, and a record outside it is a 404. `Upload` receives a `pact.FormFieldUpload` with the transaction, the record (nil on the create form), the field, its options, the file name and the capped body, and returns the URL the element shows. The route answers 201 `{"data": {"url": "..."}}`; a `pact.FormFieldError` is a 422 on the field, a body over the cap a 413, and any other error the opaque 500. Checking the file (its size, its real type) is the type's job. ## 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 element contract: - The `value` property holds the field's value: a string (the active locale's text) for a translatable type, any JSON value otherwise. - The attributes are `field-name`; `record-id`, empty on the create form; `locale`, the content locale of the value for a translatable type and empty otherwise; `ui-locale`, the admin's interface language; `options`, the JSON of the field's `typeOptions`; `disabled`, present when the field is read-only (on a preview screen, for example); and `invalid`, present while the field has an error. - `summer-change` is a bubbling `CustomEvent` the element dispatches when the administrator edits the value, and only then. The admin takes `detail.value` as the field's new value; for a translatable type it replaces only the active locale's string in the locale map. - `summer-upload` is a bubbling `CustomEvent` the element dispatches to upload a file, with `detail` `{id, file}`: an id of the element's choosing and the `File`. The admin posts the file with the administrator's session to the field's [upload route](#uploads) and answers by dispatching `summer-upload-result` on the element, with `detail` `{id, url}` on success or `{id, error}` with a message to show (the server's message for a refused file, a generic one for a file that is too large or a network failure). - The admin sets `value` only when the form value differs from the element's current `value` (compared by JSON for objects), so setting the value the element just emitted never resets it. The element must still accept a `value` it did not emit: the stored value after a save, which may differ from what it emitted when `Decode` changed it, and the other locale's text when the administrator switches locales (the admin sets `locale` first, then `value`). - An element should not re-serialize a value the administrator did not edit, so an untouched value stays equal to the one it was served and the save skips it. The element never receives a token, a cookie, a function or a Vue instance, and it makes no requests of its own: the admin owns HTTP and the session. It styles itself only through the admin's public `--c-*` CSS variables (`--c-bg`, `--c-surface`, `--c-text`, `--c-primary` and the others the cabana README lists, which switch with dark mode), with every selector scoped under its own tag, because its stylesheet is enabled on every admin page. A type whose script fails to load, or whose tag is not defined within five seconds, shows the admin's failure box in place of the field. A translatable type renders inside the admin's locale switcher, the same one `mlmarkdown` uses: one element edits the active locale, and switching locales changes its `locale` attribute and its `value`. ### How the admin shows notices After a save whose response carries `meta.notices`, the admin shows each message under its field as a warning status line, prefixed with the upper-case locale for a translatable type (`PL: 2 elements were removed: style, iframe`), and appends the messages to the saved toast, so "Save and close", which leaves the form, still tells the administrator. The lines go away on the next save attempt. The admin then reloads the stored values from the response into the form, and the element receives the stored value.