Files
summercms/docs/backend/forms.md
Jakub Zych f50d9b8f10 feat(12.1-02): permissioneditor field in radio or checkbox mode
- type: permissioneditor with mode radio (1, -1) or checkbox (1); the
  controller serves the options per request through
  cabana.PermissionEditorProvider and reads and stores the values
- a save answers 422 for a non-object, an unknown code or a value outside the
  mode's set and 403 for a changed locked code; stored codes that are not
  offered are kept
- record responses carry the stored permissions as an object
- SPA: PermissionEditorField with sections by tab, locked rows and a read-only
  mode for the preview
- README, docs, OpenAPI document, TS types and dist updated
2026-10-05 10:44:50 +02:00

22 KiB

title, description, section, order
title description section order
Forms Describe admin forms in config_form.yaml and fields.yaml, with the supported field types, spans, tabs, dropdown options and create or update contexts. backend 20

Forms

The Form behaviour's config_form.yaml and the model's fields.yaml keep their WinterCMS shape. cabana compiles them strictly at boot: an unknown key, field type, span or size stops the start-up with an error naming the file and the field, so a WinterCMS option that SummerCMS does not implement is never ignored silently.

config_form.yaml

The controller's form configuration names the fields file with a WinterCMS path, the model class, and where the SPA goes after a save:

name: acme.blog::lang.posts.form
form: ~/plugins/acme/blog/models/post/fields.yaml
modelClass: Post
defaultRedirect: acme/blog/posts
create:
    redirect: acme/blog/posts/update/:id
    redirectClose: acme/blog/posts
update:
    redirect: acme/blog/posts
    redirectClose: acme/blog/posts

modelClass must equal the controller's pact.AdminController.ModelName. The ~/plugins/<vendor>/<plugin>/ prefix points into the plugin's own embedded tree.

An optional recordActions key lists the record actions the form offers, by the names the controller registers through pact.HasAdminRecordActions. It needs a preview block, because record actions are offered on the preview screen. The show response of a record then carries meta.actions: the declared actions the administrator may run and that apply to the record in its current state. See Record actions.

fields.yaml

fields:
    title:
        label: acme.blog::lang.posts.title_column
        type: text
        span: left
        required: true
    slug:
        label: acme.blog::lang.posts.slug
        type: text
        span: right
        context: update
        comment: acme.blog::lang.posts.slug_comment
    status:
        label: acme.blog::lang.posts.status
        type: dropdown
        span: left
        options:
            draft: acme.blog::lang.posts.draft
            published: acme.blog::lang.posts.published
    published:
        label: acme.blog::lang.posts.published
        type: switch
        span: right
    content:
        label: acme.blog::lang.posts.content
        type: textarea
        size: large
        tab: acme.blog::lang.posts.tab_content

The compiled schema keeps the fields in file order, with their labels as translation keys until a request asks for them in its locale:

fsys := os.DirFS("testdata/docs")
form, err := cabana.CompileForm("acme.blog", PostsController{}, fsys)
if err != nil {
	fmt.Println(err)
	return
}
for _, f := range form.Fields {
	fmt.Printf("%s %s span=%q tab=%q required=%v options=%d\n", f.Name, f.Type, f.Span, f.Tab, f.Required, len(f.Options))
}
// Output:
// title text span="left" tab="" required=true options=0
// slug text span="right" tab="" required=false options=0
// status dropdown span="left" tab="" required=false options=2
// published switch span="right" tab="" required=false options=0
// content textarea span="" tab="acme.blog::lang.posts.tab_content" required=false options=0

Field types

Type Renders
text, textarea, number Text inputs.
checkbox, switch Booleans.
dropdown A select. Options are a map in the YAML, or the name of a method the controller answers through pact.DropdownOptionsProvider.
relation A belongsTo or belongsToMany picker; see Relation manager.
relation-manager An embedded list of related records; see Relation manager.
widget A plugin custom element with a server action; see Partials and widgets.
partial A server-rendered template; see Partials and widgets.
fileupload Uploads for an attachOne or attachMany relation; see File uploads.
datepicker A date, date and time, or time of day; see Date pickers.
permissioneditor A list of permissions to allow, deny or leave inherited; see Permission editor.
password A masked input with a show and hide button. It is a form-only field: the value is sent with a save and never returned; see Form-only fields.

