(menu): Añade extensión pagetop-menu

Gestión centralizada y persistente de menús: entidades y migraciones
SeaORM, sistema de acciones para declarar menús/ítems por código, caché
in-process del árbol y los componentes `MenuBlock`/`MenuBreadcrumb`.
This commit is contained in:
Manuel Cillero 2026-08-28 20:33:57 +02:00
parent 88acc08b5f
commit 043873a954
25 changed files with 1963 additions and 2 deletions

2
Cargo.lock generated
View file

@ -1884,10 +1884,8 @@ dependencies = [
"chrono",
"pagetop",
"pagetop-seaorm",
"sea-orm",
"serde",
"thiserror",
"tokio",
]
[[package]]

View file

@ -0,0 +1,19 @@
[package]
name = "pagetop-menu"
description = "Gestión centralizada y persistente de menús para PageTop."
version = "0.1.0"
categories = ["web-programming", "development-tools"]
keywords = ["pagetop", "menu", "navigation", "cms", "ssr"]
repository.workspace = true
homepage.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
pagetop.workspace = true
pagetop-seaorm.workspace = true
chrono.workspace = true
serde.workspace = true
thiserror.workspace = true

View file

@ -0,0 +1,312 @@
//! Tipos de acción que `pagetop-menu` expone para que otras extensiones extiendan el sistema.
//!
//! El flujo de `build_tree()` despacha estas acciones en este orden:
//!
//! 1. `AlterMenuTree` - filtros, reordenamientos, inyección de nodos en caliente.
//! 2. Cálculo del active trail por URL.
//! 3. `ResolveActiveTrail` - matching personalizado para rutas paramétricas.
//! 4. `DecorateMenuItem` - añadir atributos HTML a nodos individuales.
//!
//! El sembrado inicial usa `DeclareDefaultMenus` y `DeclareDefaultMenuItems`.
use pagetop::prelude::*;
use crate::repo::NewMenuItem;
use crate::tree::{MenuNode, MenuTree};
// **< Tipo de callbacks >**************************************************************************
pub type FnMenuDefs = fn(&mut MenuDefs);
pub type FnItemBag = fn(&mut ItemBag);
pub type FnAlterTree = fn(&mut MenuTree, &Context);
pub type FnDecorate = fn(&mut MenuNode, &Context);
// **< MenuDefs >***********************************************************************************
/// Bolsa de declaraciones de menú para `DeclareDefaultMenus`.
pub struct MenuDefs {
pub(crate) entries: Vec<(String, String)>,
}
impl MenuDefs {
/// Declara que el menú `machine_name` debe existir con el título dado.
/// Si ya existe en la lista, no se añade de nuevo.
pub fn ensure(&mut self, machine_name: impl Into<String>, title: impl Into<String>) {
let name = machine_name.into();
if !self.entries.iter().any(|(n, _)| n == &name) {
self.entries.push((name, title.into()));
}
}
}
// **< ItemBag >************************************************************************************
/// Bolsa de declaraciones de ítems para `DeclareDefaultMenuItems`.
pub struct ItemBag {
pub(crate) items: Vec<NewMenuItem>,
}
impl ItemBag {
/// Añade un ítem declarado por código a la bolsa.
pub fn add(&mut self, item: NewMenuItem) {
self.items.push(item);
}
}
// **< DeclareDefaultMenus >************************************************************************
/// Acción para declarar los menús que una extensión necesita.
///
/// Se despacha durante el sembrado inicial (`seed::run()`). El callback recibe un
/// [`MenuDefs`] y puede llamar a `ensure()` para declarar los menús necesarios.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop_menu::action::{DeclareDefaultMenus, MenuDefs};
///
/// fn my_menus(defs: &mut MenuDefs) {
/// defs.ensure("main", "Main navigation");
/// defs.ensure("footer", "Footer links");
/// }
/// // En Extension::actions():
/// // DeclareDefaultMenus::new(my_menus)
/// ```
pub struct DeclareDefaultMenus {
f: FnMenuDefs,
weight: Weight,
}
impl ActionDispatcher for DeclareDefaultMenus {
fn weight(&self) -> Weight {
self.weight
}
}
impl DeclareDefaultMenus {
pub fn new(f: FnMenuDefs) -> Self {
DeclareDefaultMenus { f, weight: 0 }
}
pub fn with_weight(mut self, w: Weight) -> Self {
self.weight = w;
self
}
pub(crate) fn dispatch(defs: &mut MenuDefs) {
dispatch_actions(
&ActionKey::new(UniqueId::of::<Self>(), None, None),
|action: &Self| (action.f)(defs),
);
}
}
// **< DeclareDefaultMenuItems >********************************************************************
/// Acción para declarar los ítems por defecto de un menú concreto.
///
/// El `referer_id` es el `machine_name` del menú al que pertenecen los ítems.
/// Se despacha durante el sembrado inicial (`seed::run()`) para cada menú conocido.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop_menu::action::{DeclareDefaultMenuItems, ItemBag};
/// use pagetop_menu::NewMenuItem;
///
/// fn blog_items(bag: &mut ItemBag) {
/// bag.add(NewMenuItem::new()
/// .with_provider("my-blog")
/// .with_external_key("blog.index")
/// .with_title("Blog")
/// .with_url("/blog")
/// .with_weight(10));
/// }
/// // En Extension::actions():
/// // DeclareDefaultMenuItems::new("main", blog_items)
/// ```
pub struct DeclareDefaultMenuItems {
menu_name: String,
f: FnItemBag,
weight: Weight,
}
impl ActionDispatcher for DeclareDefaultMenuItems {
fn referer_id(&self) -> Option<String> {
Some(self.menu_name.clone())
}
fn weight(&self) -> Weight {
self.weight
}
}
impl DeclareDefaultMenuItems {
pub fn new(menu_name: impl Into<String>, f: FnItemBag) -> Self {
DeclareDefaultMenuItems {
menu_name: menu_name.into(),
f,
weight: 0,
}
}
pub fn with_weight(mut self, w: Weight) -> Self {
self.weight = w;
self
}
pub(crate) fn dispatch(menu_name: &str, bag: &mut ItemBag) {
dispatch_actions(
&ActionKey::new(UniqueId::of::<Self>(), None, Some(menu_name.to_owned())),
|action: &Self| (action.f)(bag),
);
}
}
// **< AlterMenuTree >******************************************************************************
/// Acción para modificar el árbol de un menú antes de calcularse el active trail.
///
/// El `referer_id` es el `machine_name` del menú. Las acciones con `referer_id` `None`
/// no se registran aquí; usa un `machine_name` específico por menú.
///
/// Usos habituales: filtrar nodos por permisos, añadir nodos dinámicos, reordenar.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop_menu::action::AlterMenuTree;
/// use pagetop_menu::tree::MenuTree;
/// use pagetop::prelude::*;
///
/// fn hide_disabled(tree: &mut MenuTree, _cx: &Context) {
/// tree.roots.retain(|n| n.enabled);
/// }
/// // En Extension::actions():
/// // AlterMenuTree::new("main", hide_disabled)
/// ```
pub struct AlterMenuTree {
menu_name: String,
f: FnAlterTree,
weight: Weight,
}
impl ActionDispatcher for AlterMenuTree {
fn referer_id(&self) -> Option<String> {
Some(self.menu_name.clone())
}
fn weight(&self) -> Weight {
self.weight
}
}
impl AlterMenuTree {
pub fn new(menu_name: impl Into<String>, f: FnAlterTree) -> Self {
AlterMenuTree {
menu_name: menu_name.into(),
f,
weight: 0,
}
}
pub fn with_weight(mut self, w: Weight) -> Self {
self.weight = w;
self
}
pub(crate) fn dispatch(menu_name: &str, tree: &mut MenuTree, cx: &Context) {
dispatch_actions(
&ActionKey::new(UniqueId::of::<Self>(), None, Some(menu_name.to_owned())),
|action: &Self| (action.f)(tree, cx),
);
}
}
// **< ResolveActiveTrail >*************************************************************************
/// Acción para resolver el active trail en casos que la coincidencia por URL no cubre.
///
/// Se despacha después del cálculo automático de active trail. Útil para rutas
/// paramétricas (p. ej., `/blog/{slug}` que debe marcar activo al ítem `/blog`).
pub struct ResolveActiveTrail {
menu_name: String,
f: FnAlterTree,
weight: Weight,
}
impl ActionDispatcher for ResolveActiveTrail {
fn referer_id(&self) -> Option<String> {
Some(self.menu_name.clone())
}
fn weight(&self) -> Weight {
self.weight
}
}
impl ResolveActiveTrail {
pub fn new(menu_name: impl Into<String>, f: FnAlterTree) -> Self {
ResolveActiveTrail {
menu_name: menu_name.into(),
f,
weight: 0,
}
}
pub fn with_weight(mut self, w: Weight) -> Self {
self.weight = w;
self
}
pub(crate) fn dispatch(menu_name: &str, tree: &mut MenuTree, cx: &Context) {
dispatch_actions(
&ActionKey::new(UniqueId::of::<Self>(), None, Some(menu_name.to_owned())),
|action: &Self| (action.f)(tree, cx),
);
}
}
// **< DecorateMenuItem >***************************************************************************
/// Acción para decorar nodos individuales antes del render (atributos HTML, iconos, badges).
///
/// Se despacha para cada nodo del árbol después del cálculo del active trail,
/// por lo que el callback puede leer `node.is_active` y `node.in_active_trail`.
pub struct DecorateMenuItem {
menu_name: String,
f: FnDecorate,
weight: Weight,
}
impl ActionDispatcher for DecorateMenuItem {
fn referer_id(&self) -> Option<String> {
Some(self.menu_name.clone())
}
fn weight(&self) -> Weight {
self.weight
}
}
impl DecorateMenuItem {
pub fn new(menu_name: impl Into<String>, f: FnDecorate) -> Self {
DecorateMenuItem {
menu_name: menu_name.into(),
f,
weight: 0,
}
}
pub fn with_weight(mut self, w: Weight) -> Self {
self.weight = w;
self
}
pub(crate) fn dispatch(menu_name: &str, node: &mut MenuNode, cx: &Context) {
dispatch_actions(
&ActionKey::new(UniqueId::of::<Self>(), None, Some(menu_name.to_owned())),
|action: &Self| (action.f)(node, cx),
);
}
}

View file

@ -0,0 +1,73 @@
//! Caché in-process de los ítems de menú por `machine_name`.
//!
//! Almacena la lista plana de modelos y sus traducciones para cada menú. El árbol se
//! reconstruye en memoria en cada petición a partir de esta estructura. Hoy no existe ningún
//! camino que escriba ítems tras el sembrado inicial (`seed::run()`, antes de la primera
//! petición), así que no hace falta invalidación: cuando exista una vía de escritura en caliente
//! (p. ej. una UI de administración), deberá invalidar la entrada correspondiente aquí.
use std::collections::HashMap;
use std::sync::LazyLock;
use std::sync::{Arc, RwLock};
use crate::entity::{menu_item, menu_item_translation};
/// Contenido plano de un menú listo para construir el árbol.
pub struct FlatMenu {
pub items: Vec<menu_item::Model>,
/// Traducciones indexadas por `item_id`.
pub translations: HashMap<i32, Vec<menu_item_translation::Model>>,
}
static CACHE: LazyLock<RwLock<HashMap<String, Arc<FlatMenu>>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
/// Devuelve el contenido cacheado del menú dado, o lo carga desde BD si no está.
pub async fn get_or_load(menu_id: i32, machine_name: &str) -> Arc<FlatMenu> {
{
let guard = CACHE.read().expect("cache read lock poisoned");
if let Some(flat) = guard.get(machine_name) {
return Arc::clone(flat);
}
}
let flat = load_from_db(menu_id).await;
let arc = Arc::new(flat);
CACHE
.write()
.expect("cache write lock poisoned")
.insert(machine_name.to_owned(), Arc::clone(&arc));
arc
}
async fn load_from_db(menu_id: i32) -> FlatMenu {
use pagetop_seaorm::db::{ColumnTrait, EntityTrait, QueryFilter, dbconn};
let items = menu_item::Entity::find()
.filter(menu_item::Column::MenuId.eq(menu_id))
.all(dbconn())
.await
.unwrap_or_default();
let item_ids: Vec<i32> = items.iter().map(|i| i.id).collect();
let all_translations = if item_ids.is_empty() {
vec![]
} else {
menu_item_translation::Entity::find()
.filter(menu_item_translation::Column::ItemId.is_in(item_ids))
.all(dbconn())
.await
.unwrap_or_default()
};
let mut translations: HashMap<i32, Vec<menu_item_translation::Model>> = HashMap::new();
for t in all_translations {
translations.entry(t.item_id).or_default().push(t);
}
FlatMenu {
items,
translations,
}
}

View file

@ -0,0 +1,7 @@
//! Componentes de renderizado de menús.
mod menu_block;
mod menu_breadcrumb;
pub use menu_block::MenuBlock;
pub use menu_breadcrumb::MenuBreadcrumb;

View file

