- The admin refuses a parent from another menu than the one the form names (errors.parent_menu) instead of moving the entry to the parent's menu without a word; BeforeSave records the named menu (MenuEntry.RequestedMenuID). - An entry moved to another menu moves its descendants in the same transaction, so the public tree no longer drops them.
265 lines
9.3 KiB
Go
265 lines
9.3 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"
|
|
errParentMenu = "golem15.menu::lang.errors.parent_menu"
|
|
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, then moves the entry's descendants into its menu, so a
|
|
// subtree follows its root to another menu instead of staying behind with
|
|
// parents in a menu it is not in.
|
|
func (menuEntriesAdminController) FormAfterUpdate(ctx context.Context, model any) error {
|
|
if err := checkPlacement(ctx, model); err != nil {
|
|
return err
|
|
}
|
|
entry, ok := model.(*models.MenuEntry)
|
|
if !ok || entry == nil || entry.ID == 0 {
|
|
return nil
|
|
}
|
|
tx, ok := cabana.TxFromContext(ctx)
|
|
if !ok {
|
|
return errors.New("golem15.menu: the entry check needs the save's transaction")
|
|
}
|
|
return tx.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Exec(`WITH RECURSIVE sub(id) AS (
|
|
SELECT id FROM golem15_menu_menu_entries WHERE parent_id = ? AND id <> ?
|
|
UNION
|
|
SELECT e.id FROM golem15_menu_menu_entries e JOIN sub ON e.parent_id = sub.id
|
|
)
|
|
UPDATE golem15_menu_menu_entries SET menu_id = ? WHERE id IN (SELECT id FROM sub) AND id <> ? AND menu_id <> ?`,
|
|
entry.ID, entry.ID, entry.MenuID, entry.ID, entry.MenuID).Error
|
|
}
|
|
|
|
// checkPlacement refuses an entry in no menu, a parent from another menu
|
|
// than the one the entry names (BeforeSave would otherwise move the entry to
|
|
// the parent's menu without a word) and a parent cycle.
|
|
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.RequestedMenuID() != 0 {
|
|
return &cabana.ValidationError{Details: map[string]any{"parent": []string{errParentMenu}}}
|
|
}
|
|
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{}
|
|
)
|