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

@@ -72,6 +72,11 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err)
}
cc.BulkActions = bulkActions
recordActions, err := compileRecordActions(cc.Controller)
if err != nil {
return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err)
}
cc.RecordActions = recordActions
if err := compileClientAssets(pluginID, cc, fsys); err != nil {
return err
}
@@ -81,6 +86,21 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
if cc.Form == nil {
return nil
}
// Every recordActions name of config_form.yaml must be a record action
// the controller registers, with a label (D-10).
formFile := cc.Form.configPath
if formFile == "" {
formFile = "config_form.yaml"
}
for _, name := range cc.Form.recordActions {
action, ok := recordActions[name]
if !ok {
return bootErr(pluginID, id, formFile, fmt.Errorf("recordActions: unsupported action %s (want a record action the controller registers)", name))
}
if strings.TrimSpace(action.Label) == "" {
return bootErr(pluginID, id, formFile, fmt.Errorf("recordActions: action %s needs a label", name))
}
}
file := cc.Form.fieldsPath
if file == "" {
file = "fields.yaml"
@@ -308,3 +328,29 @@ func compileBulkActions(ctl pact.AdminController) (map[string]pact.AdminBulkActi
}
return out, nil
}
// compileRecordActions collects a controller's registered record actions into
// their own namespace; create and delete stay reserved.
func compileRecordActions(ctl pact.AdminController) (map[string]pact.AdminRecordAction, error) {
out := map[string]pact.AdminRecordAction{}
src, ok := ctl.(pact.HasAdminRecordActions)
if !ok || src == nil {
return out, nil
}
for _, action := range src.AdminRecordActions() {
if !identifier(action.Name) {
return nil, fmt.Errorf("record action name %q is not an identifier", action.Name)
}
if builtinToolbarActions[action.Name] {
return nil, fmt.Errorf("record action %s uses a reserved built-in name (create, delete)", action.Name)
}
if _, dup := out[action.Name]; dup {
return nil, fmt.Errorf("duplicate record action %s", action.Name)
}
if action.Run == nil {
return nil, fmt.Errorf("record action %s has no Run function", action.Name)
}
out[action.Name] = action
}
return out, nil
}