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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user