feat(10.1-01): run registered widget actions through a cabana-owned route
- pact: AdminClientAssets, AdminAction, AdminActionInput, AdminActionResult,
HasAdminActions and AdminPartialData contracts
- fields.yaml type: widget with widget, action and fill keys; boot checks the
plugin tag prefix, the registered action and writable scalar fill fields
- POST .../widgets/{field} behind requireAjax, controller and action
permissions, scoped non-locking record read and a server-side fill filter
- typed OpenAPI operation, inventories and an acme conformance case
This commit is contained in:
127
modules/cabana/extension.go
Normal file
127
modules/cabana/extension.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// widgetTagPattern is a valid custom-element name restricted to lowercase
|
||||
// ASCII: a letter, then at least one hyphenated segment.
|
||||
var widgetTagPattern = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)+$`)
|
||||
|
||||
// reservedWidgetTags are the hyphenated names the HTML specification reserves;
|
||||
// customElements.define refuses them.
|
||||
var reservedWidgetTags = map[string]bool{
|
||||
"annotation-xml": true, "color-profile": true, "font-face": true, "font-face-src": true,
|
||||
"font-face-uri": true, "font-face-format": true, "font-face-name": true, "missing-glyph": true,
|
||||
}
|
||||
|
||||
// builtinToolbarActions are the toolbar actions the framework implements
|
||||
// itself (D-14); a controller may not register an action with these names.
|
||||
var builtinToolbarActions = map[string]bool{"create": true, "delete": true}
|
||||
|
||||
// widgetTagPrefix is the custom-element prefix a plugin's widgets must use:
|
||||
// the plugin ID lowercased with dots and underscores turned into hyphens,
|
||||
// plus a trailing hyphen (acme.conform -> "acme-conform-"). It keeps two
|
||||
// plugins from defining the same element.
|
||||
func widgetTagPrefix(pluginID string) string {
|
||||
return strings.NewReplacer(".", "-", "_", "-").Replace(strings.ToLower(pluginID)) + "-"
|
||||
}
|
||||
|
||||
// compileExtension validates a controller's runtime admin extension points
|
||||
// after its list and form are compiled and its writable fields are bound: the
|
||||
// registered actions and every `type: widget` field. Every failure stops boot.
|
||||
func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error {
|
||||
if cc == nil || cc.Controller == nil {
|
||||
return nil
|
||||
}
|
||||
id := cc.Controller.ID()
|
||||
actions, err := compileActions(cc.Controller)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err)
|
||||
}
|
||||
cc.Actions = actions
|
||||
if cc.Form == nil {
|
||||
return nil
|
||||
}
|
||||
file := cc.Form.fieldsPath
|
||||
if file == "" {
|
||||
file = "fields.yaml"
|
||||
}
|
||||
fields := map[string]FormField{}
|
||||
for _, field := range cc.Form.Fields {
|
||||
fields[field.Name] = field
|
||||
}
|
||||
writable := map[string]bool{}
|
||||
for _, field := range cc.Writable {
|
||||
writable[field.Name] = true
|
||||
}
|
||||
prefix := widgetTagPrefix(pluginID)
|
||||
for i := range cc.Form.Fields {
|
||||
field := &cc.Form.Fields[i]
|
||||
if field.Type != "widget" {
|
||||
continue
|
||||
}
|
||||
if err := checkWidgetTag(field.Widget, prefix); err != nil {
|
||||
return bootErr(pluginID, id, file, fmt.Errorf("field %s: %w", field.Name, err))
|
||||
}
|
||||
action, ok := actions[field.Action]
|
||||
if !ok {
|
||||
return bootErr(pluginID, id, file, fmt.Errorf("field %s: action %s is not registered by the controller (pact.HasAdminActions)", field.Name, field.Action))
|
||||
}
|
||||
for _, key := range field.Fill {
|
||||
target, exists := fields[key]
|
||||
if !exists {
|
||||
return bootErr(pluginID, id, file, fmt.Errorf("field %s: fill %s is not a field of this form", field.Name, key))
|
||||
}
|
||||
if !scalarFormField(target.Type) || !writable[key] {
|
||||
return bootErr(pluginID, id, file, fmt.Errorf("field %s: fill %s is not a writable scalar field", field.Name, key))
|
||||
}
|
||||
}
|
||||
field.ActionLabel = action.Label
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkWidgetTag(tag, prefix string) error {
|
||||
if !widgetTagPattern.MatchString(tag) {
|
||||
return fmt.Errorf("widget %q is not a valid custom-element name (lowercase, with a hyphen)", tag)
|
||||
}
|
||||
if reservedWidgetTags[tag] {
|
||||
return fmt.Errorf("widget %q is a reserved element name", tag)
|
||||
}
|
||||
if !strings.HasPrefix(tag, prefix) {
|
||||
return fmt.Errorf("widget %q must start with the plugin prefix %q", tag, prefix)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compileActions collects a controller's registered actions into the single
|
||||
// action namespace (assumption-delta decision: create and delete are reserved).
|
||||
func compileActions(ctl pact.AdminController) (map[string]pact.AdminAction, error) {
|
||||
out := map[string]pact.AdminAction{}
|
||||
src, ok := ctl.(pact.HasAdminActions)
|
||||
if !ok || src == nil {
|
||||
return out, nil
|
||||
}
|
||||
for _, action := range src.AdminActions() {
|
||||
if !identifier(action.Name) {
|
||||
return nil, fmt.Errorf("action name %q is not an identifier", action.Name)
|
||||
}
|
||||
if builtinToolbarActions[action.Name] {
|
||||
return nil, fmt.Errorf("action %s uses a reserved built-in name (create, delete)", action.Name)
|
||||
}
|
||||
if _, dup := out[action.Name]; dup {
|
||||
return nil, fmt.Errorf("duplicate action %s", action.Name)
|
||||
}
|
||||
if action.Run == nil {
|
||||
return nil, fmt.Errorf("action %s has no Run function", action.Name)
|
||||
}
|
||||
out[action.Name] = action
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user