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:
170
models/menu_entry.go
Normal file
170
models/menu_entry.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The built-in entry types (D-05). Every other type is a menu item type of
|
||||
// the framework's registry (signpost). PHP stored the link type as "link";
|
||||
// the Winter import maps it to TypeURL.
|
||||
const (
|
||||
// TypeURL is a link to the entry's stored URL.
|
||||
TypeURL = "url"
|
||||
// TypeDivider is a separator with no URL.
|
||||
TypeDivider = "divider"
|
||||
)
|
||||
|
||||
// Builtin reports whether typ is a built-in entry type.
|
||||
func Builtin(typ string) bool { return typ == TypeURL || typ == TypeDivider }
|
||||
|
||||
// MenuEntry is one entry of a menu, stored on golem15_menu_menu_entries: the
|
||||
// PHP Golem15\Menu\Models\MenuEntry. Entries form a tree per menu through
|
||||
// parent_id and are ordered by sort_order within their parent. The nest
|
||||
// columns are kept for the PHP schema; nothing reads them.
|
||||
type MenuEntry struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
MenuID uint `gorm:"column:menu_id"`
|
||||
Menu *Menu `gorm:"foreignKey:MenuID" json:"-"`
|
||||
ParentID *uint `gorm:"column:parent_id"`
|
||||
Parent *MenuEntry `gorm:"foreignKey:ParentID" json:"-"`
|
||||
NestLeft *int `gorm:"column:nest_left"`
|
||||
NestRight *int `gorm:"column:nest_right"`
|
||||
NestDepth *int `gorm:"column:nest_depth"`
|
||||
SortOrder *int `gorm:"column:sort_order"`
|
||||
Type string `gorm:"column:type"`
|
||||
Title *string `gorm:"column:title"`
|
||||
URL *string `gorm:"column:url"`
|
||||
URLPattern *string `gorm:"column:url_pattern"`
|
||||
Reference *string `gorm:"column:reference"`
|
||||
Code *string `gorm:"column:code"`
|
||||
Icon *string `gorm:"column:icon"`
|
||||
CSSClass *string `gorm:"column:css_class"`
|
||||
Target *string `gorm:"column:target"`
|
||||
IsPublished *bool `gorm:"column:is_published"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
func (MenuEntry) TableName() string { return "golem15_menu_menu_entries" }
|
||||
|
||||
// MorphName is the PHP class string of the model.
|
||||
func (MenuEntry) MorphName() string { return `Golem15\Menu\Models\MenuEntry` }
|
||||
|
||||
// Fillable is the list an admin save may set. The nest columns and
|
||||
// sort_order are not: the save hook and the reorder page set them.
|
||||
func (MenuEntry) Fillable() []string {
|
||||
return []string{"menu_id", "parent_id", "type", "title", "url", "url_pattern", "reference", "code", "icon", "css_class", "target", "is_published"}
|
||||
}
|
||||
|
||||
// Rules require a type; the admin controller checks the type, URL, target,
|
||||
// menu and parent beyond that.
|
||||
func (MenuEntry) Rules() map[string]string {
|
||||
return map[string]string{"type": "required"}
|
||||
}
|
||||
|
||||
// BeforeSave ports the PHP beforeSave: the nest columns become 0 when unset,
|
||||
// an entry with a parent takes the parent's menu, and an entry without a
|
||||
// sort_order goes after the menu's last one. A missing title is stored empty
|
||||
// (dividers have none) and a missing published flag is the PHP default true.
|
||||
func (e *MenuEntry) BeforeSave(tx *gorm.DB) error {
|
||||
zero := func(p **int) {
|
||||
if *p == nil {
|
||||
v := 0
|
||||
*p = &v
|
||||
}
|
||||
}
|
||||
zero(&e.NestLeft)
|
||||
zero(&e.NestRight)
|
||||
zero(&e.NestDepth)
|
||||
if e.Title == nil {
|
||||
empty := ""
|
||||
e.Title = &empty
|
||||
}
|
||||
if e.IsPublished == nil {
|
||||
on := true
|
||||
e.IsPublished = &on
|
||||
}
|
||||
db := tx.Session(&gorm.Session{NewDB: true})
|
||||
if e.ParentID != nil && *e.ParentID != 0 {
|
||||
var menuIDs []uint
|
||||
if err := db.Model(&MenuEntry{}).Where("id = ?", *e.ParentID).Limit(1).Pluck("menu_id", &menuIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(menuIDs) > 0 && menuIDs[0] != 0 {
|
||||
e.MenuID = menuIDs[0]
|
||||
}
|
||||
}
|
||||
if e.SortOrder == nil {
|
||||
var max *int
|
||||
if err := db.Model(&MenuEntry{}).Where("menu_id = ?", e.MenuID).Select("MAX(sort_order)").Scan(&max).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
next := 1
|
||||
if max != nil {
|
||||
next = *max + 1
|
||||
}
|
||||
e.SortOrder = &next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Text is the value of an optional text column, "" when null.
|
||||
func Text(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(MenuEntry{})
|
||||
}
|
||||
|
||||
// FilterMenu is the config_filter.yaml scope of the menu filter.
|
||||
const FilterMenu = "FilterMenu"
|
||||
|
||||
// FilterScopes lists the scopes the list filters may name.
|
||||
func (MenuEntry) FilterScopes() []string { return []string{FilterMenu} }
|
||||
|
||||
// FilterScope narrows the list to the entries of the chosen menus.
|
||||
func (MenuEntry) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
|
||||
if db == nil || name != FilterMenu {
|
||||
return db
|
||||
}
|
||||
ids := filterIDs(value)
|
||||
if len(ids) == 0 {
|
||||
return db
|
||||
}
|
||||
return db.Where("menu_id IN ?", ids)
|
||||
}
|
||||
|
||||
// filterIDs reads one id or a list of ids from a filter value.
|
||||
func filterIDs(value any) []uint64 {
|
||||
var raw []any
|
||||
switch v := value.(type) {
|
||||
case []any:
|
||||
raw = v
|
||||
case []string:
|
||||
for _, s := range v {
|
||||
raw = append(raw, s)
|
||||
}
|
||||
default:
|
||||
raw = []any{v}
|
||||
}
|
||||
var out []uint64
|
||||
for _, item := range raw {
|
||||
text := strings.TrimSpace(fmt.Sprint(item))
|
||||
if item == nil || text == "" {
|
||||
continue
|
||||
}
|
||||
if id, err := strconv.ParseUint(text, 10, 32); err == nil && id > 0 {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user