feat(16.2-01): translatable plugin field types, untouched skip, writes, list values and uploads
- translatable types lift, store and hydrate through the ML path
(isMLField); Decode and Load run per locale and notices carry the locale
- on update a value equal to the served value (Load of the stored host
value or exact translation) is neither decoded nor written
- FieldWrite, FieldWritesFromContext and WithFieldWrites expose what a save
writes to the Form hooks
- list columns may use a plugin alias and show the type's ListValue
- POST .../{id}/fields/{field}/upload hands the file to the type's Upload
(guard, X-Requested-With, permissions, field context, record scope, body
cap); OpenAPI, route inventories, README and docs updated
This commit is contained in:
@@ -94,6 +94,32 @@ Any other error from `Decode` is logged and answered as the opaque 500 of a fail
|
||||
|
||||
`Load`, when set, turns the stored value into the value the element receives. It runs on the record response of `GET .../{id}` and of every create and update, with the same `pact.FormFieldValue` (the read transaction, the record, the stored `Value`). The save response therefore always carries the stored value, and the admin reloads it into the form after a save, so the administrator sees what `Decode` kept. Without `Load` the stored value is sent as it is. A `Load` error is an opaque 500.
|
||||
|
||||
## Notices
|
||||
|
||||
A notice is how a type that changes an accepted value tells the administrator what it changed. cabana collects the notices of every `Decode` call of a save, and only when the save commits does it localize them and put them in the response's `meta.notices`, one entry per message with the `field`, the content `locale` of a translatable type's value (absent for other types) and the localized `message`. A refused save (any 422 or other error) carries no notices. At most 10 notices are kept per field and locale, and each message is cut to 500 characters, so a value with thousands of removed parts cannot inflate the response. A notice may quote what the administrator submitted, such as a removed tag name; the admin shows it as text, never as markup.
|
||||
|
||||
## Translatable types
|
||||
|
||||
A type with `Translatable: true` holds one string per content locale, like `mlmarkdown`. The admin sends and receives a map of locale to string, and cabana stores it through its own multilingual path: the default locale's value goes to the host column, every other locale through the published `cabana.TranslationWriter`, inside the same permissioned save transaction. The plugin implements no locale storage.
|
||||
|
||||
`Decode` runs once per submitted locale, with `Locale` set to that content locale and `Default` true for the host column's locale; its `Value` must be a string. Each locale's notices carry that locale. `Load` runs once per locale of the record response. `Translated` reads another attribute of the same record in the same locale, exactly and without fallback, which a type that keeps two columns in step (a source and a rendered copy, for example) uses to compare them. Without a translation writer a locale map is a 422, as for the built-in multilingual types.
|
||||
|
||||
## Untouched values
|
||||
|
||||
On update, cabana compares each submitted value with the value the form was served for that field and locale: the stored host value or translation (an empty string for a missing translation), passed through `Load`. When their JSON encodings are equal, the value is neither decoded nor written, neither to the host column nor as a translation, and it yields no notice. So a record whose stored value a type would rewrite (an imported document a sanitizer would change, for instance) is left byte for byte as it is until the administrator edits that field. On create nothing is skipped. An element should not re-serialize a value the administrator did not edit either, so the comparison holds.
|
||||
|
||||
## Companion columns in Form hooks
|
||||
|
||||
A type sometimes implies a write to another column: a rendered copy next to its source, a word count, a search text. The type's `Decode` returns only the field's own value; a controller's Form hooks write the rest. `cabana.FieldWritesFromContext` returns, from the context of `pact.FormBeforeCreate`, `pact.FormAfterCreate`, `pact.FormBeforeUpdate` and `pact.FormAfterUpdate`, the values the save actually writes for plugin fields, keyed by field name, one `cabana.FieldWrite` per written locale with `Default` set for the host column's value. An untouched value is not listed, so a hook updates a companion column only for the locales that changed. A test of such a hook builds its context with `cabana.WithFieldWrites`.
|
||||
|
||||
## List columns
|
||||
|
||||
A column in `columns.yaml` may name a plugin alias as its `type`. The list then shows the type's `ListValue` for the stored value (the host column's value for a translatable type), a plain string the admin renders as text. A type without `ListValue` shows the stored value as it is.
|
||||
|
||||
## Uploads
|
||||
|
||||
A type that stores files (images in a document, say) sets `Upload`. The element never uploads anything itself: it asks the admin, which posts the file with the administrator's session to `POST <prefix>/api/v1/{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload` as one multipart part named `file`. cabana checks the backend session, the `X-Requested-With` header, the controller's permissions, that the field has a plugin type with `Upload` and that its `context` allows the operation, then caps the body at `http.body_limits.upload_bytes`. `{id}` 0 is the create form; any other id loads the record through the controller's `pact.FormExtendQuery` scope inside a transaction, and a record outside it is a 404. `Upload` receives a `pact.FormFieldUpload` with the transaction, the record (nil on the create form), the field, its options, the file name and the capped body, and returns the URL the element shows. The route answers 201 `{"data": {"url": "..."}}`; a `pact.FormFieldError` is a 422 on the field, a body over the cap a 413, and any other error the opaque 500. Checking the file (its size, its real type) is the type's job.
|
||||
|
||||
## The element
|
||||
|
||||
The admin creates the type's element for the field and talks to it through one property, attributes and events, nothing else:
|
||||
|
||||
@@ -59,7 +59,7 @@ columns:
|
||||
type: datetime
|
||||
```
|
||||
|
||||
A column takes `label`, `type` (`text`, `datetime`, `date`, `time` or `switch`), `searchable` (default false) and `sortable` (default true, as in WinterCMS), and, for a column read through a relation, `relation` and `select`. Other WinterCMS column types and options are refused at boot. When `type` is omitted, a `time.Time` column is `datetime`, a `lagoon.Date` column is `date` and a `lagoon.TimeOfDay` column is `time`; every other column stays untyped (the admin treats that as text). An explicit `type` always wins. A searchable relation column matches a record when any related record matches: a `categories` column with `relation: categories` and `select: name` on a blog post finds the posts with at least one matching category, for belongsTo, hasOne, hasMany and belongsToMany relations alike, each through a subquery, so a post is listed and counted once. A sortable hasMany or belongsToMany column sorts by its comma-joined values, the text the list shows. The compiled list keeps the columns in file order:
|
||||
A column takes `label`, `type` (`text`, `datetime`, `date`, `time` or `switch`), `searchable` (default false) and `sortable` (default true, as in WinterCMS), and, for a column read through a relation, `relation` and `select`. Other WinterCMS column types and options are refused at boot, except the alias of a [plugin field type](field-types.md#list-columns), whose column shows the type's list text. When `type` is omitted, a `time.Time` column is `datetime`, a `lagoon.Date` column is `date` and a `lagoon.TimeOfDay` column is `time`; every other column stays untyped (the admin treats that as text). An explicit `type` always wins. A searchable relation column matches a record when any related record matches: a `categories` column with `relation: categories` and `select: name` on a blog post finds the posts with at least one matching category, for belongsTo, hasOne, hasMany and belongsToMany relations alike, each through a subquery, so a post is listed and counted once. A sortable hasMany or belongsToMany column sorts by its comma-joined values, the text the list shows. The compiled list keeps the columns in file order:
|
||||
|
||||
```go src=modules/cabana/example_test.go#ExampleCompileList
|
||||
// The plugin embeds its controllers/ and models/ trees; the example reads
|
||||
|
||||
Reference in New Issue
Block a user