The WinterCMS widgets that are not in this list (the rich editor, the markdown editor, the code editor, the color picker, the media finder, the repeater, the tag list and the others) are not provided. A field with one of those types stops the start-up.

Field options

A field takes label, comment, type, required, default, tab, span (left, right, full, auto, row), size (tiny, small, large, huge, giant), context, attributes (scalar HTML attributes for the input), options and emptyOption, plus nameFrom and relation on relation fields and preset on text fields. WinterCMS keys outside this set, such as readOnly, disabled, trigger or dependsOn, are refused.

context: update shows a field only on the update form, and context: create only on the create form; a list of contexts is also accepted, and context: preview shows a field only on the preview screen. The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds.

preset makes a text field follow another text field of the same form while the administrator has not edited it, as a slug field follows a title. It is the source field's name, or a mapping with field and type:

slug:
    label: acme.blog::lang.posts.slug
    type: text
    preset:
        field: title
        type: slug

type: slug (the default, and what the short form preset: title means) lower-cases the text and turns every run of characters other than ASCII letters and digits into one hyphen; type: exact copies the text. The admin SPA applies it on the create form only, and the first manual edit of the field stops it. The server does not fill the field: a model that needs a slug even when none is sent sets it in its own BeforeValidate. The schema reports the key as preset (a cabana.FieldPreset). A preset on another field type, an unknown type, or a source that is not a text field of the same form stops the start-up, and the key is not accepted on settings forms or relation forms.

Date pickers

A type: datepicker field edits one model column. Its mode decides the column's Go type, and the start-up stops when they do not match:

mode Go type of the column JSON value
datetime (the default) time.Time or *time.Time (a timestamptz column) "2026-10-02T10:30:00Z"
date lagoon.Date or *lagoon.Date (a DATE column) "2026-10-02"
time lagoon.TimeOfDay or *lagoon.TimeOfDay (a TIME column) "14:30:00"

A plugin does not define its own date types; see Casts and validation for the two framework types. Use the pointer types for an optional value: an empty picker stores NULL, and required: true refuses it.

fields:
    published_on:
        label: acme.blog::lang.posts.published_on
        type: datepicker
        mode: date
        minDate: 2000-01-01
    starts_at:
        label: acme.blog::lang.posts.starts_at
        type: datepicker
        format: d.m.Y H:i
        firstDay: 1

The field takes the generic keys plus these WinterCMS keys:

Key Meaning
mode date, datetime or time.
format The display format as a PHP date() format. It is turned into the SPA's format at boot (the schema serves it as displayFormat); a letter with no equivalent, such as T, U or c, stops the start-up. It changes only how the value is shown, not the order of the editable segments, which follows the admin's locale.
minDate, maxDate The earliest and latest calendar date (YYYY-MM-DD). Not valid on mode: time.
yearRange Years either side of today (10) or a [from, to] list for the year selector.
firstDay The first day of the week, 0 (Sunday) to 6.
twelveHour Shows a 12-hour clock.
ignoreTimezone mode: datetime only: the value is shown and saved as entered, with no time zone conversion.

options, emptyOption, nameFrom and every other key stop the start-up.

minDate and maxDate are checked again on save: a value whose calendar date (the UTC date of a datetime, or its wall-clock date with ignoreTimezone) lies outside them is a 422 on the field. A datetime value is stored in UTC and the admin SPA shows and edits it in the browser's time zone; date and time values are never converted. The datetime picker's calendar popover also has a time field, so the clock can be set without typing in the input.

File uploads

A type: fileupload field edits one attachment relation of the form's model. The model declares its relations with an AttachRelations method (attach.HasRelations) and implements attach.Owner; see Attachments. The field name must be one of the declared relation names, otherwise the start-up stops.

fields:
    photos:
        label: acme.blog::lang.posts.photos
        type: fileupload
        mode: image
        fileTypes: jpg,png,webp
        maxFilesize: 5
        maxFiles: 10
        thumbOptions:
            mode: crop

The field takes the generic keys plus these WinterCMS keys:

