Files
summercms/docs/backend/forms.md
Jakub Zych 67d4c7ff13 feat(12.2-02): add the datepicker field with server-side bounds and date list columns
- type: datepicker compiles the D-20 keys; format maps to displayFormat with WinterCMS's momentFormat table
- boot fails when the mode does not match the column's Go type (time.Time, lagoon.Date, lagoon.TimeOfDay)
- datepicker is a writable scalar field; minDate and maxDate are rechecked on save
- columns.yaml accepts type: date and type: time; Scanner/Valuer structs are columns, not relations
- conformance fixture carries date and datetime fields; README, forms and lists docs
2026-10-02 18:19:04 +02:00

189 lines
11 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.
## 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). |
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. 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. 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.
## 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.
## 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. It 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.
## 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)). 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.