- type: fileupload compiles the D-08 keys and binds to the model's attach.Relation at boot
- X-Session-Key (cabana.SessionKeyHeader) carries the form session key; RecordInput.SessionKey
- GET and POST .../{id}/files/{field}: list with pending uploads, multipart upload into attach.Store
- the create and update save attaches the session's pending files in its transaction
- swagger2openapi folds formData parameters into a multipart requestBody
- admin OpenAPI, TS types, conformance cases, README and forms docs
144 lines
7.7 KiB
Markdown
144 lines
7.7 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). |
|
|
|
|
The WinterCMS widgets that are not in this list (the rich editor, the media finder, the repeater 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.
|
|
|
|
## 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.
|
|
|
|
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.
|