feat(10.1-01): serve controller JS/CSS and run registered toolbar actions

- boardwalk exports ContentType and SetSecurityHeaders
- pact.AdminClientAssets files are read and hashed at boot and served by exact
  key under {prefix}/assets/{vendor}/{plugin}/ with nosniff, CSP, CORP,
  no-cache and an ETag; a miss falls through to the SPA
- list and form schemas carry assets URLs with a ?v= hash
- toolbar.buttons resolves create, delete and registered actions after decode;
  toolbarActions is permission-filtered per admin
- POST .../toolbar/{action} behind requireAjax and action permissions
This commit is contained in:
Jakub Zych
2026-09-28 23:41:17 +02:00
parent f9281949a6
commit 8b1cb244de
23 changed files with 772 additions and 64 deletions

View File

@@ -125,7 +125,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("unsupported search mode %s", mode))
}
}
buttons, err := compileToolbarButtons(doc.Toolbar, doc.ShowCheckboxes)
buttons, toolbarActions, err := compileToolbarButtons(ctl, doc.Toolbar, doc.ShowCheckboxes)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
@@ -171,6 +171,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
SearchPrompt: prompt,
DefaultSort: sort,
ToolbarButtons: buttons,
ToolbarActions: toolbarActions,
Columns: columns,
Filters: filters,
RowActions: rowActions,
@@ -278,16 +279,15 @@ func compilePageOptions(recordsPerPage int, declared []int) ([]int, error) {
return options, nil
}
// toolbarButtons is the declarative toolbar.buttons list (D-14): built-in
// actions in display order. A scalar (Winter's partial name) is rejected
// with a pointer at the list syntax.
// toolbarButtons is the declarative toolbar.buttons list (D-14, D-12):
// action names in display order. The built-in create and delete sit next to
// names the controller registers through pact.HasAdminActions; decode has no
// controller, so membership is resolved in compileToolbarButtons. A scalar
// (Winter's partial name) is rejected with a pointer at the list syntax.
type toolbarButtons struct {
items []string
}
// toolbarActions are the built-in toolbar actions; custom actions are Phase 10.1.
var toolbarActions = map[string]struct{}{"create": {}, "delete": {}}
func (b *toolbarButtons) UnmarshalYAML(node ast.Node) error {
node = unwrapNode(node)
switch n := node.(type) {
@@ -301,10 +301,7 @@ func (b *toolbarButtons) UnmarshalYAML(node ast.Node) error {
for _, item := range values {
action, err := nodeString(unwrapNode(item))
if err != nil {
return fmt.Errorf("toolbar.buttons entries must be action names (create, delete)")
}
if _, ok := toolbarActions[action]; !ok {
return fmt.Errorf("toolbar.buttons: unsupported action %s (want create or delete)", action)
return fmt.Errorf("toolbar.buttons entries must be action names")
}
if _, dup := seen[action]; dup {
return fmt.Errorf("toolbar.buttons: duplicate action %s", action)
@@ -315,21 +312,43 @@ func (b *toolbarButtons) UnmarshalYAML(node ast.Node) error {
b.items = items
return nil
default:
return fmt.Errorf("toolbar.buttons must be a list of actions (create, delete); the Winter partial %q is not supported", nodeText(node))
return fmt.Errorf("toolbar.buttons must be a list of actions (create, delete or registered actions); the Winter partial %q is not supported", nodeText(node))
}
}
func compileToolbarButtons(toolbar *listToolbar, showCheckboxes bool) ([]string, error) {
// compileToolbarButtons resolves every toolbar.buttons name against the
// built-in create and delete and the controller's registered actions: the one
// action namespace widgets use too. It returns the names in declared order and
// the registered entries with their (source) labels.
func compileToolbarButtons(ctl pact.AdminController, toolbar *listToolbar, showCheckboxes bool) ([]string, []ToolbarAction, error) {
if toolbar == nil || len(toolbar.Buttons.items) == 0 {
return []string{}, nil
return []string{}, []ToolbarAction{}, nil
}
out := append([]string(nil), toolbar.Buttons.items...)
for _, action := range out {
if action == "delete" && !showCheckboxes {
return nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true")
registered := map[string]pact.AdminAction{}
if src, ok := ctl.(pact.HasAdminActions); ok && src != nil {
for _, action := range src.AdminActions() {
registered[action.Name] = action
}
}
return out, nil
out := append([]string(nil), toolbar.Buttons.items...)
custom := []ToolbarAction{}
for _, name := range out {
if builtinToolbarActions[name] {
if name == "delete" && !showCheckboxes {
return nil, nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true")
}
continue
}
action, ok := registered[name]
if !ok {
return nil, nil, fmt.Errorf("toolbar.buttons: unsupported action %s (want create, delete or an action the controller registers)", name)
}
if strings.TrimSpace(action.Label) == "" {
return nil, nil, fmt.Errorf("toolbar.buttons: action %s needs a label", name)
}
custom = append(custom, ToolbarAction{Name: name, Label: action.Label})
}
return out, custom, nil
}
func compileDefaultSort(doc *listSortDocument, columns []ListColumn) (*ListSort, error) {
@@ -433,6 +452,15 @@ func (s *ListSchema) Localize(ctx context.Context, tr *phrasebook.Translator) (*
out.SearchPrompt = translateKey(ctx, tr, s.SearchPrompt)
out.PerPageOptions = append([]int(nil), s.PerPageOptions...)
out.ToolbarButtons = append([]string(nil), s.ToolbarButtons...)
out.ToolbarActions = make([]ToolbarAction, len(s.ToolbarActions))
for i, action := range s.ToolbarActions {
action.Label = translateKey(ctx, tr, action.Label)
out.ToolbarActions[i] = action
}
out.Assets = ControllerAssets{
Scripts: append([]string{}, s.Assets.Scripts...),
Styles: append([]string{}, s.Assets.Styles...),
}
if s.DefaultSort != nil {
sort := *s.DefaultSort
out.DefaultSort = &sort