@ -0,0 +1,198 @@
use pagetop::prelude::*;
use crate::tree::{MenuKey, MenuNode, TreeOptions, build_tree, try_resolve_menu_url};
/// Renderiza un menú completo como bloque de navegación HTML.
///
/// Se construye componiendo [`Nav`] y [`nav::Item`] -- con [`Dropdown`]/[`dropdown::Item`] para
/// los nodos con hijos -- a partir del árbol del menú, así que produce el mismo marcado accesible
/// que cualquier `Nav`/`Dropdown` y se beneficia igual del CSS/JavaScript que aporta el tema
/// activo:
///
/// ```html
/// <nav aria-label="Main">
/// <ul class="nav">
/// <li class="nav-item"><a class="nav-link" aria-current="page" href="/">Home</a></li>
/// </ul>
/// </nav>
/// ```
///
/// Los temas pueden sobreescribir el render con `handle_component()`, tanto de `MenuBlock` como,
/// más generalmente, de [`Nav`]/[`nav::Item`]/[`Dropdown`]/[`dropdown::Item`]. `pagetop-bootsier`
/// ya intercepta `Dropdown` así (ver `theme::bs::dropdown`), y por tanto también los que cuelguen
/// de un `nav::Item::dropdown()`; `MenuBlock`, `Nav` y `Navbar` siguen sin interceptarse: Bootsier
/// mantiene sus propios `bs::Navbar`/`bs::Nav`, sin relación con este componente.
///
/// # Limitaciones conocidas
///
/// - **Profundidad máxima de 2 niveles.** [`Dropdown`] no admite submenús anidados (como
/// Bootstrap, del que toma su marcado): un nodo de tercer nivel o más profundo nunca se
/// construye -- [`TreeOptions::max_depth`] se acota internamente a `2` con independencia de lo
/// que indique [`with_max_depth()`](Self::with_max_depth), así que no hay pérdida silenciosa de
/// datos, sencillamente no se piden a la base de datos.
/// - **Sin colapso responsive propio.** A diferencia del antiguo `Menu::collapsible`, `Nav` es una
/// lista plana sin botón ni JavaScript de colapso; una aplicación que necesite ese
/// comportamiento debe envolver `MenuBlock` en su propia chrome hasta que exista un componente
/// `Navbar` en el core.
/// - `MenuNode::in_active_trail` y `MenuNode::expanded` (ver [`crate::tree::MenuNode`]) todavía no
/// se reflejan en el marcado -- ni [`nav::Item`] ni [`dropdown::Item`] tienen hoy una forma
/// verificada de pre-abrirse en el servidor sin que la mejora progresiva del tema
/// (`accessible-menu` en `Basic`) lo sobrescriba al inicializarse. Sólo se traduce el estado
/// `is_active`/`enabled` de cada nodo.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_menu::component::MenuBlock;
///
/// async fn handler(request: HttpRequest) -> Result<Markup, ErrorPage> {
/// Page::new(request)
/// .with_child(MenuBlock::with("main"))
/// .render().await
/// }
/// ```
#[derive(AutoDefault, Clone, Debug)]
pub struct MenuBlock {
menu_name: Option<String>,
show_title: bool,
max_depth: Option<u8>,
include_disabled: bool,
hide_when_empty: bool,
}
#[async_trait]
impl Component for MenuBlock {
fn new() -> Self {
MenuBlock {
hide_when_empty: true,
..Self::default()
}
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let Some(name) = &self.menu_name else {
return Ok(html! {});
};
// `Dropdown` no admite submenús: nunca se piden más de 2 niveles al árbol.
let opts = TreeOptions {
max_depth: Some(self.max_depth.map(|d| d.min(2)).unwrap_or(2)),
include_disabled: self.include_disabled,
};
let Some(tree) = build_tree(MenuKey::Name(name.clone()), cx, &opts).await else {
return Ok(html! {});
};
if tree.roots.is_empty() && self.hide_when_empty {
return Ok(html! {});
}
let mut nav = Nav::new();
for node in &tree.roots {
nav = nav.with_item(node_to_item(node, cx));
}
let aria_label = (!tree.title.is_empty()).then_some(&tree.title);
Ok(html! {
@if self.show_title {
h2.menu-title { (&tree.title) }
}
nav aria-label=[aria_label] {
(nav.render(cx).await)
}
})
}
}
impl MenuBlock {
/// Crea un `MenuBlock` para el menú con el `machine_name` dado.
pub fn with(menu_name: impl Into<String>) -> Self {
let mut block = Self::new();
block.menu_name = Some(menu_name.into());
block
}
#[builder_fn]
pub fn with_show_title(mut self, v: impl Into<Option<bool>>) -> Self {
if let Some(v) = v.into() {
self.show_title = v;
}
self
}
/// Establece la profundidad máxima de nodos a incluir. El efectivo nunca supera `2`, por muy
/// alto que sea el valor indicado (ver "Limitaciones conocidas" en [`MenuBlock`]).
#[builder_fn]
pub fn with_max_depth(mut self, v: impl Into<Option<u8>>) -> Self {
self.max_depth = v.into();
self
}
#[builder_fn]
pub fn with_include_disabled(mut self, v: impl Into<Option<bool>>) -> Self {
if let Some(v) = v.into() {
self.include_disabled = v;
}
self
}
#[builder_fn]
pub fn with_hide_when_empty(mut self, v: impl Into<Option<bool>>) -> Self {
if let Some(v) = v.into() {
self.hide_when_empty = v;
}
self
}
}
// **< Traducción de MenuNode a nav::Item / dropdown::Item >****************************************
// Convierte un `MenuNode` de nivel 1 (raíz) en un `nav::Item`: sin hijos, enlace (o etiqueta sin
// ruta propia); con hijos, activador de un `Dropdown` con sus hijos como `dropdown::Item`. Los
// hijos de un `MenuNode` de nivel 1 nunca tienen a su vez hijos propios -- `TreeOptions::max_depth`
// se acota a 2 en `MenuBlock::prepare()`, así que no hay un tercer nivel que representar.
fn node_to_item(node: &MenuNode, cx: &Context) -> nav::Item {
if !node.children.is_empty() {
let mut dropdown = Dropdown::new().with_title(Lc::n(node.title.clone()));
for child in &node.children {
dropdown = dropdown.with_item(child_to_item(child, cx));
}
return nav::Item::dropdown(dropdown);
}
let label = Lc::n(node.title.clone());
let is_external = node.url.as_ref().is_some_and(RoutePath::is_external);
let disabled = !node.enabled;
let Some(route) = try_resolve_menu_url(node.url.as_ref(), cx).map(Route::from) else {
return nav::Item::label(label);
};
match (is_external, disabled) {
(true, true) => nav::Item::link_blank_disabled(label, route),
(true, false) => nav::Item::link_blank(label, route),
(false, true) => nav::Item::link_disabled(label, route),
(false, false) => nav::Item::link(label, route),
}
.with_active(node.is_active)
}
// Convierte un `MenuNode` de nivel 2 en un `dropdown::Item`: sin ruta, etiqueta no interactiva; con
// ruta, enlace. `Dropdown` no admite submenús, así que no hay recursión posible aquí.
fn child_to_item(node: &MenuNode, cx: &Context) -> dropdown::Item {
let label = Lc::n(node.title.clone());
let is_external = node.url.as_ref().is_some_and(RoutePath::is_external);
let disabled = !node.enabled;
let Some(route) = try_resolve_menu_url(node.url.as_ref(), cx).map(Route::from) else {
return dropdown::Item::label(label);
};
match (is_external, disabled) {
(true, true) => dropdown::Item::link_blank_disabled(label, route),
(true, false) => dropdown::Item::link_blank(label, route),
(false, true) => dropdown::Item::link_disabled(label, route),
(false, false) => dropdown::Item::link(label, route),
}
}

