A scale-2 money field over a four-decimal column (the documented fee example) was served "12.5000" and answered money.too_precise when the untouched value was saved back. Trailing zeros carry no precision, so the server check and the SPA parser count only significant fraction digits; the value still reaches the column unchanged, never rounded or truncated. - cabana validateMoneyValue and admin parseMoneyInput trim trailing zeros before the scale comparison - forms docs and cabana README name the rule; embedded dist rebuilt - MoneyField spec asserts 12.5000 at scale 2 is accepted, 12.5010 refused
42 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. |
markdown |
A source editor for a host text column, with a sanitized preview; see Markdown and multilingual fields. |
mltext |
A text field with one editor per locale; see Markdown and multilingual fields. |
mltextarea |
A multi-line text field with one editor per locale; it takes size as textarea does. See Markdown and multilingual fields. |
mlmarkdown |
A markdown field with one editor per locale; see Markdown and multilingual fields. |
richeditor |
A WYSIWYG HTML editor for a host text column, sanitized on every save; see Rich text. |
repeater |
A list of rows, each with its own small form, stored as JSON in one column; see Repeater. |
money |
An exact decimal amount with a currency suffix, never a float and never rounded; see Money. |
The WinterCMS widgets that are not in this list (the code editor, the color picker, the media finder, the tag list and the others) are not provided. A field with one of those types stops the start-up, unless a plugin registers that alias as its own field type: plugins add field types with a custom element and Go code that decides what is stored; see Plugin field types.
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, preset on text and mltext fields, dependsOn on dropdowns (see Dependent dropdowns) and currency, currencyFrom, scale and max on money fields (see Money). WinterCMS keys outside this set, such as readOnly, disabled or trigger, 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 or mltext field follow another text or mltext 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).
Multilingual pairs follow the locale the administrator works in. An mltext target follows an mltext source per locale: typing in one locale of the source rewrites the same locale of the target, and a hand edit of one locale of the target stops only that locale. An mltext source drives a text target from the active locale. A text source fills only the active locale of an mltext target, so locales nobody looked at stay empty.
A preset on another field type (mltextarea and mlmarkdown included), an unknown type, or a source that is not a text or mltext field of the same form stops the start-up, and the key is not accepted on settings forms or relation forms. An mltextarea field is neither a preset target nor a preset source, like textarea.
Dependent dropdowns
A type: dropdown field with dependsOn takes its options from the value of a sibling field, WinterCMS's dependent dropdown. A menu entry's reference dropdown, for example, lists the posts when its type dropdown says "post" and the categories when it says "category":
fields:
type:
label: acme.menu::lang.entry.type
type: dropdown
options: typeOptions
reference:
label: acme.menu::lang.entry.reference
type: dropdown
dependsOn: type
emptyOption: backend::lang.form.none
dependsOn names another field of the same form (or of the same repeater row) whose value is a scalar. The dropdown declares no options: the controller implements pact.DependentOptionsProvider, and its DependentOptions receives the dropdown's name and the sibling's value (a string, or nil when it is empty) and answers a pact.DependentOptionSet. Applies false means that value takes no choice at all: the admin disables the dropdown and a save stores null. Otherwise Options are the choices, and an option's Depth indents a choice taken from a tree. A dependsOn on another type, on a dropdown with options, naming a missing field or the field itself, or on a controller without the provider stops the start-up; settings and relation forms refuse the key.
The admin SPA fetches the options from GET <prefix>/api/v1/{vendor}/{plugin}/{controller}/fields/{field}/dependent-options?values[<sibling>]=<value> whenever the sibling changes; for a dropdown inside a repeater row, {field} is <repeater>.<field> and the provider receives that path as the field name. The route is guarded like the form schema (a backend session with the controller's permissions) and answers {applies, options: [{value, label, depth}]} with labels in the request locale.
In the admin, the dropdown stays disabled with a hint until the sibling has a value, while its options load, and when the sibling's value takes no choice. A failed load offers a retry. Answers are cached per sibling value while the form is open. When the administrator changes the sibling and the current choice is not among the new options, the dropdown clears it, the form counts as changed and a screen reader hears that the field was cleared. A stored value that is missing from the options on load is never replaced by another choice: the dropdown shows its placeholder and the save answers with the validation message below.
The server checks the value again on every save, so a client cannot store a reference the options do not offer. When the dropdown or its sibling is written, the provider is asked with the sibling's submitted value (or the stored one when only the dropdown is sent), and in every row of a written repeater with the row's own sibling value. A value that does not apply is stored as null; a value that is not among the options is a 422 with form.dependent_invalid ("Choose one of the listed options.") on the field, or under <repeater>.<index>.<field> in a row. Null passes this check; required still applies.
Menu item types are the common source of such options. A menu or sitemap controller answers with signpost.ReferenceOptions, which turns the chosen type's reference tree into options with their depth and answers Applies false for a type without references:
// DependentOptions serves the reference dropdown: the references of the
// menu item type chosen in the type field, with their depth.
func (c EntriesController) DependentOptions(ctx context.Context, field string, values map[string]any) (pact.DependentOptionSet, error) {
typ, _ := values["type"].(string)
reg, _ := c.app.Lookup[*signpost.Registry]()
return signpost.ReferenceOptions(ctx, reg, typ)
}
See Menu item types for registering the types.
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
previewis a mapping.headerPartial: <name>names a controller partial rendered above the fields as a status hint (see Partials and widgets); writepreview: {}for a screen without a hint. An emptypreview:key, any other key inside it, or arecordActionslist without apreviewblock stops the start-up.- The form schema reports the block as
preview(acabana.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
contextallowspreview. A field without acontextshows on every screen;context: previewshows a field only here. Values are rendered as text;widgetandrelation-managerfields are not shown. - A field with
context: previewis never written. A save asks only for thecreateor theupdatecontext, so such a field in a request body is dropped like any field hidden on that form. messages.previewis the screen's subtitle andmessages.editthe label of its edit button; both default to framework texts (cabana.FormMessages).recordUrlinconfig_list.yamland the form'screate.redirect,update.redirectCloseand 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 whosecontextallows 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.yamlwith the typepassword,text,textarea,number,checkbox,switchordropdown. Anything else stops the start-up. type: passwordis 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 secondpasswordfield, compared by theconfirmedrule on the server.- Form-only fields are not available on settings forms or relation forms.
- On a settings form
type: passwordis not a form-only field but a write-only secret backed by alagoon.Encryptedcolumn; see Write-only secrets.
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 aCode, aLabel, and optionally aTaband aComment; the three texts are translation keys or text. Options with the sameTabare shown as one section of the list, in the order the controller returns them, and options without aTabform a last section. The form schema carries them on the field aspermissionOptions. - 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
0means "no value". The checked set is then handed toAdminSetPermissionValues, 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
Lockedset 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 403forbiddenwith a message on the field, and nothing is written. - A locked code must be stored with a value its mode can send: 1, -1 or no value in radio mode, 1 or no value in checkbox mode. The admin sends only those values, so a locked code stored with anything else never matches, and every save of that record from the admin form is refused for that administrator. Normalize stored values before locking a code.
- A save that does not send the field leaves the stored permissions alone, and the field's
contextapplies as for any field. - The keys
options,default,nameFrom,emptyOption,relationandpresetare refused on the type. A field withoutmode, 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.
Markdown and multilingual fields
type: markdown edits markdown source on a host text column. The admin SPA shows a source editor with a Preview toggle. When Preview opens, and again shortly after the source changes while it is open, the SPA posts the field's source (the active locale's text for mlmarkdown) to the preview route below and renders only the server's answer; when the output is refused it shows the server's message as text. The server renders through cabana.RenderMarkdown, which uses the pinned goldmark engine without unsafe HTML. Output that still contains a script or iframe tag, an event handler, or a javascript, vbscript or data URL is refused, so translated raw HTML cannot become executable preview content. POST <prefix>/api/v1/markdown/preview renders a {markdown} source through cabana.RenderMarkdown for any signed-in administrator and answers {html}, or a 422 validation_failed on markdown when the output is refused.
type: mltext, type: mltextarea and type: mlmarkdown reuse the ordinary text, textarea and markdown editors with a locale selector. mltextarea takes size like textarea. mlmarkdown composes the markdown control rather than a second parser. Each ML field shows its own selector; changing one selector changes every ML control on the form. Selector options come from cabana.FormMeta.EnabledLocales on the form schema (filled from cabana.TranslationWriter.EnabledLocales after Lookup; FormSchema.Localize stays cache-only). The options list the default locale first, then the other enabled codes in the writer's order, so every ML control starts on the default locale, whose values live in the host columns. Create seeds {[code]: ""} for every enabled code. A GET or save of a host scalar is merged onto that seed so sibling locales are not dropped. An mltext field can carry a preset; it follows per locale as described under Field options.
The save body sends every locale as a JSON object of code to string. Extra YAML keys are not accepted: the types reuse label, comment, span, size, required, tab and context. An unknown type such as mlunknown stops the start-up.
fields:
title:
label: acme.blog::lang.posts.title
type: mltext
span: full
summary:
label: acme.blog::lang.posts.summary
type: mltextarea
size: small
excerpt:
label: acme.blog::lang.posts.excerpt
type: markdown
body:
label: acme.blog::lang.posts.body
type: mlmarkdown
size: huge
tab: acme.blog::lang.posts.tab_content
A save of { "title": { "en": "Hello", "pl": "Witaj" } } lifts the nested map before cabana.ProjectWritableFields drops nested values. The default locale fills the host column; other locales are written through a published cabana.TranslationWriter after the host row has a primary key, still inside the controller's permissioned save transaction. The default locale is not duplicated into the translation store. Relation-child create and update on cabana.RelationService use the same lift and apply path as controller save; child show hydrates through hydrateMLRecord like a host GET. Show and save then run hydrateMLRecord: each declared ML field in the record payload is a map of every enabled locale (default from the host column, others from TranslationWriter.TranslatedExact; a missing non-default code is an empty string, never D-11 fallback). A non-string value, an undeclared locale, a missing default locale, or a nested map on a field that is not declared multilingual is a 422 on that field. Generic nested objects on other field types are still dropped. Cabana never imports a translate plugin and never exposes a public translate-write or translate-read route: if no writer is published, a nested ML map is a 422.
Repeater
type: repeater edits a list of rows stored as JSON in one column, WinterCMS's repeater widget. Each row has the fields of the repeater's form:
fields:
content:
label: acme.faq::lang.faq.content
type: repeater
prompt: acme.faq::lang.faq.add_question
titleFrom: question
minItems: 1
maxItems: 50
form:
fields:
question:
label: acme.faq::lang.faq.question
type: text
required: true
answer:
label: acme.faq::lang.faq.answer
type: richeditor
form.fieldstakes the field typestext,textarea,number,checkbox,switch,dropdown(with static options, a method, ordependsOnnaming another field of the same row) andricheditor. Any other type, a repeater inside a repeater, andtab,contextorpreseton a nested field stop the start-up. WinterCMS'sgroupsandstyleare refused too.promptis the add button's text,titleFromnames the nested field whose value titles a collapsed row, andminItemsandmaxItemsbound the number of rows (non-negative,minItemsat mostmaxItems). The field always spans the full width; it also takeslabel,comment,required,tabandcontext.- The model column holds the JSON list, usually a
lagoon.Jsonableof[]map[string]anyor of a typed row slice. A record response carries the value as a list of row objects, an empty list when nothing is stored.
A save body sends the list of rows. Null is stored as an empty list; anything that is not a list of objects is a 422 on the field. Each row keeps only the nested field names, so extra keys are dropped, and its values are coerced per nested type: a number to a JSON number, a checkbox or switch to a boolean, everything else to a string or null. A required nested value missing from a row is a 422 under <field>.<index>.<nested>, such as content.3.answer. Too many or too few rows is a 422 on the field (repeater.max_items, repeater.min_items), and a required repeater needs at least one row. Every nested richeditor value is sanitized like a top-level one, with its notices under the row path, and every nested dependent dropdown is rechecked against its row's sibling.
In the admin, every row has a drag handle, its number, a title (the titleFrom value, or "Item N"), buttons to move it up or down, collapse it and remove it, and the nested fields below. The handle also moves the row with the arrow keys. Removing a row that holds a value asks first; an empty row goes at once. The add button appends a row with the nested defaults, and a counter shows the number of rows when maxItems is set. Every change only edits the form: nothing is sent until the record is saved, and Cancel discards it. A validation error under a row path opens that row, marks it and focuses the first invalid field.
The repeater is the one field type whose value may be a nested list: cabana.ProjectWritableFields lets a repeater's list through and still drops nested values on every other field. The type is supported in controller forms only; settings forms and relation forms refuse it at start-up.
Rich text
type: richeditor edits HTML in a host text column with a WYSIWYG editor in the admin SPA. It takes the common keys (label, comment, span, required, tab, context, default) plus size; any other key stops the start-up.
fields:
description:
label: acme.faq::lang.faq.description
type: richeditor
size: large
The value travels as a string of HTML, or null. Anything else in a save body is a 422 on the field. Every submitted value is sanitized on the server before it is stored, on create and on update, through seaglass: bluemonday's UGC policy, which keeps paragraphs, headings, lists, emphasis, quotes, tables, images and links with http, https, mailto, tel or relative URLs, and removes scripts, styles, frames, forms, comments, every on* attribute and every other URL scheme.
- HTML the policy keeps whole is stored byte for byte, so a value that only differs in escaping is never rewritten.
- Otherwise the clean HTML is stored and the save still succeeds. The create or update response then carries
meta.noticesfor the field: how many elements and attributes were removed with their names, how many comments, or that some markup changed. The admin shows them under the field.
The editor's own toolbar keeps to the same allowlist, but the server is the gate: a value posted by any client goes through the policy. The type is supported in controller forms and on settings forms, whose save runs the same sanitizer; relation forms refuse it at start-up, so no save path stores an unsanitized value.
In the admin SPA the field is a TipTap editor with a toolbar for bold, italic, underline, a level 3 heading, bullet and numbered lists, quotes, links, undo and redo. The editor code loads on first use, so forms without a rich text field never download it.
- Untouched values stay as stored. Until the administrator edits the text, the form sends the loaded string unchanged. Opening and saving a form never rewrites imported HTML, even markup the editor would format differently. After the first edit the value is the editor's HTML, and an editor holding only an empty paragraph sends an empty string.
- Edit HTML. The toolbar's HTML view edits the source in a plain text area and sends the text as typed. Use it for markup the editor cannot show: when the stored HTML contains elements other than
p,br,strong,b,em,i,u,s,h3,ul,ol,li,blockquote,a,divandspan, a note above the text says so, and the HTML view changes it without losing it. - Links accept
http,https,mailto,teland relative URLs. Ctrl+K (Cmd+K) opens the link box. - After a save the notices appear under the field and the editor shows the sanitized value the server stored.
- If the editor code cannot be loaded, the field falls back to the HTML view, so the record can still be edited and saved.
Money
type: money edits an exact decimal amount. The value travels as a decimal string with a dot and at most scale fraction digits ("39.8400", "85.4193", "-12.5"), or null. Neither the server nor the admin SPA ever parses it into a float: the string is checked as text, handed to the model column unchanged, and formatted for reading with Intl.NumberFormat from the string itself. A value is never rounded or truncated: one with too many decimals is refused.
The column is a string (or *string), or a struct type whose pointer implements sql.Scanner and that implements driver.Valuer, such as a plugin's scale-4 amount type over a numeric(12,4) column. Any other column type stops the start-up naming the field.
fields:
currency:
label: acme.shop::lang.orders.currency
type: text
total:
label: acme.shop::lang.orders.total
type: money
currencyFrom: currency
max: "99999999.9999"
fee:
label: acme.shop::lang.orders.fee
type: money
currency: EUR
scale: 2
The field takes the generic keys plus:
| Key | Meaning |
|---|---|
currency |
A literal three-letter upper-case ISO 4217 code shown as the input's suffix. |
currencyFrom |
The name of a string column of the same model that holds the code; the suffix follows that field's current value on the form. Exactly one of currency and currencyFrom is required: both, or neither, stop the start-up naming the field. |
scale |
The most fraction digits a value may carry: 2, 3 or 4 (the default). |
max |
The column ceiling as a decimal string with at most scale places, for example "999999.9999" for a numeric(10,4) column. Optional. |
options, emptyOption, nameFrom, dependsOn and preset are refused on a money field, and the four money keys are refused on every other type.
A save checks every submitted money value before the transaction and answers a 422 on the field with one of three backend::lang.money messages:
| Value | Answer |
|---|---|
Not a string, or a string that is not an optional minus, digits and at most one dot followed by digits ("1,5", "1e3", "1 000", a JSON number) |
money.invalid |
More significant fraction digits than scale ("12.34567" at scale 4); trailing zeros do not count, so a scale-2 field over a numeric(10,4) column accepts its loaded "12.5000" |
money.too_precise, with :scale |
Above max in absolute magnitude, compared as scaled digit strings |
money.too_large, with :max |
null is accepted and stored as NULL (or the column type's empty value); required: true refuses it through the usual rule. A value the administrator did not edit is sent back as it was loaded, so opening and saving a form with a stored 85.4193 writes 85.4193 again, never 85.42. The same checks run on settings forms and relation forms.
In the admin SPA the field is a text input in decimal mode, right-aligned in tabular numerals, with the ISO code as a suffix (omitted while a currencyFrom sibling is still empty). A loaded value is shown with its trailing zeros trimmed down to two decimals and the locale's decimal separator; typing accepts a comma or a dot and ignores spaces, and emits the canonical dot string. The error line shows on blur for a malformed or too-precise text, and the text is still sent as typed so the server's answer is the gate. Read-only and preview show the locale-formatted amount with the code (PLN 39.84, 39,84 PLN) or an em dash when empty.
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). Declared mltext, mltextarea and mlmarkdown locale maps are the exception: they are lifted first, as Markdown and multilingual fields. A repeater's list of rows is the other exception: it is decoded row by row before the projection. 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.