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:
2026-10-10 11:19:24 +02:00
parent 83fa2f7b2c
commit 8fa92db96d
21 changed files with 1281 additions and 67 deletions

View File

@@ -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: