feat(cabana): per-request options for plugin field types

- pact.FormFieldType gains RequestOptions, called with the application's
  database once per form schema request for each type the form uses
- the form schema route merges the answer into a copy of each field's
  typeOptions; a fields.yaml key wins, the compiled schema never changes
- an error logs one warning naming the type and serves the fields.yaml
  options only
- TestFieldTypeRequestOptions covers every screen, the precedence, the
  call count, the error path, unchanged schemas, form extension fields
  and the authenticated handler; a runnable example backs the docs
- the typeOptions description, the regenerated OpenAPI document and
  schema.d.ts, the field types page and the pact and cabana READMEs
  document it
This commit is contained in:
Jakub Zych
2026-11-04 08:40:09 +01:00
parent 561a56c768
commit 4cd35c9d66
12 changed files with 416 additions and 12 deletions

View File

@@ -70,7 +70,30 @@ A plugin-typed field accepts the general keys `label`, `comment`, `span`, `type`
`CheckOptions`, when set, checks a field's options at start-up, as the `rows` check in the example above does. An error stops it and names the field and the type.
The form schema carries a plugin field with `element`, `translatable` (only when true) and `typeOptions` (only when the field sets options). `cabana.CompileFormWithTypes` compiles a form with one plugin's types outside a running application, which is how a plugin tests its `fields.yaml`.
### Per-request options
Options in `fields.yaml` are compiled once at start-up. A type whose element follows something that changes while the application runs, such as a site setting that decides which heading levels an editor offers, sets `RequestOptions` to a function such as this one:
```go src=modules/cabana/example_field_type_test.go#headingLevels
// headingLevels is a RequestOptions func: the editor offers the heading
// levels from the site's top heading down to level 4, read on every form
// schema request, so a changed setting needs no restart.
func headingLevels(ctx context.Context, db *gorm.DB) (map[string]any, error) {
top, err := siteTopHeading(ctx, db)
if err != nil {
return nil, err
}
levels := []int{}
for level := top; level <= 4; level++ {
levels = append(levels, level)
}
return map[string]any{"headingLevels": levels}, nil
}
```
The form schema route calls it once per request for each type the form uses, however many fields use the type, with the application's database (nil when none is configured), and merges the answer into the `typeOptions` of every field of the type, a form extension's fields included. A key the field sets in `fields.yaml` wins over the same key from `RequestOptions`. The element therefore reads the value from its `options` attribute and needs no request, token or route of its own. An error does not fail the form: the schema is served with the `fields.yaml` options only and the error is logged once as a warning naming the type. Every administrator who may open the form receives the options, so they must hold no secret. A type without `RequestOptions` is served exactly as before.
The form schema carries a plugin field with `element`, `translatable` (only when true) and `typeOptions` (only when the field sets options or `RequestOptions` answers some). `cabana.CompileFormWithTypes` compiles a form with one plugin's types outside a running application, which is how a plugin tests its `fields.yaml`.
## Decode, notices and refusals