feat(10.1-01): run registered widget actions through a cabana-owned route

- pact: AdminClientAssets, AdminAction, AdminActionInput, AdminActionResult,
  HasAdminActions and AdminPartialData contracts
- fields.yaml type: widget with widget, action and fill keys; boot checks the
  plugin tag prefix, the registered action and writable scalar fill fields
- POST .../widgets/{field} behind requireAjax, controller and action
  permissions, scoped non-locking record read and a server-side fill filter
- typed OpenAPI operation, inventories and an acme conformance case
This commit is contained in:
Jakub Zych
2026-09-28 23:35:00 +02:00
parent 9b98d8409f
commit f9281949a6
19 changed files with 876 additions and 10 deletions

View File

@@ -2,6 +2,7 @@ package cabana_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io/fs"
@@ -103,6 +104,19 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
e.gadgetID = dataID(t, rec.Body.Bytes())
return rec
}, into[cabana.RecordEnvelope]()},
{"POST /{vendor}/{plugin}/{controller}/widgets/{field}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets/widgets/lookup", map[string]any{"record_id": e.gadgetID, "values": map[string]any{"name": "x", "active": false}}, true)
// The fixture action also returns active, which is outside the
// field's fill: the server filter must drop it.
var body cabana.Envelope[cabana.AdminActionResult]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("widget action body: %v\n%s", err, rec.Body.String())
}
if len(body.Data.Fill) != 1 || body.Data.Fill["name"] != "lookup-"+e.stamp {
t.Fatalf("widget fill = %#v, want only name", body.Data.Fill)
}
return rec
}, into[cabana.Envelope[cabana.AdminActionResult]]()},
{"GET /{vendor}/{plugin}/{controller}", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets?search="+e.stamp, nil, true)
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]]()},
@@ -299,7 +313,7 @@ func newConformEnv(t *testing.T) *conformEnv {
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
t.Fatal(err)
}
plugins := []party.Plugin{conformPlugin{}}
plugins := []party.Plugin{conformPlugin{stamp: stamp}}
if err := phrasebook.Activate(app, plugins); err != nil {
t.Fatal(err)
}
@@ -362,14 +376,14 @@ func (conformSettings) TableName() string { return "cabana_conform_settin
func (conformSettings) Fillable() []string { return []string{"enabled"} }
func (conformSettings) Rules() map[string]string { return map[string]string{} }
type conformPlugin struct{}
type conformPlugin struct{ stamp string }
func (conformPlugin) ID() string { return "acme.conform" }
func (conformPlugin) Requires() []string { return nil }
func (conformPlugin) Register(*backpack.App) error { return nil }
func (conformPlugin) Boot(*backpack.App) error { return nil }
func (conformPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{conformController{}}
func (p conformPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{conformController{stamp: p.stamp}}
}
func (conformPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.conform.access", Roles: []string{"developer"}}}
@@ -386,7 +400,7 @@ func (conformPlugin) Settings() []pact.SettingsItem {
}
func (conformPlugin) AdminFS() fs.FS { return conformFS() }
type conformController struct{}
type conformController struct{ stamp string }
func (conformController) ID() string { return "acme.conform.gadgets" }
func (conformController) ModelName() string { return "Gadget" }
@@ -403,6 +417,15 @@ func (conformController) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &conformGroup{} }, ForeignKey: "group_id"}}
}
func (c conformController) AdminActions() []pact.AdminAction {
return []pact.AdminAction{{
Name: "lookup", Label: "Look up", Permissions: []string{"acme.conform.access"},
Run: func(context.Context, pact.AdminActionInput) (pact.AdminActionResult, error) {
return pact.AdminActionResult{Message: "Looked up", Fill: map[string]any{"name": "lookup-" + c.stamp, "active": true}}, nil
},
}}
}
func conformFS() fs.FS {
file := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} }
return fstest.MapFS{
@@ -483,6 +506,12 @@ update:
type: relation-manager
relation: members
context: [update]
lookup:
label: Lookup
type: widget
widget: acme-conform-lookup
action: lookup
fill: [name]
`),
"models/settings/fields.yaml": file(`fields:
enabled: