Files
summercms/docs/backend/field-types.md
Jakub Zych 8fa92db96d feat(16.2-01): translatable plugin field types, untouched skip, writes, list values and uploads
- translatable types lift, store and hydrate through the ML path
  (isMLField); Decode and Load run per locale and notices carry the locale
- on update a value equal to the served value (Load of the stored host
  value or exact translation) is neither decoded nor written
- FieldWrite, FieldWritesFromContext and WithFieldWrites expose what a save
  writes to the Form hooks
- list columns may use a plugin alias and show the type's ListValue
- POST .../{id}/fields/{field}/upload hands the file to the type's Upload
  (guard, X-Requested-With, permissions, field context, record scope, body
  cap); OpenAPI, route inventories, README and docs updated
2026-10-10 11:19:24 +02:00

13 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.

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