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

@@ -40,6 +40,7 @@ type listDocument struct {
Filter string `yaml:"filter"`
Messages *listMessageKeys `yaml:"messages"`
HeaderPartial string `yaml:"headerPartial"`
BulkActions bulkActionNames `yaml:"bulkActions"`
}
type listSortDocument struct {
@@ -153,6 +154,11 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
if doc.ShowCheckboxes {
bulk = append(bulk, BulkAction{Name: "delete", Label: "backend::lang.list.delete_selected"})
}
declared, err := compileBulkActionNames(ctl, doc.BulkActions.items, doc.ShowCheckboxes)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
bulk = append(bulk, declared...)
filters := []ListFilter{}
if strings.TrimSpace(doc.Filter) != "" {
filters, err = compileFilters(pluginID, ctl, fsys, dir, doc.Filter)
@@ -362,6 +368,81 @@ func compileToolbarButtons(ctl pact.AdminController, toolbar *listToolbar, showC
return out, custom, nil
}
// bulkActionNames is the declarative bulkActions list (D-09): the names of
// bulk actions the controller registers through pact.HasAdminBulkActions, in
// menu order. Decode has no controller, so membership is resolved in
// compileBulkActionNames.
type bulkActionNames struct {
items []string
}
func (b *bulkActionNames) UnmarshalYAML(node ast.Node) error {
items, err := actionNameList(node, "bulkActions", "bulkActions must be a list of bulk action names the controller registers")
if err != nil {
return err
}
b.items = items
return nil
}
// actionNameList decodes a YAML sequence of action names for key: duplicates
// are refused, and anything but a sequence is refused with scalarHint.
func actionNameList(node ast.Node, key, scalarHint string) ([]string, error) {
node = unwrapNode(node)
switch n := node.(type) {
case nil, *ast.NullNode:
return nil, nil
case *ast.SequenceNode:
values := sequenceValues(n)
items := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, item := range values {
name, err := nodeString(unwrapNode(item))
if err != nil {
return nil, fmt.Errorf("%s entries must be action names", key)
}
if _, dup := seen[name]; dup {
return nil, fmt.Errorf("%s: duplicate action %s", key, name)
}
seen[name] = struct{}{}
items = append(items, name)
}
return items, nil
default:
return nil, fmt.Errorf("%s", scalarHint)
}
}
// compileBulkActionNames resolves every bulkActions name against the bulk
// actions the controller registers and returns the schema entries in declared
// order, with their source label and confirm keys.
func compileBulkActionNames(ctl pact.AdminController, names []string, showCheckboxes bool) ([]BulkAction, error) {
if len(names) == 0 {
return nil, nil
}
if !showCheckboxes {
return nil, fmt.Errorf("bulkActions needs showCheckboxes: true")
}
registered := map[string]pact.AdminBulkAction{}
if src, ok := ctl.(pact.HasAdminBulkActions); ok && src != nil {
for _, action := range src.AdminBulkActions() {
registered[action.Name] = action
}
}
out := make([]BulkAction, 0, len(names))
for _, name := range names {
action, ok := registered[name]
if !ok || builtinToolbarActions[name] {
return nil, fmt.Errorf("bulkActions: unsupported action %s (want a bulk action the controller registers)", name)
}
if strings.TrimSpace(action.Label) == "" {
return nil, fmt.Errorf("bulkActions: action %s needs a label", name)
}
out = append(out, BulkAction{Name: name, Label: action.Label, Confirm: action.Confirm})
}
return out, nil
}
func compileDefaultSort(doc *listSortDocument, columns []ListColumn) (*ListSort, error) {
if doc == nil {
return nil, nil
@@ -555,6 +636,7 @@ func localizeBulkActions(ctx context.Context, tr *phrasebook.Translator, actions
out := make([]BulkAction, len(actions))
for i, action := range actions {
action.Label = translateKey(ctx, tr, action.Label)
action.Confirm = translateKey(ctx, tr, action.Confirm)
out[i] = action
}
if out == nil {