Key Meaning
mode image or file (the default). Image mode accepts only jpg, jpeg, png, gif and webp and checks that the bytes decode as such an image.
fileTypes Allowed extensions, as a comma- or pipe-separated string or a list.
mimeTypes Allowed MIME types (image/png, image/*) or extensions.
maxFilesize The largest file in megabytes. The file plus 64 KiB of multipart framing may not exceed http.body_limits.upload_bytes.
maxFiles The most files an attachMany relation may hold. Refused on attachOne.
imageWidth, imageHeight Preview size, 1 to 4096 pixels (240 by 240 when not set).
thumbOptions A mapping with mode: auto, exact, crop (the default) or fit.
useCaption Lets the administrator edit each file's title and description.
prompt The upload button text, a translation key.

options, emptyOption, nameFrom and every other key stop the start-up.

Uploads are deferred until the form is saved, on the create form and the update form alike, as WinterCMS's file upload widget does. The admin SPA makes a random session key when it opens a form and sends it in the X-Session-Key header with every upload and with the save. The upload stores the file unattached and records a pending binding for that key and the signed-in administrator; the record's create or update save attaches every pending file of the form's fileupload fields inside its own transaction. If the save fails with a 422, the uploads stay pending for the next attempt; if the form is left without saving, the daily deferred:purge removes them. A session key is only ever seen by the administrator who used it.

Removing a file is deferred the same way: the file disappears from the form at once and is deleted by the save (a pending upload that is removed is deleted at once). On an attachOne relation, saving a new upload deletes the file it replaces. Captions (with useCaption) and the order of an attachMany field are saved at once, as in WinterCMS. After applying the session's work, the save checks maxFiles and, for a required: true fileupload field, that at least one file is attached; a failure is a 422 on the field and keeps the pending work. Files of a protected relation are shown through authenticated admin routes only; see Protected files in the admin.

The limits are enforced on the server: the upload route caps the request body at the smaller of http.body_limits.upload_bytes and maxFilesize plus 64 KiB (413 payload_too_large past it), and a file that is too large, of a type the field does not allow, or not a valid image in image mode is a 422 on the field.

Preview screen

A form may have a read-only screen in front of its update form, as WinterCMS's preview context: the list opens the record there, the administrator reads it, runs a record action or presses the edit button. A preview block in config_form.yaml turns it on:

name: acme.roster::lang.people.form
form: ~/plugins/acme/roster/models/person/fields.yaml
modelClass: Person
defaultRedirect: acme/roster/people
create:
    redirect: acme/roster/people/preview/:id
    redirectClose: acme/roster/people
update:
    redirect: acme/roster/people
    redirectClose: acme/roster/people/preview/:id
preview:
    headerPartial: status
recordActions: [activate, reinstate]
messages:
    preview: acme.roster::lang.people.preview
    edit: acme.roster::lang.people.edit
  • preview is a mapping. headerPartial: <name> names a controller partial rendered above the fields as a status hint (see Partials and widgets); write preview: {} for a screen without a hint. An empty preview: key, any other key inside it, or a recordActions list without a preview block stops the start-up.
  • The form schema reports the block as preview (a cabana.FormPreview), and the SPA then serves the record at <controller>/<id>/preview. A form without the block has no such screen: the route goes to the update form.
  • The screen shows every field whose context allows preview. A field without a context shows on every screen; context: preview shows a field only here. Values are rendered as text; widget and relation-manager fields are not shown.
  • A field with context: preview is never written. A save asks only for the create or the update context, so such a field in a request body is dropped like any field hidden on that form.
  • messages.preview is the screen's subtitle and messages.edit the label of its edit button; both default to framework texts (cabana.FormMessages).
  • recordUrl in config_list.yaml and the form's create.redirect, update.redirectClose and the other redirects may point at the screen as <vendor>/<plugin>/<controller>/preview/:id. On the update form of a record with a preview, the back arrow and Cancel return to the preview; after a delete the form goes to the list.
  • The footer holds the record actions the show response offers in meta.actions, then the edit button. After an action the record and the status hint are loaded again in place.

Form-only fields

Some fields of a form are not columns of the record: a password and its confirmation, or a "send an invitation" checkbox. A controller lists them through pact.FormVirtualFields:

// FormVirtualFields names the fields of fields.yaml that are not columns of
// the form. cabana never fills or returns them.
func (MembersController) FormVirtualFields() []string {
	return []string{"password", "password_confirmation", "notify"}
}
  • A listed field needs no model column. cabana never binds it, never fills it into the model and never puts it in a record response, on any route. A model column with the same name (a stored password hash) is not touched by the form.
  • The values an administrator submits reach the controller's Form hooks through cabana.VirtualFieldsFromContext(ctx); see Admin controllers. Only fields that were sent and whose context allows the operation are there. A value must be a scalar: an object or a list is a 422 on the field.
  • Every listed name must be a field of the form's fields.yaml with the type password, text, textarea, number, checkbox, switch or dropdown. Anything else stops the start-up.
  • type: password is always a form-only field: a password field the controller does not list stops the start-up. The admin SPA shows it empty on every load, leaves an empty password out of an update (empty means unchanged), and clears it after a save. A confirmation is a second password field, compared by the confirmed rule on the server.
  • Form-only fields are not available on settings forms or relation forms.

Permission editor

type: permissioneditor edits a set of permissions on a record: which codes are allowed, and in radio mode which are denied. The field needs a mode:

permissions:
    label: acme.roster::lang.people.permissions
    type: permissioneditor
    mode: radio
    tab: acme.roster::lang.people.tab_permissions
    context: update
Mode Control per permission Values
radio Allow, Inherit, Deny 1 allows, -1 denies; an inherited permission has no value.
checkbox One checkbox 1 allows; an unchecked permission has no value.

The permissions themselves are not in the YAML. The controller implements cabana.PermissionEditorProvider and answers per request, so the list may depend on the signed-in administrator:

// AdminPermissionOptions lists the permissions the `type: permissioneditor`
// field offers, in display order. The principal on ctx decides what is locked.
func (MembersController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) {
	principal, _ := bouncer.User(ctx)
	mayExport := cabana.Allows(principal, []string{"acme.roster.manage"})
	return []cabana.PermissionOption{
		{Code: "posts.edit", Label: "acme.roster::lang.permissions.posts_edit", Tab: "acme.roster::lang.permissions.tab_content"},
		{Code: "posts.publish", Label: "acme.roster::lang.permissions.posts_publish", Tab: "acme.roster::lang.permissions.tab_content"},
		{Code: "reports.export", Label: "acme.roster::lang.permissions.reports_export", Tab: "acme.roster::lang.permissions.tab_reports", Locked: !mayExport},
	}, nil
}
// AdminPermissionValues reads the permissions stored on the record.
func (MembersController) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) {
	values := map[string]int{}
	if raw := record.(*Member).Permissions; raw != "" {
		if err := json.Unmarshal([]byte(raw), &values); err != nil {
			return nil, err
		}
	}
	return values, nil
}
// AdminSetPermissionValues stores the checked set on the model. The save
// writes the row afterwards, in the same transaction.
func (MembersController) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error {
	raw, err := json.Marshal(values)
	if err != nil {
		return err
	}
	record.(*Member).Permissions = string(raw)
	return nil
}
  • An option (cabana.PermissionOption) has a Code, a Label, and optionally a Tab and a Comment; the three texts are translation keys or text. Options with the same Tab are shown as one section of the list, in the order the controller returns them, and options without a Tab form a last section. The form schema carries them on the field as permissionOptions.
  • The value travels as a JSON object of code to integer, in a record response and in a save body: {"posts.edit": 1, "posts.publish": -1}. A record response always carries the object, empty when nothing is stored.
  • A save checks the submitted object inside its transaction, after the Form before-hooks and before the row is written. A value that is not an object of integers, a code the controller does not offer, or a value outside the mode's set is a 422 on the field; a 0 means "no value". The checked set is then handed to AdminSetPermissionValues, which decides how it is stored.
  • A stored code that is not among the options is kept as it is: an offered code that is left out loses its value, a code that is not offered is never touched and can never be submitted.
  • An option with Locked set is shown with a disabled control. The server enforces it: a save in which a locked code's value differs from the stored one is answered 403 forbidden with a message on the field, and nothing is written.
  • A save that does not send the field leaves the stored permissions alone, and the field's context applies as for any field.
  • The keys options, default, nameFrom, emptyOption, relation and preset are refused on the type. A field without mode, or on a controller that does not implement the provider, stops the start-up. The type is not available on settings forms or relation forms.

What a save may write

The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's Fillable list, drops unknown keys, case variants and nested objects, and fills the model with lagoon.Fill (see Models). The model's validation rules (a Rules method returning lagoon.Validate rule strings) and the form's required flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field. A controller implementing pact.FormRules supplies its own rule set per operation, which replaces the model's rules for admin saves.

Form-only fields are never written by cabana. Their submitted values are validated, when a rule names them, and handed to the controller's hooks; what is stored from them is the hook's decision.