Files
sm-menu-plugin/controllers/menuentries.go
Jakub Zych 08f99b8aad 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
2026-10-12 17:30:50 +02:00

239 lines
8.1 KiB
Go

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{}
)