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
513 lines
42 KiB
Markdown
513 lines
42 KiB
Markdown
---
|
|
title: Forms
|
|
description: Describe admin forms in config_form.yaml and fields.yaml, with the supported field types, spans, tabs, dropdown options and create or update contexts.
|
|
section: backend
|
|
order: 20
|
|
---
|
|
# Forms
|
|
|
|
The Form behaviour's `config_form.yaml` and the model's `fields.yaml` keep their WinterCMS shape. [cabana](../../modules/cabana/README.md) 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:
|
|
|
|
```yaml src=modules/cabana/testdata/docs/controllers/posts/config_form.yaml
|
|
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](#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](admin-controllers.md#record-actions).
|
|
|
|
## fields.yaml
|
|
|
|
```yaml src=modules/cabana/testdata/docs/models/post/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:
|
|
|
|
```go src=modules/cabana/example_test.go#ExampleCompileForm
|
|
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.md). |
|
|
| `relation-manager` | An embedded list of related records; see [Relation manager](relation-manager.md). |
|
|
| `widget` | A plugin custom element with a server action; see [Partials and widgets](partials-and-widgets.md). |
|
|
| `partial` | A server-rendered template; see [Partials and widgets](partials-and-widgets.md). |
|
|
| `fileupload` | Uploads for an attachOne or attachMany relation; see [File uploads](#file-uploads). |
|
|
| `datepicker` | A date, date and time, or time of day; see [Date pickers](#date-pickers). |
|
|
| `permissioneditor` | A list of permissions to allow, deny or leave inherited; see [Permission editor](#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](#form-only-fields). |
|
|
| `markdown` | A source editor for a host text column, with a sanitized preview; see [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
|
|
| `mltext` | A text field with one editor per locale; see [Markdown and multilingual fields](#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](#markdown-and-multilingual-fields). |
|
|
| `mlmarkdown` | A markdown field with one editor per locale; see [Markdown and multilingual fields](#markdown-and-multilingual-fields). |
|
|
| `richeditor` | A WYSIWYG HTML editor for a host text column, sanitized on every save; see [Rich text](#rich-text). |
|
|
| `repeater` | A list of rows, each with its own small form, stored as JSON in one column; see [Repeater](#repeater). |
|
|
| `money` | An exact decimal amount with a currency suffix, never a float and never rounded; see [Money](#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-types.md).
|
|
|
|
### 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](#dependent-dropdowns)) and `currency`, `currencyFrom`, `scale` and `max` on money fields (see [Money](#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](#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`:
|
|
|
|
```yaml
|
|
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":
|
|
|
|
```yaml
|
|
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:
|
|
|
|
```go src=modules/signpost/example_test.go#EntriesController.DependentOptions
|
|
// 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](../plugins/menu-item-types.md) 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](../database/casts-and-validation.md) for the two framework types. Use the pointer types for an optional value: an empty picker stores NULL, and `required: true` refuses it.
|
|
|
|
```yaml
|
|
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](../database/attachments.md). The field name must be one of the declared relation names, otherwise the start-up stops.
|
|
|
|
```yaml
|
|
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](../database/attachments.md#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:
|
|
|
|
```yaml src=modules/cabana/testdata/roster/controllers/people/config_form.yaml
|
|
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](partials-and-widgets.md#status-hints)); 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`:
|
|
|
|
```go src=modules/cabana/example_form_seams_test.go#MembersController.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](admin-controllers.md#form-only-values-and-rules). 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.
|
|
- On a settings form `type: password` is not a form-only field but a write-only secret backed by a `lagoon.Encrypted` column; see [Write-only secrets](settings.md#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`:
|
|
|
|
```yaml
|
|
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:
|
|
|
|
```go src=modules/cabana/example_form_seams_test.go#MembersController.AdminPermissionOptions
|
|
// 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
|
|
}
|
|
```
|
|
|
|
```go src=modules/cabana/example_form_seams_test.go#MembersController.AdminPermissionValues
|
|
// 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
|
|
}
|
|
```
|
|
|
|
```go src=modules/cabana/example_form_seams_test.go#MembersController.AdminSetPermissionValues
|
|
// 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 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 `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.
|
|
|
|
## 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](#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.
|
|
|
|
```yaml
|
|
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`:
|
|
|
|
```yaml
|
|
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.fields` takes the field types `text`, `textarea`, `number`, `checkbox`, `switch`, `dropdown` (with static options, a method, or `dependsOn` naming another field of the same row) and `richeditor`. Any other type, a repeater inside a repeater, and `tab`, `context` or `preset` on a nested field stop the start-up. WinterCMS's `groups` and `style` are refused too.
|
|
- `prompt` is the add button's text, `titleFrom` names the nested field whose value titles a collapsed row, and `minItems` and `maxItems` bound the number of rows (non-negative, `minItems` at most `maxItems`). The field always spans the full width; it also takes `label`, `comment`, `required`, `tab` and `context`.
|
|
- The model column holds the JSON list, usually a `lagoon.Jsonable` of `[]map[string]any` or 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.
|
|
|
|
```yaml
|
|
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](../../modules/seaglass/README.md): 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.notices` for 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](settings.md#rich-text-on-a-settings-page), 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`, `div` and `span`, a note above the text says so, and the HTML view changes it without losing it.
|
|
- **Links** accept `http`, `https`, `mailto`, `tel` and 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.
|
|
|
|
```yaml
|
|
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](../database/models.md)). Declared `mltext`, `mltextarea` and `mlmarkdown` locale maps are the exception: they are lifted first, as [Markdown and multilingual fields](#markdown-and-multilingual-fields). A [repeater](#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](#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.
|