feat(17-04): menu plugin admin with registry types, URL checks and tree reorder
- golem15.menu plugin, tables golem15_menu_menus and golem15_menu_menu_entries with the PHP columns plus a nullable url_pattern (D-02) - MenuEntry BeforeSave ports PHP beforeSave: nest columns 0, menu from the parent, sort_order after the menu's last - entry type dropdown: url, divider and the signpost registry types; reference depends on type through signpost.ReferenceOptions - URL scheme allowlist (errors.url_scheme), target _self or _blank, menu and parent cycle checks - menu filter, toolbar reorder/create/delete and config_reorder.yaml partitioned by menu - navigation Menus with Menus and Menu entries, permission golem15.menu.manage_menu, en and pl phrasebooks - TestMenuAdminSmoke
This commit is contained in:
21
controllers/admin_registry.go
Normal file
21
controllers/admin_registry.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Package controllers holds the menu plugin's admin controllers.
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// PermissionManageMenu is the PHP permission golem15.menu.manage_menu; both
|
||||
// screens require it.
|
||||
const PermissionManageMenu = "golem15.menu.manage_menu"
|
||||
|
||||
// AdminControllers is the plugin's admin controller list. app returns the
|
||||
// booted application; it is asked on every call, so a controller never keeps
|
||||
// a database handle or registry of its own.
|
||||
func AdminControllers(app func() *backpack.App) []pact.AdminController {
|
||||
return []pact.AdminController{
|
||||
menusAdminController{},
|
||||
menuEntriesAdminController{app: app},
|
||||
}
|
||||
}
|
||||
5
controllers/helpers.go
Normal file
5
controllers/helpers.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package controllers
|
||||
|
||||
import "strconv"
|
||||
|
||||
func uintText(id uint) string { return strconv.FormatUint(uint64(id), 10) }
|
||||
238
controllers/menuentries.go
Normal file
238
controllers/menuentries.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.golem15.com/golem15/sm-menu-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-menu-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MenuEntriesControllerID is the cabana controller id of the Menu entries
|
||||
// screen.
|
||||
const MenuEntriesControllerID = "golem15.menu.menuentries"
|
||||
|
||||
// The phrase keys of the entry form's refusals and built-in type labels.
|
||||
const (
|
||||
errURLScheme = "golem15.menu::lang.errors.url_scheme"
|
||||
errTarget = "golem15.menu::lang.errors.target"
|
||||
errTypeUnknown = "golem15.menu::lang.errors.type_unknown"
|
||||
errMenuRequired = "golem15.menu::lang.errors.menu_required"
|
||||
errParentCycle = "golem15.menu::lang.errors.parent_cycle"
|
||||
labelTypeURL = "golem15.menu::lang.models.menuentry.type_url"
|
||||
labelTypeDivide = "golem15.menu::lang.models.menuentry.type_divider"
|
||||
)
|
||||
|
||||
// menuEntriesAdminController lists and edits menu entries. Its type dropdown
|
||||
// offers the built-ins url and divider and every menu item type of the
|
||||
// registry; its reference dropdown depends on the type through
|
||||
// signpost.ReferenceOptions; config_reorder.yaml gives the tree reorder page
|
||||
// scoped to one menu.
|
||||
type menuEntriesAdminController struct {
|
||||
app func() *backpack.App
|
||||
}
|
||||
|
||||
func (menuEntriesAdminController) ID() string { return MenuEntriesControllerID }
|
||||
|
||||
func (menuEntriesAdminController) ModelName() string { return `Golem15\Menu\Models\MenuEntry` }
|
||||
|
||||
func (menuEntriesAdminController) ConfigDir() string { return "controllers/menuentries" }
|
||||
|
||||
func (menuEntriesAdminController) RequiredPermissions() []string {
|
||||
return []string{PermissionManageMenu}
|
||||
}
|
||||
|
||||
func (menuEntriesAdminController) NewRecord() any { return &models.MenuEntry{} }
|
||||
|
||||
// AdminFieldRelations binds the menu field (also the reorder partition) and
|
||||
// the parent field.
|
||||
func (menuEntriesAdminController) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||
return []cabana.FieldRelationContract{
|
||||
{Field: "menu", Kind: "belongsTo", NewRelated: func() any { return &models.Menu{} }, ForeignKey: "menu_id"},
|
||||
{Field: "parent", Kind: "belongsTo", NewRelated: func() any { return &models.MenuEntry{} }, ForeignKey: "parent_id"},
|
||||
}
|
||||
}
|
||||
|
||||
// RelationExtendOptionsQuery orders the menu choices (and the reorder
|
||||
// partitions) by name and the parent choices by title.
|
||||
func (menuEntriesAdminController) RelationExtendOptionsQuery(_ context.Context, field string, db *gorm.DB) *gorm.DB {
|
||||
switch field {
|
||||
case "menu":
|
||||
return db.Order("name ASC").Order("id ASC")
|
||||
case "parent":
|
||||
return db.Order("title ASC").Order("id ASC")
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func (c menuEntriesAdminController) registry() *signpost.Registry {
|
||||
if c.app == nil {
|
||||
return nil
|
||||
}
|
||||
app := c.app()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
reg, _ := app.Lookup[*signpost.Registry]()
|
||||
return reg
|
||||
}
|
||||
|
||||
func (c menuEntriesAdminController) pool() *gorm.DB {
|
||||
if c.app == nil {
|
||||
return nil
|
||||
}
|
||||
app := c.app()
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
db, _ := app.Lookup[*gorm.DB]()
|
||||
return db
|
||||
}
|
||||
|
||||
// DropdownOptions serves the type dropdown: url (Link) and divider
|
||||
// (Divider), then every registry type in registry order with its owning
|
||||
// plugin's label (D-05).
|
||||
func (c menuEntriesAdminController) DropdownOptions(string) []pact.Option {
|
||||
out := []pact.Option{
|
||||
{Value: models.TypeURL, Label: labelTypeURL},
|
||||
{Value: models.TypeDivider, Label: labelTypeDivide},
|
||||
}
|
||||
for _, t := range c.registry().Types() {
|
||||
out = append(out, pact.Option{Value: t.Key, Label: t.Label})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DependentOptions serves the reference dropdown: url, divider and an
|
||||
// unknown type take no reference (Applies false); a registry type offers its
|
||||
// references through signpost.ReferenceOptions (D-08).
|
||||
func (c menuEntriesAdminController) DependentOptions(ctx context.Context, _ string, values map[string]any) (pact.DependentOptionSet, error) {
|
||||
typ, _ := values["type"].(string)
|
||||
if typ == "" || models.Builtin(typ) {
|
||||
return pact.DependentOptionSet{}, nil
|
||||
}
|
||||
return signpost.ReferenceOptions(ctx, c.registry(), typ)
|
||||
}
|
||||
|
||||
// FilterOptions serves the menu filter's choices: every menu by name.
|
||||
func (c menuEntriesAdminController) FilterOptions(scope string) []pact.Option {
|
||||
if scope != models.FilterMenu {
|
||||
return nil
|
||||
}
|
||||
db := c.pool()
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
var menus []models.Menu
|
||||
if err := db.Session(&gorm.Session{NewDB: true}).Order("name ASC, id ASC").Find(&menus).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]pact.Option, 0, len(menus))
|
||||
for _, m := range menus {
|
||||
out = append(out, pact.Option{Value: uintText(m.ID), Label: m.Name})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FormBeforeCreate checks the type, the URL scheme and the target.
|
||||
func (c menuEntriesAdminController) FormBeforeCreate(_ context.Context, model any) error {
|
||||
return c.checkEntry(model)
|
||||
}
|
||||
|
||||
// FormBeforeUpdate applies the create checks to an update.
|
||||
func (c menuEntriesAdminController) FormBeforeUpdate(_ context.Context, model any) error {
|
||||
return c.checkEntry(model)
|
||||
}
|
||||
|
||||
// checkEntry refuses an unknown type, a URL outside the scheme allowlist
|
||||
// (errors.url_scheme, Pitfall 15) and a target other than _self or _blank.
|
||||
func (c menuEntriesAdminController) checkEntry(model any) error {
|
||||
entry, ok := model.(*models.MenuEntry)
|
||||
if !ok || entry == nil {
|
||||
return nil
|
||||
}
|
||||
details := map[string]any{}
|
||||
if entry.Type != "" && !models.Builtin(entry.Type) {
|
||||
if _, known := c.registry().Type(entry.Type); !known {
|
||||
details["type"] = []string{errTypeUnknown}
|
||||
}
|
||||
}
|
||||
if !classes.AllowedURL(models.Text(entry.URL)) {
|
||||
details["url"] = []string{errURLScheme}
|
||||
}
|
||||
if !classes.AllowedTarget(models.Text(entry.Target)) {
|
||||
details["target"] = []string{errTarget}
|
||||
}
|
||||
if len(details) > 0 {
|
||||
return &cabana.ValidationError{Details: details}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormAfterCreate refuses an entry that ends up in no menu (no menu chosen
|
||||
// and no parent to take it from). cabana writes the menu and parent from the
|
||||
// relation fields after the model rules ran, so the check runs once the row
|
||||
// is written, inside the save's transaction.
|
||||
func (menuEntriesAdminController) FormAfterCreate(ctx context.Context, model any) error {
|
||||
return checkPlacement(ctx, model)
|
||||
}
|
||||
|
||||
// FormAfterUpdate also refuses a parent that is the entry itself or one of
|
||||
// its descendants.
|
||||
func (menuEntriesAdminController) FormAfterUpdate(ctx context.Context, model any) error {
|
||||
return checkPlacement(ctx, model)
|
||||
}
|
||||
|
||||
func checkPlacement(ctx context.Context, model any) error {
|
||||
entry, ok := model.(*models.MenuEntry)
|
||||
if !ok || entry == nil {
|
||||
return nil
|
||||
}
|
||||
if entry.MenuID == 0 {
|
||||
return &cabana.ValidationError{Details: map[string]any{"menu": []string{errMenuRequired}}}
|
||||
}
|
||||
if entry.ParentID == nil {
|
||||
return nil
|
||||
}
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return errors.New("golem15.menu: the entry check needs the save's transaction")
|
||||
}
|
||||
db := tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx)
|
||||
seen := map[uint]bool{}
|
||||
for id := *entry.ParentID; id != 0; {
|
||||
if id == entry.ID || seen[id] {
|
||||
return &cabana.ValidationError{Details: map[string]any{"parent": []string{errParentCycle}}}
|
||||
}
|
||||
seen[id] = true
|
||||
var rows []models.MenuEntry
|
||||
if err := db.Select("id", "parent_id").Where("id = ?", id).Limit(1).Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 || rows[0].ParentID == nil {
|
||||
break
|
||||
}
|
||||
id = *rows[0].ParentID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ pact.AdminController = menuEntriesAdminController{}
|
||||
_ pact.AdminPermissioned = menuEntriesAdminController{}
|
||||
_ pact.AdminRecordSource = menuEntriesAdminController{}
|
||||
_ pact.DropdownOptionsProvider = menuEntriesAdminController{}
|
||||
_ pact.DependentOptionsProvider = menuEntriesAdminController{}
|
||||
_ pact.FilterOptions = menuEntriesAdminController{}
|
||||
_ pact.FormBeforeCreate = menuEntriesAdminController{}
|
||||
_ pact.FormBeforeUpdate = menuEntriesAdminController{}
|
||||
_ pact.FormAfterCreate = menuEntriesAdminController{}
|
||||
_ pact.FormAfterUpdate = menuEntriesAdminController{}
|
||||
_ pact.RelationExtendOptionsQuery = menuEntriesAdminController{}
|
||||
_ cabana.FieldRelationProvider = menuEntriesAdminController{}
|
||||
)
|
||||
14
controllers/menuentries/config_filter.yaml
Normal file
14
controllers/menuentries/config_filter.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
# ===================================
|
||||
# Filter Scope Definitions
|
||||
# ===================================
|
||||
#
|
||||
# The menu filter replaces the PHP toolbar's menu select. Its name is the
|
||||
# reorder partition column, so the list's Reorder button opens the filtered
|
||||
# menu.
|
||||
|
||||
scopes:
|
||||
menu_id:
|
||||
label: golem15.menu::lang.models.menuentry.menu
|
||||
modelClass: Golem15\Menu\Models\Menu
|
||||
nameFrom: name
|
||||
scope: FilterMenu
|
||||
25
controllers/menuentries/config_form.yaml
Normal file
25
controllers/menuentries/config_form.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# ===================================
|
||||
# Form Behavior Config
|
||||
# ===================================
|
||||
|
||||
# Record name
|
||||
name: golem15.menu::lang.models.menuentry.label
|
||||
|
||||
# Model Form Field configuration
|
||||
form: ~/plugins/golem15/menu/models/menuentry/fields.yaml
|
||||
|
||||
# Model Class name
|
||||
modelClass: Golem15\Menu\Models\MenuEntry
|
||||
|
||||
# Default redirect location
|
||||
defaultRedirect: golem15/menu/menuentries
|
||||
|
||||
# Create page
|
||||
create:
|
||||
redirect: golem15/menu/menuentries/update/:id
|
||||
redirectClose: golem15/menu/menuentries
|
||||
|
||||
# Update page
|
||||
update:
|
||||
redirect: golem15/menu/menuentries
|
||||
redirectClose: golem15/menu/menuentries
|
||||
46
controllers/menuentries/config_list.yaml
Normal file
46
controllers/menuentries/config_list.yaml
Normal file
@@ -0,0 +1,46 @@
|
||||
# ===================================
|
||||
# List Behavior Config
|
||||
# ===================================
|
||||
#
|
||||
# The PHP list showed the top-level entries of one menu picked in a toolbar
|
||||
# select; here every entry is listed, the menu filter picks the menu, and
|
||||
# the reorder page edits the tree.
|
||||
|
||||
# Model List Column configuration
|
||||
list: ~/plugins/golem15/menu/models/menuentry/columns.yaml
|
||||
|
||||
# Model Class name
|
||||
modelClass: Golem15\Menu\Models\MenuEntry
|
||||
|
||||
# List Title
|
||||
title: golem15.menu::lang.models.menuentry.label_plural
|
||||
|
||||
# Link URL for each record
|
||||
recordUrl: golem15/menu/menuentries/update/:id
|
||||
|
||||
# Message to display if the list is empty
|
||||
noRecordsMessage: backend::lang.list.no_records
|
||||
|
||||
# Records to display per page
|
||||
recordsPerPage: 20
|
||||
|
||||
# Options to provide the user when selecting how many records to display per page
|
||||
perPageOptions: [20, 40, 80, 100, 120]
|
||||
|
||||
# Displays the list column set up button
|
||||
showSetup: true
|
||||
|
||||
# Displays the sorting link on each column
|
||||
showSorting: true
|
||||
|
||||
# Display checkboxes next to each record
|
||||
showCheckboxes: true
|
||||
|
||||
# The menu filter (config_filter.yaml)
|
||||
filter: config_filter.yaml
|
||||
|
||||
# Toolbar: reorder (config_reorder.yaml), create and delete selected
|
||||
toolbar:
|
||||
buttons: [reorder, create, delete]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
27
controllers/menuentries/config_reorder.yaml
Normal file
27
controllers/menuentries/config_reorder.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
# ===================================
|
||||
# Reorder Behavior Config
|
||||
# ===================================
|
||||
#
|
||||
# The PHP reorder_config.yaml, scoped to one menu through the partition: the
|
||||
# page lists the entries of one menu as a tree and saves parent_id and
|
||||
# sort_order. The PHP toolbar partial is not supported.
|
||||
|
||||
# Reorder Title
|
||||
title: golem15.menu::lang.models.menuentry.reorder_title
|
||||
|
||||
# Attribute name
|
||||
nameFrom: title
|
||||
|
||||
# Model Class name
|
||||
modelClass: Golem15\Menu\Models\MenuEntry
|
||||
|
||||
# Tree columns
|
||||
parentColumn: parent_id
|
||||
sortColumn: sort_order
|
||||
|
||||
# One menu at a time
|
||||
partition:
|
||||
column: menu_id
|
||||
relation: menu
|
||||
nameFrom: name
|
||||
label: golem15.menu::lang.models.menuentry.menu
|
||||
31
controllers/menus.go
Normal file
31
controllers/menus.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"git.golem15.com/golem15/sm-menu-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// MenusControllerID is the cabana controller id of the Menus screen.
|
||||
const MenusControllerID = "golem15.menu.menus"
|
||||
|
||||
// menusAdminController lists and edits menus: name, code, CSS class and the
|
||||
// active switch.
|
||||
type menusAdminController struct{}
|
||||
|
||||
func (menusAdminController) ID() string { return MenusControllerID }
|
||||
|
||||
func (menusAdminController) ModelName() string { return `Golem15\Menu\Models\Menu` }
|
||||
|
||||
func (menusAdminController) ConfigDir() string { return "controllers/menus" }
|
||||
|
||||
func (menusAdminController) RequiredPermissions() []string {
|
||||
return []string{PermissionManageMenu}
|
||||
}
|
||||
|
||||
func (menusAdminController) NewRecord() any { return &models.Menu{} }
|
||||
|
||||
var (
|
||||
_ pact.AdminController = menusAdminController{}
|
||||
_ pact.AdminPermissioned = menusAdminController{}
|
||||
_ pact.AdminRecordSource = menusAdminController{}
|
||||
)
|
||||
25
controllers/menus/config_form.yaml
Normal file
25
controllers/menus/config_form.yaml
Normal file
@@ -0,0 +1,25 @@
|
||||
# ===================================
|
||||
# Form Behavior Config
|
||||
# ===================================
|
||||
|
||||
# Record name
|
||||
name: golem15.menu::lang.models.menu.label
|
||||
|
||||
# Model Form Field configuration
|
||||
form: ~/plugins/golem15/menu/models/menu/fields.yaml
|
||||
|
||||
# Model Class name
|
||||
modelClass: Golem15\Menu\Models\Menu
|
||||
|
||||
# Default redirect location
|
||||
defaultRedirect: golem15/menu/menus
|
||||
|
||||
# Create page
|
||||
create:
|
||||
redirect: golem15/menu/menus/update/:id
|
||||
redirectClose: golem15/menu/menus
|
||||
|
||||
# Update page
|
||||
update:
|
||||
redirect: golem15/menu/menus
|
||||
redirectClose: golem15/menu/menus
|
||||
39
controllers/menus/config_list.yaml
Normal file
39
controllers/menus/config_list.yaml
Normal file
@@ -0,0 +1,39 @@
|
||||
# ===================================
|
||||
# List Behavior Config
|
||||
# ===================================
|
||||
|
||||
# Model List Column configuration
|
||||
list: ~/plugins/golem15/menu/models/menu/columns.yaml
|
||||
|
||||
# Model Class name
|
||||
modelClass: Golem15\Menu\Models\Menu
|
||||
|
||||
# List Title
|
||||
title: golem15.menu::lang.models.menu.label_plural
|
||||
|
||||
# Link URL for each record
|
||||
recordUrl: golem15/menu/menus/update/:id
|
||||
|
||||
# Message to display if the list is empty
|
||||
noRecordsMessage: backend::lang.list.no_records
|
||||
|
||||
# Records to display per page
|
||||
recordsPerPage: 20
|
||||
|
||||
# Options to provide the user when selecting how many records to display per page
|
||||
perPageOptions: [20, 40, 80, 100, 120]
|
||||
|
||||
# Displays the list column set up button
|
||||
showSetup: true
|
||||
|
||||
# Displays the sorting link on each column
|
||||
showSorting: true
|
||||
|
||||
# Display checkboxes next to each record
|
||||
showCheckboxes: true
|
||||
|
||||
# Toolbar: the PHP list_toolbar partial held create and delete selected
|
||||
toolbar:
|
||||
buttons: [create, delete]
|
||||
search:
|
||||
prompt: backend::lang.list.search_prompt
|
||||
Reference in New Issue
Block a user