View file

@ -0,0 +1,103 @@
use pagetop::base::component::breadcrumb;
use pagetop::prelude::*;
use crate::tree::{MenuKey, MenuNode, TreeOptions, build_tree, try_resolve_menu_url};
/// Migas de pan del menú dado, basándose en el active trail.
///
/// Resuelve el árbol del menú y su active trail (de forma asíncrona, en su propio
/// [`prepare()`](Component::prepare)) y delega el renderizado en
/// [`Breadcrumb`](pagetop::base::component::Breadcrumb): esta extensión sólo aporta los datos, la
/// estructura HTML y las clases CSS son responsabilidad del componente base.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_menu::component::MenuBreadcrumb;
///
/// async fn handler(request: HttpRequest) -> Result<Markup, ErrorPage> {
/// Page::new(request)
/// .with_child(MenuBreadcrumb::with("main"))
/// .render().await
/// }
/// ```
#[derive(AutoDefault, Clone, Debug)]
pub struct MenuBreadcrumb {
menu_name: Option<String>,
include_current: bool,
}
#[async_trait]
impl Component for MenuBreadcrumb {
fn new() -> Self {
MenuBreadcrumb {
include_current: true,
..Self::default()
}
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let Some(name) = &self.menu_name else {
return Ok(html! {});
};
let opts = TreeOptions::default();
let Some(tree) = build_tree(MenuKey::Name(name.clone()), cx, &opts).await else {
return Ok(html! {});
};
let path = extract_trail(&tree.roots);
if path.is_empty() {
return Ok(html! {});
}
let mut inner = Breadcrumb::new();
let last = path.len() - 1;
for (i, node) in path.iter().enumerate() {
let label = Lc::n(node.title.clone());
if i == last {
if self.include_current {
inner = inner.with_crumb(breadcrumb::Crumb::current(label));
}
} else if let Some(url) = node.url.clone() {
let route =
Route::with(move |cx| try_resolve_menu_url(Some(&url), cx).unwrap_or_default());
inner = inner.with_crumb(breadcrumb::Crumb::new(label, route));
} else {
inner = inner.with_crumb(breadcrumb::Crumb::text(label));
}
}
Ok(inner.render(cx).await)
}
}
impl MenuBreadcrumb {
/// Crea un `MenuBreadcrumb` para el menú con el `machine_name` dado.
pub fn with(menu_name: impl Into<String>) -> Self {
let mut bc = Self::new();
bc.menu_name = Some(menu_name.into());
bc
}
#[builder_fn]
pub fn with_include_current(mut self, v: impl Into<Option<bool>>) -> Self {
if let Some(v) = v.into() {
self.include_current = v;
}
self
}
}
// Extrae la cadena raíz -> nodo activo recorriendo el active trail.
fn extract_trail(nodes: &[MenuNode]) -> Vec<&MenuNode> {
for node in nodes {
if node.in_active_trail || node.is_active {
let mut path = extract_trail(&node.children);
path.insert(0, node);
return path;
}
}
vec![]
}

View file

@ -0,0 +1,56 @@
//! Configuración de `pagetop-menu`.
//!
//! Todos los valores pueden sobreescribirse en los ficheros TOML de la aplicación:
//!
//! ```toml
//! [menu]
//! default_menus = ["main", "footer", "user"]
//! ```
use pagetop::prelude::*;
use serde::Deserialize;
use std::sync::LazyLock;
// **< CONFIG_MENU >********************************************************************************
include_config!(CONFIG_MENU: MenuTopConfig => [
// Menús que se crean automáticamente si no los declara ninguna extensión.
// (Nota: los valores de lista no son soportados por config-rs vía set_default; se leen del
// TOML.)
]);
// **< MenuTopConfig >******************************************************************************
/// Estructura raíz para la sección `[menu]` del fichero de configuración.
#[derive(Clone, Debug, Deserialize)]
pub struct MenuTopConfig {
pub menu: Settings,
}
// **< SETTINGS >***********************************************************************************
/// Acceso directo a los ajustes de `pagetop-menu` (alias de `CONFIG_MENU.menu`).
pub static SETTINGS: LazyLock<Settings> = LazyLock::new(|| CONFIG_MENU.menu.clone());
// **< Settings >***********************************************************************************
/// Ajustes de la extensión `pagetop-menu`, accesibles en la sección `[menu]` del TOML.
#[derive(Clone, Debug, Deserialize)]
pub struct Settings {
/// Menús que se aseguran en BD al arrancar si ninguna extensión los declara.
#[serde(default = "default_menus")]
pub default_menus: Vec<String>,
}
impl Default for Settings {
fn default() -> Self {
Settings {
default_menus: default_menus(),
}
}
}
fn default_menus() -> Vec<String> {
vec!["main".into(), "footer".into(), "user".into()]
}

View file

@ -0,0 +1,6 @@
//! Entidades SeaORM de `pagetop-menu`.
pub mod menu;
pub mod menu_item;
pub mod menu_item_translation;
pub mod menu_translation;

View file

