Files
sm-menu-plugin/models/menu_entry.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

171 lines
5.0 KiB
Go

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
}