Files
summercms/docs/backend/field-types.md
Jakub Zych 4adb48a76e feat(16.2-01): translatable plugin fields, element uploads and save notices in the admin
- MLPluginField wraps PluginField in the MLField locale switcher; the
  element gets the active content locale and that locale's string
- isMLField treats a translatable plugin field as a locale map in the form
  state, FormView adopt and the relation child modal
- summer-upload posts the file with the admin session to the field upload
  route and answers summer-upload-result {id, url} or {id, error}
- noticeLines shows meta.notices under each field as a warning status line
  (text only) and in the saved toast; the stored value is reloaded
- docs/backend/field-types.md documents the full element contract;
  modules/boardwalk/dist rebuilt
2026-10-10 11:25:26 +02:00

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