feat(12.1-01): declared bulk actions on admin lists

- pact.HasAdminBulkActions with AdminBulkAction, its input and result
- config_list.yaml bulkActions, compiled fail-loud, needs showCheckboxes
- POST .../{controller}/bulk/{action}: ids resolved and locked through the
  list scope in one transaction; partial selection is 409
- list schema offers declared actions per principal, with confirm text
- admin SPA bulk actions menu with confirm, busy state and failure toasts
- acme.roster fixture, tracer test, OpenAPI, TS types, dist, READMEs, docs
This commit is contained in:
Jakub Zych
2026-10-04 23:28:30 +02:00
parent ca9e9c0557
commit a879d6388c
45 changed files with 2007 additions and 38 deletions

View File

@@ -279,6 +279,14 @@ 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}/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]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Affected != 1 || body.Data.Message != "Touched" {
t.Fatalf("bulk action body = %s (%v)", rec.Body.String(), err)
}
return rec
}, into[cabana.Envelope[cabana.BulkActionResult]](), nil},
{"DELETE /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
@@ -790,6 +798,16 @@ func (c conformController) AdminActions() []pact.AdminAction {
},
}}
}
// AdminBulkActions registers the bulk action the list's bulkActions declares.
func (conformController) AdminBulkActions() []pact.AdminBulkAction {
return []pact.AdminBulkAction{{
Name: "touch", Label: "Touch", Permissions: []string{"acme.conform.access"},
Run: func(_ context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
return pact.AdminBulkActionResult{Message: "Touched", Affected: len(in.Records)}, nil
},
}}
}
func (conformController) AdminJS() []string { return []string{"assets/js/lookup.js"} }
// PartialData supplies curated view models, never the gadget model itself.
@@ -824,6 +842,7 @@ toolbar:
buttons: [create, delete, recount]
search:
prompt: backend::lang.list.search_prompt
bulkActions: [touch]
`),
// A plain custom element: a light-DOM button that asks the admin SPA
// to run the field's action. It makes no network call and never