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

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