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:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`):
|
||||
|
||||
Reference in New Issue
Block a user