feat(12.1-02): writable foreign keys, locked relation options, invisible columns

- FieldRelationContract.WritableForeignKey makes a belongsTo field over a
  protected foreign key writable; the protected key list is unchanged
- cabana.RelationLockProvider names related ids an administrator may not add
  or remove: options and labels carry locked, and a create or update that
  changes the locked subset is 403 before any row is written
- columns.yaml invisible keeps a column searchable and out of the rows
- a controller implementing pact.FilterOptions serves a scope filter's
  choices before the model
- SPA: locked chips and options in RelationField, DataTable skips invisible
  columns
- README, docs, OpenAPI document, TS types and dist updated
This commit is contained in:
Jakub Zych
2026-10-05 10:58:38 +02:00
parent f50d9b8f10
commit df5cace852
39 changed files with 1115 additions and 84 deletions

View File

@@ -423,6 +423,7 @@ list: ~/plugins/acme/roster/models/person/columns.yaml
modelClass: Person
title: acme.roster::lang.people.title
recordUrl: acme/roster/people/preview/:id
filter: config_filter.yaml
recordsPerPage: 20
showCheckboxes: true
toolbar:

View File

@@ -88,6 +88,8 @@ for _, f := range list.Filters {
`type: date` and `type: time` are for `lagoon.Date` and `lagoon.TimeOfDay` columns (a `DATE` and a `TIME` column): the admin shows the stored `2026-10-02` or `14:30:00` as it is, with no time zone conversion, where `type: datetime` would read a date as midnight UTC and could show the previous day. A struct that stores itself in one column (it implements `sql.Scanner` or `driver.Valuer`, as both types do) is a plain column, never a relation.
`invisible: true` keeps a column out of the table while it stays part of the list: the search covers it when it is `searchable`, it can still be sorted by, and the schema reports it with `invisible`. The admin SPA renders no header and no cell for it, and the rows of a list response do not carry its value. Use it for a column administrators search by but do not need to read, such as an e-mail address next to a name.
Search runs over the searchable columns only; sort accepts only sortable columns. A request that names any other column is refused, never passed to SQL.
## Filters
@@ -110,7 +112,9 @@ scopes:
|------|---------|
| `switch` | A boolean `column`. With `options`, the two values are the options' keys, kept as typed scalars. |
| `daterange` | A date or timestamp `column` between two dates. |
| scope (a `scope` key with `modelClass` and `nameFrom`) | Records by a model-backed choice. The model implements `pact.FilterScope`, whose `FilterScopes` lists the scope names it answers, and `pact.FilterOptions` for the choices the SPA loads. |
| scope (a `scope` key with `modelClass` and `nameFrom`) | Records by a model-backed choice. The model implements `pact.FilterScope`, whose `FilterScopes` lists the scope names it answers. `pact.FilterOptions` serves the choices the SPA loads; the controller or the model implements it. |
The choices of a scope filter are asked from the controller first, when it implements `pact.FilterOptions`, and from the model otherwise. A model is a fresh value with no database handle, so choices that are rows of a table (the groups a user can be filtered by, for example) belong on the controller, which can hold the handle it was built with. The scope that filters the query is always the model's `pact.FilterScope`. A scope filter with no `pact.FilterOptions` on either stops the start-up.
WinterCMS `conditions` SQL fragments are not supported; use a `column` or a scope the model implements. A scope filter whose name the model does not list in `FilterScopes` stops the start-up, so request text can never select another method.

View File

@@ -23,6 +23,63 @@ The controller implements `cabana.FieldRelationProvider` and returns a `cabana.F
The SPA loads the choices, paginated, from `.../fields/{field}/options`, and every record response carries the display labels of the linked records. A controller that implements `pact.RelationExtendOptionsQuery` narrows the choices, and the same scoped query rechecks the submitted IDs on save, so a record it does not offer cannot be attached.
### Writable foreign keys
Some column names are never written from a request body, whatever the form declares: `id`, `created_at`, `updated_at`, `deleted_at`, `owner_id`, `user_id`, `collection_id`, `organisation_id`, `organization_id`, `scope_id`, `role_id`, `permissions`, `is_superuser`, `is_system`, `is_activated` and `password`. A belongsTo field whose `ForeignKey` is one of them is therefore read-only: it shows the linked record's label and has no options endpoint. A contract that sets `WritableForeignKey` makes this one field writable:
```go src=modules/cabana/example_form_seams_test.go#MembersController.AdminFieldRelations
// AdminFieldRelations binds the form's two relation fields. organisation_id
// is a protected column, so the team field would be read-only; the contract
// opts in to writing it through this field.
func (MembersController) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{
Field: "team",
Kind: "belongsTo",
NewRelated: func() any { return &Team{} },
ForeignKey: "organisation_id",
WritableForeignKey: true,
}, {
Field: "groups",
Kind: "belongsToMany",
NewRelated: func() any { return &Group{} },
NewPivot: func() any { return &MemberGroup{} },
ParentForeignKey: "member_id",
RelatedForeignKey: "group_id",
}}
}
```
The protection itself does not change. A plain field named `organisation_id` is still dropped from a body, and the id submitted through the relation field is still rechecked against the scoped options query, so only a record the controller offers can be set. `WritableForeignKey` on a belongsToMany contract stops the start-up.
### Locked options
A controller may let an administrator see a choice without letting them change it, for example membership of a group that grants access. It implements `cabana.RelationLockProvider` and returns, per field and per request, a `cabana.RelationLock`: the related ids that are locked for the signed-in administrator, and a message.
```go src=modules/cabana/example_form_seams_test.go#MembersController.AdminRelationLocks
// AdminRelationLocks names the groups an administrator without
// acme.roster.manage may not put a member into or take a member out of. Inside
// a save the ids are read with the save's transaction.
func (c MembersController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
principal, _ := bouncer.User(ctx)
if field != "groups" || cabana.Allows(principal, []string{"acme.roster.manage"}) {
return cabana.RelationLock{}, nil
}
db := c.DB
if tx, ok := cabana.TxFromContext(ctx); ok {
db = tx
}
lock := cabana.RelationLock{Message: "acme.roster::lang.members.group_locked"}
err := db.WithContext(ctx).Model(&Group{}).Where("code = ?", "staff").Pluck("id", &lock.IDs).Error
return lock, err
}
```
- The options endpoint and the labels of every record response mark those records with `locked: true`. The admin SPA shows a locked chip without a remove button, does not let a locked option be chosen, and prints a short note under the field. A response without locks has no `locked` key.
- The server enforces the lock; the flag is only a display aid. A create or update is checked inside its transaction, after the scope check and before any row is written. For a belongsToMany field the locked ids among the record's current links and among the submitted ids must be the same set, so a locked record can be neither added nor removed while other links change freely. For a belongsTo field a change is refused when the current or the submitted record is locked.
- A refused save is answered 403 `forbidden` with the lock's `Message` (a translation key or text), also as a message on the field, and nothing is written. With an empty `Message` the admin shows its own text.
- A save that does not send the field is not checked: it changes nothing. A controller without the provider behaves as before.
- The provider is asked with the request context: `bouncer.User(ctx)` is the administrator, and inside a save `cabana.TxFromContext(ctx)` is the transaction.
## Relation managers
A `type: relation-manager` field embeds a relation manager in the form. Its `relation` key names an entry in the controller's `config_relation.yaml`, which describes the two panels: the linked records (`view`) and the candidates shown when linking (`manage`):