- 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
239 lines
8.1 KiB
Go
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{}
|
|
)
|