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:
38
models/menu.go
Normal file
38
models/menu.go
Normal 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
26
models/menu/columns.yaml
Normal 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
25
models/menu/fields.yaml
Normal 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
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
|
||||
}
|
||||
34
models/menuentry/columns.yaml
Normal file
34
models/menuentry/columns.yaml
Normal 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
|
||||
77
models/menuentry/fields.yaml
Normal file
77
models/menuentry/fields.yaml
Normal 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
15
models/registry.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user