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:
2026-10-12 17:30:50 +02:00
commit 08f99b8aad
34 changed files with 2197 additions and 0 deletions

38
models/menu.go Normal file
View File

@@ -0,0 +1,38 @@
package models
import "time"
// Menu is a named menu stored on golem15_menu_menus, the PHP
// Golem15\Menu\Models\Menu. Sites address a menu by its unique code.
type Menu struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Code string `gorm:"column:code"`
IsActive bool `gorm:"column:is_active"`
CSSClass *string `gorm:"column:css_class"`
Entries []MenuEntry `gorm:"foreignKey:MenuID" json:"-"`
CreatedAt *time.Time `gorm:"column:created_at"`
UpdatedAt *time.Time `gorm:"column:updated_at"`
}
func (Menu) TableName() string { return "golem15_menu_menus" }
// MorphName is the PHP class string of the model.
func (Menu) MorphName() string { return `Golem15\Menu\Models\Menu` }
// Fillable is the list an admin save may set.
func (Menu) Fillable() []string {
return []string{"name", "code", "is_active", "css_class"}
}
// Rules require a name and a code that no other menu uses.
func (Menu) Rules() map[string]string {
return map[string]string{
"name": "required",
"code": "required|unique:golem15_menu_menus",
}
}
func init() {
Register(Menu{})
}

26
models/menu/columns.yaml Normal file
View File

@@ -0,0 +1,26 @@
# ===================================
# List Column Definitions
# ===================================
columns:
id:
label: golem15.menu::lang.models.general.id
searchable: true
name:
label: golem15.menu::lang.models.menu.name
searchable: true
code:
label: golem15.menu::lang.models.menu.code
searchable: true
is_active:
label: golem15.menu::lang.models.menu.is_active
type: switch
created_at:
label: golem15.menu::lang.models.general.created_at
type: datetime
sortable: true
invisible: true
updated_at:
label: golem15.menu::lang.models.general.updated_at
type: datetime
sortable: true

25
models/menu/fields.yaml Normal file
View File

@@ -0,0 +1,25 @@
# ===================================
# Form Field Definitions
# ===================================
fields:
name:
label: golem15.menu::lang.models.menu.name
type: text
span: left
required: true
code:
label: golem15.menu::lang.models.menu.code
type: text
span: right
required: true
preset: name
css_class:
label: golem15.menu::lang.models.menu.css_class
type: text
span: left
is_active:
label: golem15.menu::lang.models.menu.is_active
type: switch
span: right
default: true

170
models/menu_entry.go Normal file
View 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
}

View File

@@ -0,0 +1,34 @@
# ===================================
# List Column Definitions
# ===================================
columns:
id:
label: golem15.menu::lang.models.general.id
searchable: true
menu:
label: golem15.menu::lang.models.menuentry.menu
searchable: true
relation: menu
select: name
title:
label: golem15.menu::lang.models.menuentry.title
searchable: true
type:
label: golem15.menu::lang.models.menuentry.type
searchable: true
url:
label: golem15.menu::lang.models.menuentry.url
searchable: true
is_published:
label: golem15.menu::lang.models.menuentry.is_published
type: switch
created_at:
label: golem15.menu::lang.models.general.created_at
type: datetime
sortable: true
invisible: true
updated_at:
label: golem15.menu::lang.models.general.updated_at
type: datetime
sortable: true

View File

@@ -0,0 +1,77 @@
# ===================================
# Form Field Definitions
# ===================================
#
# Adapted from the WinterCMS form: the "Menu Entry Options" tab is flattened,
# the "Children" tab and partial are gone (the reorder page replaces them),
# menu is a relation field (it is also the reorder partition), the type
# options are the built-ins plus the registry types, and reference,
# url_pattern and target are new.
fields:
menu:
label: golem15.menu::lang.models.menuentry.menu
comment: golem15.menu::lang.models.menuentry.menu_comment
type: relation
nameFrom: name
span: full
parent:
label: golem15.menu::lang.models.menuentry.parent
type: relation
nameFrom: title
emptyOption: backend::lang.form.none
span: left
type:
label: golem15.menu::lang.models.menuentry.type
type: dropdown
options: getTypeOptions
default: url
required: true
span: right
title:
label: golem15.menu::lang.models.menuentry.title
type: text
span: left
url:
label: golem15.menu::lang.models.menuentry.url
comment: golem15.menu::lang.models.menuentry.url_comment
type: text
span: right
reference:
label: golem15.menu::lang.models.menuentry.reference
comment: golem15.menu::lang.models.menuentry.reference_comment
type: dropdown
dependsOn: type
emptyOption: backend::lang.form.none
span: left
url_pattern:
label: golem15.menu::lang.models.menuentry.url_pattern
comment: golem15.menu::lang.models.menuentry.url_pattern_comment
type: text
span: right
code:
label: golem15.menu::lang.models.menuentry.code
type: text
span: left
icon:
label: golem15.menu::lang.models.menuentry.icon
type: text
span: right
css_class:
label: golem15.menu::lang.models.menuentry.css_class
type: text
span: left
target:
label: golem15.menu::lang.models.menuentry.target
type: dropdown
default: _self
span: right
options:
_self: golem15.menu::lang.models.menuentry.target_self
_blank: golem15.menu::lang.models.menuentry.target_blank
is_published:
label: golem15.menu::lang.models.menuentry.is_published
type: switch
default: true
context: update
span: left

15
models/registry.go Normal file
View File

@@ -0,0 +1,15 @@
// Package models holds the menu plugin's GORM models.
package models
var all []any
// Register appends GORM models so later files add models without editing
// plugin.go.
func Register(models ...any) {
all = append(all, models...)
}
// All returns every model registered by this package.
func All() []any {
return all
}