@ -0,0 +1,37 @@
use pagetop_seaorm::db::*;
use chrono::NaiveDateTime;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
#[sea_orm(table_name = "menus")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub machine_name: String,
pub locked: bool,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
pub enum Relation {
#[sea_orm(has_many = "super::menu_translation::Entity")]
MenuTranslations,
#[sea_orm(has_many = "super::menu_item::Entity")]
MenuItems,
}
impl Related<super::menu_translation::Entity> for Entity {
fn to() -> RelationDef {
Relation::MenuTranslations.def()
}
}
impl Related<super::menu_item::Entity> for Entity {
fn to() -> RelationDef {
Relation::MenuItems.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,47 @@
use pagetop_seaorm::db::*;
use chrono::NaiveDateTime;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
#[sea_orm(table_name = "menu_items")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub menu_id: i32,
pub parent_id: Option<i32>,
pub url: String,
pub weight: i32,
pub enabled: bool,
pub expanded: bool,
pub provider: String,
pub external_key: Option<String>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::menu::Entity",
from = "Column::MenuId",
to = "super::menu::Column::Id",
on_delete = "Cascade"
)]
Menu,
#[sea_orm(has_many = "super::menu_item_translation::Entity")]
MenuItemTranslations,
}
impl Related<super::menu::Entity> for Entity {
fn to() -> RelationDef {
Relation::Menu.def()
}
}
impl Related<super::menu_item_translation::Entity> for Entity {
fn to() -> RelationDef {
Relation::MenuItemTranslations.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,30 @@
use pagetop_seaorm::db::*;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
#[sea_orm(table_name = "menu_item_translations")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub item_id: i32,
#[sea_orm(primary_key, auto_increment = false)]
pub lang: String,
pub title: String,
}
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::menu_item::Entity",
from = "Column::ItemId",
to = "super::menu_item::Column::Id",
on_delete = "Cascade"
)]
MenuItem,
}
impl Related<super::menu_item::Entity> for Entity {
fn to() -> RelationDef {
Relation::MenuItem.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,31 @@
use pagetop_seaorm::db::*;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
#[sea_orm(table_name = "menu_translations")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub menu_id: i32,
#[sea_orm(primary_key, auto_increment = false)]
pub lang: String,
pub title: String,
pub description: Option<String>,
}
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::menu::Entity",
from = "Column::MenuId",
to = "super::menu::Column::Id",
on_delete = "Cascade"
)]
Menu,
}
impl Related<super::menu::Entity> for Entity {
fn to() -> RelationDef {
Relation::Menu.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -0,0 +1,13 @@
//! Tipos de error de `pagetop-menu`.
use thiserror::Error;
/// Errores que puede producir `pagetop-menu`.
#[derive(Debug, Error)]
pub enum MenuError {
#[error("invalid machine name: {0}")]
InvalidName(String),
#[error("database error: {0}")]
Database(#[from] pagetop_seaorm::db::DbErr),
}

View file

@ -0,0 +1,130 @@
/*!
<div align="center">
<h1>PageTop Menu</h1>
<p>Gestión centralizada y persistente de menús para <strong>PageTop</strong>.</p>
</div>
## Guía rápida
Declara la dependencia en tu `Cargo.toml` y reenvía a `pagetop-seaorm` el motor de base de datos
que vayas a usar:
```toml
[features]
sqlite = ["pagetop-seaorm/sqlite"]
[dependencies]
pagetop-menu = { version = "..." }
```
Añade `&pagetop_menu::Menu` a las dependencias de tu extensión, declara los menús
que necesitas y añade los componentes a tus páginas:
```rust,no_run
use pagetop::prelude::*;
use pagetop_menu::prelude::*;
pub struct MyApp;
#[async_trait]
impl Extension for MyApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&pagetop_menu::Menu]
}
fn actions(&self) -> Vec<ActionBox> {
actions![
DeclareDefaultMenuItems::new("main", home_items),
]
}
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(home))
}
}
fn home_items(bag: &mut ItemBag) {
bag.add(NewMenuItem::new()
.with_provider("myapp")
.with_external_key("home")
.with_title("Home")
.with_url("/")
.with_weight(0));
}
async fn home(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_child(MenuBlock::with("main"))
.render().await
}
```
*/
use pagetop::prelude::*;
use pagetop_seaorm::install_migrations;
include_locales!(LOCALES_MENU);
pub mod action;
pub mod component;
pub mod config;
pub mod error;
pub mod tree;
pub(crate) mod cache;
pub(crate) mod entity;
pub(crate) mod migration;
pub(crate) mod repo;
pub(crate) mod seed;
pub use action::{
AlterMenuTree, DeclareDefaultMenuItems, DeclareDefaultMenus, DecorateMenuItem, ItemBag,
MenuDefs, ResolveActiveTrail,
};
pub use repo::NewMenuItem;
pub use tree::{MenuKey, MenuNode, MenuTree, TreeOptions, build_tree};
/// Prelude de `pagetop-menu`.
pub mod prelude {
pub use crate::action::{
AlterMenuTree, DeclareDefaultMenuItems, DeclareDefaultMenus, DecorateMenuItem, ItemBag,
MenuDefs, ResolveActiveTrail,
};
pub use crate::component::{MenuBlock, MenuBreadcrumb};
pub use crate::error::MenuError;
pub use crate::repo::NewMenuItem;
pub use crate::tree::{MenuKey, MenuNode, MenuTree, TreeOptions, build_tree};
}
// **< Extension >**********************************************************************************
/// Implementa la extensión `pagetop-menu`.
pub struct Menu;
#[async_trait]
impl Extension for Menu {
fn name(&self) -> Lc {
Lc::t("extension_name", &LOCALES_MENU)
}
fn description(&self) -> Lc {
Lc::t("extension_description", &LOCALES_MENU)
}
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&pagetop_seaorm::SeaORM]
}
async fn initialize(&self) {
install_migrations!(
m20260629_000001_create_menus,
m20260629_000002_create_menu_translations,
m20260629_000003_create_menu_items,
m20260629_000004_create_menu_item_translations,
);
seed::run().await;
}
}

View file

@ -0,0 +1,2 @@
extension_name = PageTop Menu
extension_description = Centralized and persistent menu management for PageTop.

View file

@ -0,0 +1,2 @@
extension_name = PageTop Menú
extension_description = Gestión centralizada y persistente de menús para PageTop.

View file

@ -0,0 +1,6 @@
//! Migraciones de `pagetop-menu`.
pub mod m20260629_000001_create_menus;
pub mod m20260629_000002_create_menu_translations;
pub mod m20260629_000003_create_menu_items;
pub mod m20260629_000004_create_menu_item_translations;

View file

@ -0,0 +1,32 @@
use pagetop_seaorm::migration::*;
pub struct Migration;
#[pagetop::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
table_auto(Menus::Table)
.col(pk_auto(Menus::Id))
.col(string_len_uniq(Menus::MachineName, 64))
.col(boolean(Menus::Locked).default(false))
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Menus::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub enum Menus {
Table,
Id,
MachineName,
Locked,
}

View file

@ -0,0 +1,58 @@
use pagetop_seaorm::migration::*;
pub struct Migration;
#[pagetop::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(MenuTranslations::Table)
.if_not_exists()
.col(integer(MenuTranslations::MenuId))
.col(string_len(MenuTranslations::Lang, 35))
.col(string_len(MenuTranslations::Title, 128))
.col(text_null(MenuTranslations::Description))
.primary_key(
Index::create()
.col(MenuTranslations::MenuId)
.col(MenuTranslations::Lang),
)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_menu_translations_menu_id")
.from(MenuTranslations::Table, MenuTranslations::MenuId)
.to(Menus::Table, Menus::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(MenuTranslations::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum MenuTranslations {
Table,
MenuId,
Lang,
Title,
Description,
}
#[derive(DeriveIden)]
enum Menus {
Table,
Id,
}

View file

@ -0,0 +1,87 @@
use pagetop_seaorm::migration::*;
pub struct Migration;
#[pagetop::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
table_auto(MenuItems::Table)
.col(pk_auto(MenuItems::Id))
.col(integer(MenuItems::MenuId))
.col(integer_null(MenuItems::ParentId))
.col(string_len(MenuItems::Url, 2048))
.col(integer(MenuItems::Weight).default(0))
.col(boolean(MenuItems::Enabled).default(true))
.col(boolean(MenuItems::Expanded).default(false))
.col(string_len(MenuItems::Provider, 64).default("user"))
.col(string_len_null(MenuItems::ExternalKey, 128))
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_menu_items_menu_id")
.from(MenuItems::Table, MenuItems::MenuId)
.to(Menus::Table, Menus::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
// Índice para listar hijos ordenados.
manager
.create_index(
Index::create()
.name("idx_menu_items_menu_parent_weight")
.table(MenuItems::Table)
.col(MenuItems::MenuId)
.col(MenuItems::ParentId)
.col(MenuItems::Weight)
.to_owned(),
)
.await?;
// Índice de unicidad para upserts por extensión.
manager
.create_index(
Index::create()
.name("idx_menu_items_provider_key")
.table(MenuItems::Table)
.col(MenuItems::Provider)
.col(MenuItems::ExternalKey)
.unique()
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(MenuItems::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub enum MenuItems {
Table,
Id,
MenuId,
ParentId,
Url,
Weight,
Enabled,
Expanded,
Provider,
ExternalKey,
}
#[derive(DeriveIden)]
enum Menus {
Table,
Id,
}

View file

@ -0,0 +1,56 @@
use pagetop_seaorm::migration::*;
pub struct Migration;
#[pagetop::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(MenuItemTranslations::Table)
.if_not_exists()
.col(integer(MenuItemTranslations::ItemId))
.col(string_len(MenuItemTranslations::Lang, 35))
.col(string_len(MenuItemTranslations::Title, 255))
.primary_key(
Index::create()
.col(MenuItemTranslations::ItemId)
.col(MenuItemTranslations::Lang),
)
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("fk_menu_item_translations_item_id")
.from(MenuItemTranslations::Table, MenuItemTranslations::ItemId)
.to(MenuItems::Table, MenuItems::Id)
.on_delete(ForeignKeyAction::Cascade)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(MenuItemTranslations::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum MenuItemTranslations {
Table,
ItemId,
Lang,
Title,
}
#[derive(DeriveIden)]
enum MenuItems {
Table,
Id,
}

View file

@ -0,0 +1,309 @@
//! Operaciones de base de datos para menús e ítems de menú.
use chrono::Utc;
use pagetop::locale::Locale;
use pagetop_seaorm::db::{
ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set, dbconn,
};
use crate::entity::{menu, menu_item, menu_item_translation, menu_translation};
use crate::error::MenuError;
use crate::tree::MenuKey;
// **< Tipos de entrada >***************************************************************************
/// Datos necesarios para crear un nuevo menú.
pub struct NewMenu {
pub machine_name: String,
/// Títulos por idioma: `(lang, title)`. Al menos uno es obligatorio.
pub titles: Vec<(String, String)>,
pub locked: bool,
}
/// Datos de un ítem nuevo o para hacer upsert.
pub struct NewMenuItem {
pub parent_key: Option<String>,
/// Títulos por idioma: `(lang, title)`. Al menos uno es obligatorio.
pub titles: Vec<(String, String)>,
pub url: String,
pub weight: i32,
pub enabled: bool,
pub expanded: bool,
pub provider: String,
pub external_key: Option<String>,
}
impl NewMenuItem {
pub fn new() -> Self {
NewMenuItem {
parent_key: None,
titles: Vec::new(),
url: String::new(),
weight: 0,
enabled: true,
expanded: false,
provider: "user".into(),
external_key: None,
}
}
/// Añade el título en el idioma por defecto de la aplicación.
pub fn with_title(mut self, title: impl Into<String>) -> Self {
let lang = Locale::default_langid().to_string();
self.titles.push((lang, title.into()));
self
}
/// Añade el título en el idioma indicado.
pub fn with_title_for(mut self, lang: impl Into<String>, title: impl Into<String>) -> Self {
self.titles.push((lang.into(), title.into()));
self
}
pub fn with_url(mut self, u: impl Into<String>) -> Self {
self.url = u.into();
self
}
pub fn with_weight(mut self, w: i32) -> Self {
self.weight = w;
self
}
pub fn with_enabled(mut self, v: bool) -> Self {
self.enabled = v;
self
}
pub fn with_expanded(mut self, v: bool) -> Self {
self.expanded = v;
self
}
pub fn with_provider(mut self, p: impl Into<String>) -> Self {
self.provider = p.into();
self
}
pub fn with_external_key(mut self, k: impl Into<String>) -> Self {
self.external_key = Some(k.into());
self
}
pub fn with_parent_key(mut self, k: impl Into<String>) -> Self {
self.parent_key = Some(k.into());
self
}
}
impl Default for NewMenuItem {
fn default() -> Self {
Self::new()
}
}
// **< find_menu_model >****************************************************************************
/// Devuelve el modelo de BD del menú dado, o `None` si no existe.
pub async fn find_menu_model(key: &MenuKey) -> Option<menu::Model> {
match key {
MenuKey::Id(id) => menu::Entity::find_by_id(*id).one(dbconn()).await.ok()?,
MenuKey::Name(n) => menu::Entity::find()
.filter(menu::Column::MachineName.eq(n.as_str()))
.one(dbconn())
.await
.ok()?,
}
}
// **< create_menu >********************************************************************************
/// Crea un nuevo menú en la base de datos con sus traducciones iniciales.
pub async fn create_menu(input: NewMenu) -> Result<menu::Model, MenuError> {
if input.machine_name.is_empty()
|| !input
.machine_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return Err(MenuError::InvalidName(input.machine_name));
}
let now = Utc::now().naive_utc();
let result = menu::Entity::insert(menu::ActiveModel {
id: NotSet,
machine_name: Set(input.machine_name),
locked: Set(input.locked),
created_at: Set(now),
updated_at: Set(now),
})
.exec_with_returning(dbconn())
.await?;
for (lang, title) in input.titles {
upsert_menu_translation(result.id, &lang, &title, None).await?;
}
Ok(result)
}
/// Crea un menú sólo si no existe ya uno con el mismo `machine_name`, y sincroniza
/// la traducción del título en el idioma por defecto.
pub async fn ensure_menu(machine_name: &str, title: &str) -> Result<(), MenuError> {
let lang = Locale::default_langid().to_string();
let existing = find_menu_model(&MenuKey::Name(machine_name.to_owned())).await;
let menu_id = if let Some(m) = existing {
m.id
} else {
create_menu(NewMenu {
machine_name: machine_name.to_owned(),
titles: vec![(lang.clone(), title.to_owned())],
locked: false,
})
.await?
.id
};
upsert_menu_translation(menu_id, &lang, title, None).await?;
Ok(())
}
// **< upsert_item >********************************************************************************
/// Inserta o actualiza un ítem identificado por `(provider, external_key)`.
///
/// Si el ítem ya existe, actualiza `url`, `weight` y `parent_id`; los campos `enabled`
/// y `expanded` modificados por el administrador no se sobreescriben. Las traducciones
/// se sincronizan para los idiomas incluidos en `input.titles`.
pub async fn upsert_item(
menu_id: i32,
provider: &str,
external_key: &str,
input: NewMenuItem,
) -> Result<(), MenuError> {
let existing = menu_item::Entity::find()
.filter(menu_item::Column::Provider.eq(provider))
.filter(menu_item::Column::ExternalKey.eq(external_key))
.one(dbconn())
.await?;
let parent_id = resolve_parent_id(menu_id, &input.parent_key).await;
let now = Utc::now().naive_utc();
let item_id = if let Some(row) = existing {
menu_item::ActiveModel {
id: Set(row.id),
url: Set(input.url),
weight: Set(input.weight),
parent_id: Set(parent_id),
updated_at: Set(now),
..Default::default()
}
.update(dbconn())
.await?;
row.id
} else {
menu_item::Entity::insert(menu_item::ActiveModel {
id: NotSet,
menu_id: Set(menu_id),
parent_id: Set(parent_id),
url: Set(input.url),
weight: Set(input.weight),
enabled: Set(input.enabled),
expanded: Set(input.expanded),
provider: Set(provider.to_owned()),
external_key: Set(Some(external_key.to_owned())),
created_at: Set(now),
updated_at: Set(now),
})
.exec_with_returning(dbconn())
.await?
.id
};
for (lang, title) in input.titles {
upsert_item_translation(item_id, &lang, &title).await?;
}
Ok(())
}
// **< Funciones internas >*************************************************************************
async fn resolve_parent_id(menu_id: i32, parent_key: &Option<String>) -> Option<i32> {
let key = parent_key.as_deref()?;
let row = menu_item::Entity::find()
.filter(menu_item::Column::MenuId.eq(menu_id))
.filter(menu_item::Column::ExternalKey.eq(key))
.one(dbconn())
.await
.ok()??;
Some(row.id)
}
async fn upsert_menu_translation(
menu_id: i32,
lang: &str,
title: &str,
description: Option<&str>,
) -> Result<(), MenuError> {
let existing = menu_translation::Entity::find()
.filter(menu_translation::Column::MenuId.eq(menu_id))
.filter(menu_translation::Column::Lang.eq(lang))
.one(dbconn())
.await?;
if existing.is_some() {
menu_translation::ActiveModel {
menu_id: Set(menu_id),
lang: Set(lang.to_owned()),
title: Set(title.to_owned()),
description: Set(description.map(str::to_owned)),
}
.update(dbconn())
.await?;
} else {
menu_translation::Entity::insert(menu_translation::ActiveModel {
menu_id: Set(menu_id),
lang: Set(lang.to_owned()),
title: Set(title.to_owned()),
description: Set(description.map(str::to_owned)),
})
.exec(dbconn())
.await?;
}
Ok(())
}
async fn upsert_item_translation(item_id: i32, lang: &str, title: &str) -> Result<(), MenuError> {
let existing = menu_item_translation::Entity::find()
.filter(menu_item_translation::Column::ItemId.eq(item_id))
.filter(menu_item_translation::Column::Lang.eq(lang))
.one(dbconn())
.await?;
if existing.is_some() {
menu_item_translation::ActiveModel {
item_id: Set(item_id),
lang: Set(lang.to_owned()),
title: Set(title.to_owned()),
}
.update(dbconn())
.await?;
} else {
menu_item_translation::Entity::insert(menu_item_translation::ActiveModel {
item_id: Set(item_id),
lang: Set(lang.to_owned()),
title: Set(title.to_owned()),
})
.exec(dbconn())
.await?;
}
Ok(())
}

View file

@ -0,0 +1,60 @@
//! Sembrado inicial de menús e ítems declarados por extensiones.
//!
//! Se invoca desde `Extension::initialize()` después de aplicar las migraciones.
//! Es idempotente: puede ejecutarse en cada arranque sin duplicar datos.
use crate::action::{DeclareDefaultMenuItems, DeclareDefaultMenus, ItemBag, MenuDefs};
use crate::config::SETTINGS;
use crate::repo;
/// Ejecuta el sembrado completo:
///
/// 1. Recoge los menús declarados por extensiones vía `DeclareDefaultMenus`.
/// 2. Asegura que los menús configurados en `menu.default_menus` también existen.
/// 3. Para cada menú conocido, recoge y aplica los ítems declarados por extensiones.
pub(crate) async fn run() {
// Paso 1: recoger declaraciones de menú.
let mut defs = MenuDefs {
entries: Vec::new(),
};
DeclareDefaultMenus::dispatch(&mut defs);
// Paso 2: añadir los menús por defecto de configuración si no se declararon ya.
for name in &SETTINGS.default_menus {
if !defs.entries.iter().any(|(n, _)| n == name) {
let title = capitalize(name);
defs.entries.push((name.clone(), title));
}
}
// Paso 3: crear cada menú que no exista y sembrar sus ítems.
for (machine_name, title) in &defs.entries {
repo::ensure_menu(machine_name, title).await.ok();
let Some(menu_id) =
repo::find_menu_model(&crate::tree::MenuKey::Name(machine_name.to_owned()))
.await
.map(|m| m.id)
else {
continue;
};
let mut bag = ItemBag { items: Vec::new() };
DeclareDefaultMenuItems::dispatch(machine_name, &mut bag);
for item in bag.items {
if let Some(key) = item.external_key.clone() {
let provider = item.provider.clone();
repo::upsert_item(menu_id, &provider, &key, item).await.ok();
}
}
}
}
fn capitalize(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().to_string() + c.as_str(),
}
}

View file

@ -0,0 +1,289 @@
//! Tipos en memoria del árbol de menús y función de construcción.
use std::collections::HashMap;
use pagetop::locale::{Locale, RequestLocale};
use pagetop::prelude::*;
use crate::cache::FlatMenu;
use crate::entity::menu_item_translation;
use crate::{action, cache, repo};
// **< MenuKey >************************************************************************************
/// Selector para localizar un menú por `id` o por `machine_name`.
pub enum MenuKey {
Id(i32),
Name(String),
}
impl From<i32> for MenuKey {
fn from(id: i32) -> Self {
MenuKey::Id(id)
}
}
impl From<&str> for MenuKey {
fn from(name: &str) -> Self {
MenuKey::Name(name.to_owned())
}
}
impl From<String> for MenuKey {
fn from(name: String) -> Self {
MenuKey::Name(name)
}
}
// **< URL de un ítem de menú >*********************************************************************
/// Construye la URL de un ítem de menú a partir del texto crudo guardado en BD.
///
/// Devuelve `None` para `<nolink>` o una cadena vacía (título de sección, sin enlace). El
/// `RoutePath` devuelto está sin resolver todavía: [`try_resolve_menu_url()`] es quien decide, en
/// el momento del renderizado, si debe pasar por [`Context::route()`] o dejarse tal cual.
pub fn menu_item_url(url: &str) -> Option<RoutePath> {
(!url.is_empty() && url != "<nolink>").then(|| RoutePath::new(url.to_owned()))
}
/// Resuelve la URL de un ítem de menú para renderizado.
///
/// Las internas pasan por [`Context::route()`] para preservar `lang` cuando corresponda; las
/// externas ([`RoutePath::is_external()`]) se devuelven tal cual, sin tocar el idioma, porque no
/// pertenecen al espacio de rutas de la aplicación. `None` (sin enlace) se propaga tal cual.
pub fn try_resolve_menu_url(url: Option<&RoutePath>, cx: &Context) -> Option<RoutePath> {
url.map(|path| {
if path.is_external() {
path.clone()
} else {
cx.route(path.path().to_owned())
}
})
}
// **< TreeOptions >********************************************************************************
/// Opciones de construcción del árbol de menú.
#[derive(Clone, Debug, Default)]
pub struct TreeOptions {
/// Profundidad máxima de nodos a incluir (`None` = sin límite).
pub max_depth: Option<u8>,
/// Si `true`, incluye también los ítems con `enabled = false`.
pub include_disabled: bool,
}
// **< MenuNode >***********************************************************************************
/// Nodo del árbol de menú. Sus campos son `pub` para que las acciones puedan modificarlos.
#[derive(Clone, Debug)]
pub struct MenuNode {
pub item_id: i32,
pub title: String,
/// `None` si el ítem no tiene enlace (`<nolink>` o URL vacía): título de sección.
pub url: Option<RoutePath>,
pub weight: i32,
pub depth: u8,
pub enabled: bool,
pub expanded: bool,
pub provider: String,
pub external_key: Option<String>,
/// Atributos HTML adicionales inyectados por `DecorateMenuItem` en tiempo de render.
pub attrs: HashMap<String, String>,
pub children: Vec<MenuNode>,
/// `true` si este nodo o algún descendiente coincide con la ruta actual.
pub in_active_trail: bool,
/// `true` si este nodo coincide exactamente con la ruta actual.
pub is_active: bool,
}
// **< MenuTree >***********************************************************************************
/// Árbol completo de un menú, listo para renderizar.
#[derive(Clone, Debug)]
pub struct MenuTree {
pub menu_id: i32,
pub machine_name: String,
pub title: String,
pub roots: Vec<MenuNode>,
}
impl MenuTree {
/// Aplica `f` recursivamente a todos los nodos del árbol (post-orden).
pub fn walk_mut<F: FnMut(&mut MenuNode)>(&mut self, f: &mut F) {
walk_nodes_mut(&mut self.roots, f);
}
}
fn walk_nodes_mut<F: FnMut(&mut MenuNode)>(nodes: &mut [MenuNode], f: &mut F) {
for node in nodes.iter_mut() {
walk_nodes_mut(&mut node.children, f);
f(node);
}
}
// **< build_tree >*********************************************************************************
/// Construye el árbol del menú indicado aplicando caché, acciones y active trail.
///
/// Devuelve `None` si el menú no existe en la base de datos.
pub async fn build_tree(key: MenuKey, cx: &Context, opts: &TreeOptions) -> Option<MenuTree> {
let menu = repo::find_menu_model(&key).await?;
let flat = cache::get_or_load(menu.id, &menu.machine_name).await;
let lang = RequestLocale::from_request(cx.request())
.langid()
.to_string();
let menu_title = resolve_menu_title(menu.id, &lang).await;
let roots = build_nodes(&flat, None, 1, opts, &lang);
let mut tree = MenuTree {
menu_id: menu.id,
machine_name: menu.machine_name.clone(),
title: menu_title,
roots,
};
// Acciones de alteración del árbol (filtros, reordenamientos, etc.).
action::AlterMenuTree::dispatch(&menu.machine_name, &mut tree, cx);
// Cálculo del active trail por coincidencia de URL.
let current_path = cx.request().map(|r| r.path()).unwrap_or("/");
compute_active_trail(&mut tree.roots, current_path);
// Acciones de resolución de active trail para rutas paramétricas o especiales.
action::ResolveActiveTrail::dispatch(&menu.machine_name, &mut tree, cx);
// Decoración de nodos (atributos HTML, iconos, badges...).
let name = menu.machine_name.clone();
tree.walk_mut(&mut |node| {
action::DecorateMenuItem::dispatch(&name, node, cx);
});
Some(tree)
}
// **< Funciones internas >*************************************************************************
pub(crate) fn build_nodes(
flat: &FlatMenu,
parent_id: Option<i32>,
depth: u8,
opts: &TreeOptions,
lang: &str,
) -> Vec<MenuNode> {
if opts.max_depth.map(|d| depth > d).unwrap_or(false) {
return vec![];
}
let mut nodes: Vec<MenuNode> = flat
.items
.iter()
.filter(|m| m.parent_id == parent_id && (opts.include_disabled || m.enabled))
.map(|m| {
let translations = flat
.translations
.get(&m.id)
.map(Vec::as_slice)
.unwrap_or(&[]);
MenuNode {
item_id: m.id,
title: resolve_item_title(translations, lang),
url: menu_item_url(&m.url),
weight: m.weight,
depth,
enabled: m.enabled,
expanded: m.expanded,
provider: m.provider.clone(),
external_key: m.external_key.clone(),
attrs: HashMap::new(),
children: build_nodes(flat, Some(m.id), depth + 1, opts, lang),
in_active_trail: false,
is_active: false,
}
})
.collect();
nodes.sort_by_key(|n| n.weight);
nodes
}
pub(crate) fn compute_active_trail(nodes: &mut [MenuNode], current_path: &str) -> bool {
let mut any_active = false;
for node in nodes.iter_mut() {
let self_active = node
.url
.as_ref()
.is_some_and(|p| !p.is_external() && p.path() == current_path);
let child_active = compute_active_trail(&mut node.children, current_path);
node.is_active = self_active;
node.in_active_trail = self_active || child_active;
if node.in_active_trail {
any_active = true;
}
}
any_active
}
// Resuelve el título del menú desde la BD aplicando la cadena de fallback de idioma.
async fn resolve_menu_title(menu_id: i32, lang: &str) -> String {
use crate::entity::menu_translation;
use pagetop_seaorm::db::{ColumnTrait, EntityTrait, QueryFilter, dbconn};
let rows = menu_translation::Entity::find()
.filter(menu_translation::Column::MenuId.eq(menu_id))
.all(dbconn())
.await
.unwrap_or_default();
resolve_title_from(
rows.iter().map(|r| (r.lang.as_str(), r.title.as_str())),
lang,
)
}
// Resuelve el título de un ítem aplicando la cadena de fallback de idioma.
fn resolve_item_title(translations: &[menu_item_translation::Model], lang: &str) -> String {
resolve_title_from(
translations
.iter()
.map(|t| (t.lang.as_str(), t.title.as_str())),
lang,
)
}
// Cadena de fallback: exacto -> base del lang -> idioma por defecto -> base del defecto ->
// cualquiera.
fn resolve_title_from<'a>(
translations: impl Iterator<Item = (&'a str, &'a str)> + Clone,
lang: &str,
) -> String {
let base_lang = lang.split('-').next().unwrap_or(lang);
let default_lang = Locale::default_langid().to_string();
let base_default = default_lang.split('-').next().unwrap_or("").to_owned();
let candidates = [
lang,
base_lang,
default_lang.as_str(),
base_default.as_str(),
];
for candidate in candidates {
if candidate.is_empty() {
continue;
}
if let Some((_, title)) = translations.clone().find(|(l, _)| *l == candidate) {
return title.to_owned();
}
}
// Cualquier traducción disponible como último recurso.
translations
.clone()
.next()
.map(|(_, t)| t.to_owned())
.unwrap_or_default()
}