feat(12.1-01): declared record actions with an applicability rule

- pact.HasAdminRecordActions with AdminRecordAction (Applies, Run)
- config_form.yaml recordActions, compiled fail-loud
- show response meta.actions lists the permitted actions that apply
- POST .../{controller}/{id}/actions/{action}: record loaded and locked
  through the form scope; 404 out of scope, 409 when it does not apply
- RecordActions.vue with confirm and request flow (mounted by plan 02)
- roster fixture, smoke tests, OpenAPI, TS types, READMEs, docs
This commit is contained in:
Jakub Zych
2026-10-04 23:37:30 +02:00
parent a879d6388c
commit e0ccced76a
34 changed files with 1350 additions and 24 deletions

View File

@@ -279,6 +279,18 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
spare := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "spare-" + e.stamp}, true)
return e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk-delete", map[string]any{"ids": []uint{dataID(t, spare.Body.Bytes())}}, true)
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/actions/{action}", 200, "cabana.Envelope-cabana_AdminActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
shown := e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
if !strings.Contains(shown.Body.String(), `"actions":[{"name":"ping","label":"Ping"}]`) {
t.Fatalf("show does not offer the record action: %s", shown.Body.String())
}
rec := e.send(t, http.MethodPost, fmt.Sprintf("/acme/conform/gadgets/%d/actions/ping", e.gadgetID), map[string]any{}, true)
var body cabana.Envelope[cabana.AdminActionResult]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Message != "Pinged" || body.Data.Fill == nil || len(body.Data.Fill) != 0 {
t.Fatalf("record action body = %s (%v)", rec.Body.String(), err)
}
return rec
}, into[cabana.Envelope[cabana.AdminActionResult]](), nil},
{"POST /{vendor}/{plugin}/{controller}/bulk/{action}", 200, "cabana.Envelope-cabana_BulkActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk/touch", map[string]any{"ids": []uint{e.gadgetID}}, true)
var body cabana.Envelope[cabana.BulkActionResult]
@@ -808,6 +820,17 @@ func (conformController) AdminBulkActions() []pact.AdminBulkAction {
},
}}
}
// AdminRecordActions registers the record action the form's recordActions
// declares.
func (conformController) AdminRecordActions() []pact.AdminRecordAction {
return []pact.AdminRecordAction{{
Name: "ping", Label: "Ping", Permissions: []string{"acme.conform.access"},
Run: func(context.Context, pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
return pact.AdminRecordActionResult{Message: "Pinged"}, nil
},
}}
}
func (conformController) AdminJS() []string { return []string{"assets/js/lookup.js"} }
// PartialData supplies curated view models, never the gadget model itself.
@@ -892,6 +915,7 @@ create:
update:
redirect: acme/conform/gadgets
redirectClose: acme/conform/gadgets
recordActions: [ping]
`),
"controllers/gadgets/config_relation.yaml": file(`members:
label: Members