fix(cabana): translate hook ValidationError phrase keys in the request locale
- One shared localizer for ValidationError and ForbiddenError, tested in writeCRUDError's order; replaces the ForbiddenError-only one. - Applied on the CRUD and relation service transactions, the detached bulk branch, runAction, the record read (ShowRecord), and the reorder, singleton and relation-options routes, which localized neither type. - Only strings containing "::" are looked up, so translated text passes through unchanged and logs no missing-key warning. - Copy-on-write: the plugin's error value is never modified. - README and backend docs describe the localized ValidationError.
This commit is contained in:
@@ -269,13 +269,15 @@ A hook or an action stops a write by returning an error. Which error decides wha
|
||||
|
||||
| Error | Answer |
|
||||
|-------|--------|
|
||||
| `cabana.ValidationError` | 422 `validation_failed`, with its `Details` as messages per field. |
|
||||
| `cabana.ValidationError` | 422 `validation_failed`, with its `Details` as messages per field; each message that is a translation key is translated in the request locale. |
|
||||
| `cabana.ForbiddenError` | 403 `forbidden`, with its `Message` and its `Details`. |
|
||||
| any other error | The opaque 500. The error is logged on the server and its text never reaches the client. |
|
||||
|
||||
Return a `cabana.ForbiddenError` when the signed-in administrator may open the screen but may not make this particular change, for example editing a record that needs a higher permission. `Message` is a translation key or text; cabana translates it, and every `Details` message, in the request locale. `Details` maps a field name to a list of messages, as a `cabana.ValidationError` does, and may be left out. With an empty `Message` the admin shows its own text.
|
||||
|
||||
Every one of these errors rolls the write's transaction back, so a refused write changes nothing: a bulk action that refuses on its third record leaves the first two untouched. The admin SPA shows a refused save as a banner above the form and keeps what the administrator typed; a refused delete or action is a toast. A `cabana.ForbiddenError` works the same from the form hooks, the relation hooks, and bulk, record, toolbar and widget actions.
|
||||
A `cabana.ValidationError` message may be a translation key too. A hook that returns `acme.blog::lang.errors.slug_taken` on the `slug` field shows the administrator the sentence in their language; text that is not a key, such as an already translated message, is sent as written. cabana translates a copy, so the error can be a shared package variable.
|
||||
|
||||
Every one of these errors rolls the write's transaction back, so a refused write changes nothing: a bulk action that refuses on its third record leaves the first two untouched. The admin SPA shows a refused save as a banner above the form and keeps what the administrator typed; a refused delete or action is a toast. Both errors work the same, translated in the request locale, from the form hooks, the relation hooks, bulk, record, toolbar and widget actions, the reorder hook (`pact.ReorderAfterSave`) and the singleton record (`pact.AdminSingleton`).
|
||||
|
||||
## Toolbar actions
|
||||
|
||||
@@ -441,7 +443,7 @@ The admin SPA shows the declared actions in a "Bulk actions" menu next to the se
|
||||
|
||||
- The ids are resolved and row-locked through the controller's `pact.ListExtendQuery` scope inside one transaction. `Run` receives the loaded records in `pact.AdminBulkActionInput`, never the ids, so an id outside the scope cannot reach plugin code.
|
||||
- A selection that matches no row in the scope answers `affected: 0` without calling `Run`. A selection of which only a part is in the scope answers 409 `conflict` and changes nothing.
|
||||
- `Run` writes through `cabana.TxFromContext(ctx)`. Any error rolls every row back; a `cabana.ValidationError` is a 422 and any other error the opaque 500.
|
||||
- `Run` writes through `cabana.TxFromContext(ctx)`. Any error rolls every row back; a `cabana.ValidationError` is a 422 with its messages translated in the request locale, and any other error the opaque 500.
|
||||
- The action's `Permissions` are checked on top of the controller's. An administrator who may not run an action does not get it in the list schema, and posting it answers 403.
|
||||
- `pact.AdminBulkActionResult` carries an optional `Message` (a phrase key or text) and `Affected`, the number of records the action changed. The answer is a `cabana.BulkActionResult`.
|
||||
- An action with `Detached: true` runs outside the framework transaction. Its records are loaded through the same scope, with the same empty and 409 rules, but without row locks, and `cabana.TxFromContext` reports no transaction. `Run` opens the short transactions it needs (`lagoon.Transaction`). Set it on an action that calls an external service, so that no transaction or row lock is held across the call. What the action saved before a later failure stays saved.
|
||||
|
||||
@@ -284,7 +284,7 @@ A save posts `{partition, nodes: [{id, parent_id, sort_order}]}` to `POST .../re
|
||||
- A duplicate id, a parent outside the set, a cycle and a node deeper than `maxDepth` are 422 `validation_failed` on `nodes`; a partitioned page without `partition` is a 422 on `partition`.
|
||||
- Otherwise the parent and sort columns of every node are written, without running the model's save hooks, and the answer is the new tree.
|
||||
|
||||
The model's save hooks do not run on a reorder, and neither do the controller's `pact.FormAfterUpdate` hooks. A controller that has to react to a new order implements `pact.ReorderAfterSave`: `ReorderAfterSave(ctx, partition)` runs after every node was written, inside the same transaction (`cabana.TxFromContext` returns it), with the posted partition, or nil when the reorder has none. A returned error rolls the whole reorder back. The transaction is a `lagoon.Transaction`, so work registered with `lagoon.AfterCommit` runs only once the new order is committed; purging a site's page cache after a menu reorder is the typical case (`cooler.PurgeSiteCacheAfterCommit`, see [Site cache purge](../services/caching.md#site-cache-purge)).
|
||||
The model's save hooks do not run on a reorder, and neither do the controller's `pact.FormAfterUpdate` hooks. A controller that has to react to a new order implements `pact.ReorderAfterSave`: `ReorderAfterSave(ctx, partition)` runs after every node was written, inside the same transaction (`cabana.TxFromContext` returns it), with the posted partition, or nil when the reorder has none. A returned error rolls the whole reorder back. A `cabana.ValidationError` is answered 422 and a `cabana.ForbiddenError` 403, both with their messages translated in the request locale; any other error is the opaque 500. The transaction is a `lagoon.Transaction`, so work registered with `lagoon.AfterCommit` runs only once the new order is committed; purging a site's page cache after a menu reorder is the typical case (`cooler.PurgeSiteCacheAfterCommit`, see [Site cache purge](../services/caching.md#site-cache-purge)).
|
||||
|
||||
Both routes need the controller's permissions; the save is a write route (CSRF header, and a personal token needs a scope that allows POST).
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ lookup:
|
||||
- `action` names an action the controller registers through `pact.HasAdminActions`.
|
||||
- `fill` lists the writable scalar fields of the same form the action may write back.
|
||||
|
||||
The SPA posts `record_id`, the fill snapshot `values` and, when the element supplied one, `payload` to `.../widgets/{field}`. cabana checks the CSRF header, the controller's and the action's permissions and the record scope, then calls the action's `Run` with a `pact.AdminActionInput`. The action reads the payload from the `Payload` field as raw JSON (any value up to 64 KiB, exactly the bytes the client sent, nil when absent) and decodes it itself; it must treat any ids inside the payload as untrusted input and re-check them against its own scope, because the framework never uses the payload to select the record. The answer's `pact.AdminActionResult` carries a message and the fill values; keys outside `fill` and values that are not scalars are dropped before the response is written. It may also carry `Data`, any JSON value up to 256 KiB encoded, which is passed through as `data` and is not filtered like `fill` (a larger or unencodable value is a 500). Toolbar and record actions refuse a payload. An action may return a `cabana.ValidationError` to answer 422 on a field.
|
||||
The SPA posts `record_id`, the fill snapshot `values` and, when the element supplied one, `payload` to `.../widgets/{field}`. cabana checks the CSRF header, the controller's and the action's permissions and the record scope, then calls the action's `Run` with a `pact.AdminActionInput`. The action reads the payload from the `Payload` field as raw JSON (any value up to 64 KiB, exactly the bytes the client sent, nil when absent) and decodes it itself; it must treat any ids inside the payload as untrusted input and re-check them against its own scope, because the framework never uses the payload to select the record. The answer's `pact.AdminActionResult` carries a message and the fill values; keys outside `fill` and values that are not scalars are dropped before the response is written. It may also carry `Data`, any JSON value up to 256 KiB encoded, which is passed through as `data` and is not filtered like `fill` (a larger or unencodable value is a 500). Toolbar and record actions refuse a payload. An action may return a `cabana.ValidationError` to answer 422 on a field; its messages may be translation keys, translated in the request locale.
|
||||
|
||||
### Widget element contract
|
||||
|
||||
|
||||
Reference in New Issue
Block a user