The field upload route ran only protect, so the create form of a
controller without the create toolbar button could still write files to
the public bucket. It now refuses with 403 where the save route would:
create for {id} 0, update otherwise (operationDeclared).
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,partialand the others in the field type table). - No other plugin registers the same alias; the message names both plugins.
Elementis a valid custom-element name that starts with the plugin's prefix:acme.bloggivesacme-blog-.Decodeis set.Scripts(.js,.mjs) andStyles(.css) are clean paths underassets/that exist in the plugin'sAdminFS; a plugin that declares files implementspact.AdminAssets.- Each
Optionskey is letters and digits starting with a letter, is declared once and is not a general field key such aslabel,optionsormode.
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:
Noticescarries onepact.FormFieldNoticeper message, for example what a sanitizer removed. The save goes through, and the create or update response carries the messages inmeta.notices:{"data": {"id": 7, "notes": "..."}, "meta": {"labels": {}, "notices": [{"field": "notes", "message": "2 elements were removed: style, iframe"}]}}Messageis a phrase key or text, localized in the request locale withParams; aCountabove zero selects the plural form of the phrase and sets:count. A save whoseDecodecalls return no notice has nonoticeskey at all. -
It refuses the value: a
*pact.FormFieldErroris a 422validation_failedon 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 controller declares the save the upload belongs to (create for {id} 0, which needs the list's create toolbar button, update otherwise; a 403 otherwise, as the save routes answer), 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
valueproperty 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'stypeOptions;disabled, present when the field is read-only (on a preview screen, for example); andinvalid, present while the field has an error. summer-changeis a bubblingCustomEventthe element dispatches when the administrator edits the value, and only then. The admin takesdetail.valueas the field's new value; for a translatable type it replaces only the active locale's string in the locale map.summer-uploadis a bubblingCustomEventthe element dispatches to upload a file, withdetail{id, file}: an id of the element's choosing and theFile. The admin posts the file with the administrator's session to the field's upload route and answers by dispatchingsummer-upload-resulton the element, withdetail{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
valueonly when the form value differs from the element's currentvalue(compared by JSON for objects), so setting the value the element just emitted never resets it. The element must still accept avalueit did not emit: the stored value after a save, which may differ from what it emitted whenDecodechanged it, and the other locale's text when the administrator switches locales (the admin setslocalefirst, thenvalue). - 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.