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

@@ -49,8 +49,11 @@ type rosterPerson struct {
Slug string `gorm:"column:slug"`
// Permissions is the permission editor's storage: a JSON object of code
// to value, or NULL.
Permissions *string `gorm:"column:permissions"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
Permissions *string `gorm:"column:permissions"`
// OrganisationID is a protected fill key: only the team relation field,
// whose contract sets WritableForeignKey, writes it.
OrganisationID *uint `gorm:"column:organisation_id"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
}
func (rosterPerson) TableName() string { return "roster_people" }
@@ -62,6 +65,42 @@ func (rosterPerson) Rules() map[string]string {
return map[string]string{"name": "required", "password": "required|between:8,255|confirmed"}
}
// FilterScopes and FilterScope: the tagged filter keeps the people who carry
// one tag. Its choices come from the controller, which can read the tags.
func (rosterPerson) FilterScopes() []string { return []string{"tagged"} }
func (rosterPerson) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
if db == nil || name != "tagged" {
return db
}
return db.Where("roster_people.id IN (SELECT person_id FROM roster_person_tags WHERE tag_id = ?)", value)
}
// rosterTeam is the belongsTo target of the team field.
type rosterTeam struct {
ID uint `gorm:"column:id;primaryKey"`
Tenant string `gorm:"column:tenant"`
Name string `gorm:"column:name"`
}
func (rosterTeam) TableName() string { return "roster_teams" }
// rosterTag is the belongsToMany target of the tags field. The tag named
// staff is locked for an administrator without acme.roster.manage.
type rosterTag struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
}
func (rosterTag) TableName() string { return "roster_tags" }
type rosterPersonTag struct {
PersonID uint `gorm:"column:person_id;primaryKey"`
TagID uint `gorm:"column:tag_id;primaryKey"`
}
func (rosterPersonTag) TableName() string { return "roster_person_tags" }
// rosterHash is the fixture's stand-in for a password hash.
func rosterHash(plain string) string {
sum := sha256.Sum256([]byte(plain))
@@ -156,6 +195,12 @@ func (s *rosterSpy) takeBulk() []pact.AdminBulkActionInput {
type rosterPlugin struct {
spy *rosterSpy
fsys fs.FS
// db is the handle the controller reads filter choices and locked tags
// with outside a transaction.
db *gorm.DB
// relations, when set, rewrites the controller's relation contracts
// (boot tests).
relations func([]cabana.FieldRelationContract) []cabana.FieldRelationContract
}
func (rosterPlugin) ID() string { return "acme.roster" }
@@ -163,7 +208,7 @@ func (rosterPlugin) Requires() []string { return nil }
func (rosterPlugin) Register(*backpack.App) error { return nil }
func (rosterPlugin) Boot(*backpack.App) error { return nil }
func (p rosterPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{rosterController{spy: p.spy}}
return []pact.AdminController{rosterController{spy: p.spy, db: p.db, relations: p.relations}}
}
func (rosterPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.roster.access", Roles: []string{"developer"}}, {Code: "acme.roster.manage", Roles: []string{"developer"}}}
@@ -188,7 +233,74 @@ func (rosterPlugin) LangFS() fs.FS {
return out
}
type rosterController struct{ spy *rosterSpy }
type rosterController struct {
spy *rosterSpy
db *gorm.DB
relations func([]cabana.FieldRelationContract) []cabana.FieldRelationContract
}
// AdminFieldRelations: team writes the protected organisation_id through an
// explicit opt-in; tags is a plain belongsToMany.
func (c rosterController) AdminFieldRelations() []cabana.FieldRelationContract {
out := []cabana.FieldRelationContract{{
Field: "team", Kind: "belongsTo", NewRelated: func() any { return &rosterTeam{} },
ForeignKey: "organisation_id", WritableForeignKey: true,
}, {
Field: "tags", Kind: "belongsToMany", NewRelated: func() any { return &rosterTag{} },
NewPivot: func() any { return &rosterPersonTag{} }, ParentForeignKey: "person_id", RelatedForeignKey: "tag_id",
}}
if c.relations != nil {
out = c.relations(out)
}
return out
}
// RelationExtendOptionsQuery offers only the acme tenant's teams.
func (rosterController) RelationExtendOptionsQuery(_ context.Context, field string, db *gorm.DB) *gorm.DB {
if field == "team" {
return db.Where("tenant = ?", "acme")
}
return db
}
// handle is the save's transaction when there is one, else the plugin's
// database handle.
func (c rosterController) handle(ctx context.Context) *gorm.DB {
if tx, ok := cabana.TxFromContext(ctx); ok {
return tx
}
return c.db.WithContext(ctx)
}
// AdminRelationLocks locks the staff tag for an administrator without
// acme.roster.manage.
func (c rosterController) AdminRelationLocks(ctx context.Context, field string) (cabana.RelationLock, error) {
principal, _ := bouncer.User(ctx)
if field != "tags" || cabana.Allows(principal, []string{"acme.roster.manage"}) {
return cabana.RelationLock{}, nil
}
var ids []uint
if err := c.handle(ctx).Model(&rosterTag{}).Where("name = ?", "staff").Pluck("id", &ids).Error; err != nil {
return cabana.RelationLock{}, err
}
return cabana.RelationLock{IDs: ids, Message: "acme.roster::lang.people.tag_locked"}, nil
}
// FilterOptions serves the tagged filter's choices from the database.
func (c rosterController) FilterOptions(scope string) []pact.Option {
if scope != "tagged" || c.db == nil {
return nil
}
var tags []rosterTag
if err := c.db.Order("name").Find(&tags).Error; err != nil {
return nil
}
out := make([]pact.Option, len(tags))
for i, tag := range tags {
out[i] = pact.Option{Value: fmt.Sprint(tag.ID), Label: tag.Name}
}
return out
}
func (rosterController) ID() string { return "acme.roster.people" }
func (rosterController) ModelName() string { return "Person" }
@@ -496,9 +608,16 @@ type rosterEnv struct {
}
func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
t.Helper()
return newRosterEnvWith(t, nil)
}
// newRosterEnvWith is newRosterEnv with the plugin adjusted by configure
// before it is assembled.
func newRosterEnvWith(t *testing.T, configure func(*rosterPlugin)) (*rosterEnv, *gorm.DB) {
t.Helper()
gdb := adminGorm(t)
models := []any{&rosterPerson{}}
models := []any{&rosterPerson{}, &rosterTeam{}, &rosterTag{}, &rosterPersonTag{}}
if err := gdb.Migrator().DropTable(models...); err != nil {
t.Fatal(err)
}
@@ -537,7 +656,11 @@ func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
t.Fatal(err)
}
spy := &rosterSpy{}
plugins := []party.Plugin{rosterPlugin{spy: spy}}
plugin := rosterPlugin{spy: spy, db: gdb}
if configure != nil {
configure(&plugin)
}
plugins := []party.Plugin{plugin}
if err := phrasebook.Activate(app, plugins); err != nil {
t.Fatal(err)
}
@@ -591,12 +714,18 @@ func rosterTree(t *testing.T, replace map[string]string) fstest.MapFS {
// rosterBoot activates the roster plugin over fsys and returns the boot error.
func rosterBoot(t *testing.T, fsys fs.FS) error {
t.Helper()
return rosterBootWith(t, rosterPlugin{spy: &rosterSpy{}, fsys: fsys})
}
// rosterBootWith activates one roster plugin value and returns the boot error.
func rosterBootWith(t *testing.T, plugin rosterPlugin) error {
t.Helper()
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{rosterPlugin{spy: &rosterSpy{}, fsys: fsys}})
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{plugin})
return err
}