Files
summercms/docs/backend/field-types.md
Jakub Zych 717d46ec3d 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
2026-10-10 11:06:12 +02:00

8.0 KiB

title, description, section, order
title description section order
Plugin field types 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. backend 25

Plugin field types

The built-in field types of a form 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 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:

// 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:

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.

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).
  • 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:

    {"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.