♻️ Refactoriza la API de Children e InRegion

- Patrón prototipo en `InRegion`: cada petición recibe clones profundos.
- `ComponentClone` habilita clonar `dyn Component` de forma segura.
- `ChildTyped<C>` renombrado a `Slot<C>`, elimina `ChildTypedOp`.
- `Mutex` en lugar de `Arc<RwLock>` en `Child` y `Slot`.
- `is_renderable` y `setup_before_prepare` reciben `&Context`.
- Nuevos tests para `Children`, `ChildOp` y `Slot`.
This commit is contained in:
Manuel Cillero 2026-03-26 20:36:32 +01:00
parent 04e3d5b3c2
commit 54f990b11c
33 changed files with 740 additions and 314 deletions

View file

@ -89,11 +89,11 @@ impl Extension for SuperMenu {
})),
));
InRegion::Global(&DefaultRegion::Header).add(Child::with(
InRegion::Global(&DefaultRegion::Header).add(
Container::new()
.with_width(container::Width::FluidMax(UnitValue::RelRem(75.0)))
.add_child(navbar_menu),
));
);
}
}

View file

@ -6,7 +6,7 @@ use crate::prelude::*;
///
/// Envuelve un contenido con la etiqueta HTML indicada por [`container::Kind`]. Sólo se renderiza
/// si existen componentes hijos (*children*).
#[derive(AutoDefault, Debug, Getters)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Container {
#[getters(skip)]
id: AttrId,
@ -29,11 +29,11 @@ impl Component for Container {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, self.container_width().to_class());
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let output = self.children().render(cx);
if output.is_empty() {
return Ok(html! {});

View file

@ -19,7 +19,7 @@ use crate::LOCALES_BOOTSIER;
///
/// Ver ejemplo en el módulo [`dropdown`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[derive(AutoDefault, Debug, Getters)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Dropdown {
#[getters(skip)]
id: AttrId,
@ -56,14 +56,14 @@ impl Component for Dropdown {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
self.alter_classes(
ClassesOp::Prepend,
self.direction().class_with(*self.button_grouped()),
);
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
// Si no hay elementos en el menú, no se prepara.
let items = self.items().render(cx);
if items.is_empty() {
@ -247,10 +247,20 @@ impl Dropdown {
self
}
/// Modifica la lista de elementos (`children`) aplicando una operación [`TypedOp`].
/// Modifica la lista de elementos del menú aplicando una operación [`ChildOp`].
///
/// Para añadir elementos usa [`Child::with(item)`](Child::with):
///
/// ```rust,ignore
/// dropdown.with_items(ChildOp::Add(Child::with(dropdown::Item::link(...))));
/// dropdown.with_items(ChildOp::AddMany(vec![
/// Child::with(dropdown::Item::link(...)),
/// Child::with(dropdown::Item::divider()),
/// ]));
/// ```
#[builder_fn]
pub fn with_items(mut self, op: TypedOp<dropdown::Item>) -> Self {
self.items.alter_typed(op);
pub fn with_items(mut self, op: ChildOp) -> Self {
self.items.alter_child(op);
self
}
}

View file

@ -7,7 +7,7 @@ use pagetop::prelude::*;
///
/// Define internamente la naturaleza del elemento y su comportamiento al mostrarse o interactuar
/// con él.
#[derive(AutoDefault, Debug)]
#[derive(AutoDefault, Clone, Debug)]
pub enum ItemKind {
/// Elemento vacío, no produce salida.
#[default]
@ -43,7 +43,7 @@ pub enum ItemKind {
///
/// Permite definir el identificador, las clases de estilo adicionales y el tipo de interacción
/// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú.
#[derive(AutoDefault, Debug, Getters)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Item {
#[getters(skip)]
id: AttrId,
@ -62,7 +62,7 @@ impl Component for Item {
self.id.get()
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self.item_kind() {
ItemKind::Void => html! {},

View file

@ -27,7 +27,7 @@ use crate::theme::form;
/// .with_classes(ClassesOp::Add, "mb-3")
/// .add_child(Input::new().with_name("q"));
/// ```
#[derive(AutoDefault, Debug, Getters)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Form {
#[getters(skip)]
id: AttrId,
@ -48,11 +48,11 @@ impl Component for Form {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, "form");
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let method = match self.method() {
form::Method::Post => Some("post"),
form::Method::Get => None,

View file

@ -3,7 +3,7 @@ use pagetop::prelude::*;
/// Agrupa controles relacionados de un formulario (`<fieldset>`).
///
/// Se usa para mejorar la accesibilidad cuando se acompaña de una leyenda que encabeza el grupo.
#[derive(AutoDefault, Debug, Getters)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Fieldset {
#[getters(skip)]
id: AttrId,
@ -22,7 +22,7 @@ impl Component for Fieldset {
self.id.get()
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(html! {
fieldset id=[self.id()] class=[self.classes().get()] disabled[*self.disabled()] {
@if let Some(legend) = self.legend().lookup(cx) {

View file

@ -3,7 +3,7 @@ use pagetop::prelude::*;
use crate::theme::form;
use crate::LOCALES_BOOTSIER;
#[derive(AutoDefault, Debug, Getters)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Input {
classes: Classes,
input_type: form::InputType,
@ -29,14 +29,14 @@ impl Component for Input {
Self::default()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
self.alter_classes(
ClassesOp::Prepend,
util::join!("form-item form-type-", self.input_type().to_string()),
);
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let id = self.name().get().map(|name| util::join!("edit-", name));
Ok(html! {
div class=[self.classes().get()] {

View file

@ -2,7 +2,7 @@ use crate::prelude::*;
const DEFAULT_VIEWBOX: &str = "0 0 16 16";
#[derive(AutoDefault)]
#[derive(AutoDefault, Clone)]
pub enum IconKind {
#[default]
None,
@ -13,7 +13,7 @@ pub enum IconKind {
},
}
#[derive(AutoDefault, Debug, Getters)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Icon {
/// Devuelve las clases CSS asociadas al icono.
classes: Classes,
@ -26,7 +26,7 @@ impl Component for Icon {
Self::default()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
if !matches!(self.icon_kind(), IconKind::None) {
self.alter_classes(ClassesOp::Prepend, "icon");
}
@ -35,7 +35,7 @@ impl Component for Icon {
}
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self.icon_kind() {
IconKind::None => html! {},
IconKind::Font(_) => {

View file

@ -9,7 +9,7 @@ use crate::prelude::*;
/// ([`classes::Border`](crate::theme::classes::Border)) y **redondeo de esquinas**
/// ([`classes::Rounded`](crate::theme::classes::Rounded)).
/// - Resuelve el texto alternativo `alt` con **localización** mediante [`L10n`].
#[derive(AutoDefault, Debug, Getters)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Image {
#[getters(skip)]
id: AttrId,
@ -32,11 +32,11 @@ impl Component for Image {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, self.source().to_class());
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let dimensions = self.size().to_style();
let alt_text = self.alternative().lookup(cx).unwrap_or_default();
let is_decorative = alt_text.is_empty();

View file

@ -19,9 +19,9 @@
//! .add_item(nav::Item::dropdown(
//! Dropdown::new()
//! .with_title(L10n::n("Options"))
//! .with_items(TypedOp::AddMany(vec![
//! Typed::with(dropdown::Item::link(L10n::n("Action"), |_| "/action".into())),
//! Typed::with(dropdown::Item::link(L10n::n("Another"), |_| "/another".into())),
//! .with_items(ChildOp::AddMany(vec![
//! Child::with(dropdown::Item::link(L10n::n("Action"), |_| "/action".into())),
//! Child::with(dropdown::Item::link(L10n::n("Another"), |_| "/another".into())),
//! ])),
//! ))
//! .add_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#".into()));

View file

@ -10,7 +10,7 @@ use crate::prelude::*;
///
/// Ver ejemplo en el módulo [`nav`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[derive(AutoDefault, Debug, Getters)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Nav {
#[getters(skip)]
id: AttrId,
@ -33,7 +33,7 @@ impl Component for Nav {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, {
let mut classes = "nav".to_string();
self.nav_kind().push_class(&mut classes);
@ -42,7 +42,7 @@ impl Component for Nav {
});
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let items = self.items().render(cx);
if items.is_empty() {
return Ok(html! {});
@ -108,10 +108,22 @@ impl Nav {
self
}
/// Modifica la lista de elementos (`children`) aplicando una operación [`TypedOp`].
/// Modifica la lista de elementos del menú aplicando una operación [`ChildOp`].
///
/// Para añadir elementos usa [`Child::with(item)`](Child::with):
///
/// ```rust,ignore
/// nav.with_items(ChildOp::Add(Child::with(nav::Item::link(...))));
/// nav.with_items(ChildOp::AddMany(vec![
/// Child::with(nav::Item::link(...)),
/// Child::with(nav::Item::link_disabled(...)),
/// ]));
/// ```
///
/// Para la mayoría de los casos, [`add_item()`](Self::add_item) es más directo.
#[builder_fn]
pub fn with_items(mut self, op: TypedOp<nav::Item>) -> Self {
self.items.alter_typed(op);
pub fn with_items(mut self, op: ChildOp) -> Self {
self.items.alter_child(op);
self
}
}

View file

@ -10,7 +10,7 @@ use crate::LOCALES_BOOTSIER;
///
/// Define internamente la naturaleza del elemento y su comportamiento al mostrarse o interactuar
/// con él.
#[derive(AutoDefault, Debug)]
#[derive(AutoDefault, Clone, Debug)]
pub enum ItemKind {
/// Elemento vacío, no produce salida.
#[default]
@ -28,9 +28,9 @@ pub enum ItemKind {
},
/// Contenido HTML arbitrario. El componente [`Html`] se renderiza tal cual como elemento del
/// menú, sin añadir ningún comportamiento de navegación adicional.
Html(Typed<Html>),
Html(Slot<Html>),
/// Elemento que despliega un menú [`Dropdown`].
Dropdown(Typed<Dropdown>),
Dropdown(Slot<Dropdown>),
}
impl ItemKind {
@ -76,7 +76,7 @@ impl ItemKind {
///
/// Permite definir el identificador, las clases de estilo adicionales y el tipo de interacción
/// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú.
#[derive(AutoDefault, Debug, Getters)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Item {
#[getters(skip)]
id: AttrId,
@ -95,11 +95,11 @@ impl Component for Item {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, self.item_kind().to_class());
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self.item_kind() {
ItemKind::Void => html! {},
@ -159,7 +159,7 @@ impl Component for Item {
},
ItemKind::Dropdown(menu) => {
if let Some(dd) = menu.borrow() {
if let Some(dd) = menu.get() {
let items = dd.items().render(cx);
if items.is_empty() {
return Ok(html! {});
@ -264,7 +264,7 @@ impl Item {
/// con las clases de navegación asociadas a [`Item`].
pub fn html(html: Html) -> Self {
Self {
item_kind: ItemKind::Html(Typed::with(html)),
item_kind: ItemKind::Html(Slot::with(html)),
..Default::default()
}
}
@ -276,7 +276,7 @@ impl Item {
/// a su representación en [`Nav`].
pub fn dropdown(menu: Dropdown) -> Self {
Self {
item_kind: ItemKind::Dropdown(Typed::with(menu)),
item_kind: ItemKind::Dropdown(Slot::with(menu)),
..Default::default()
}
}

View file

@ -11,12 +11,12 @@ use crate::prelude::*;
/// - Si no hay imagen ([`with_image()`](Self::with_image)) ni título
/// ([`with_title()`](Self::with_title)), la marca de identidad no se renderiza.
/// - El eslogan ([`with_slogan()`](Self::with_slogan)) es opcional; por defecto no tiene contenido.
#[derive(AutoDefault, Debug, Getters)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Brand {
#[getters(skip)]
id: AttrId,
/// Devuelve la imagen de marca (si la hay).
image: Typed<Image>,
image: Slot<Image>,
/// Devuelve el título de la identidad de marca.
#[default(_code = "L10n::n(&global::SETTINGS.app.name)")]
title: L10n,
@ -36,7 +36,7 @@ impl Component for Brand {
self.id.get()
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let image = self.image().render(cx);
let title = self.title().using(cx);
if title.is_empty() && image.is_empty() {

View file

@ -14,7 +14,7 @@ const TOGGLE_OFFCANVAS: &str = "offcanvas";
///
/// Ver ejemplos en el módulo [`navbar`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[derive(AutoDefault, Debug, Getters)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Navbar {
#[getters(skip)]
id: AttrId,
@ -39,7 +39,7 @@ impl Component for Navbar {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, {
let mut classes = "navbar".to_string();
self.expand().push_class(&mut classes, "navbar-expand", "");
@ -48,7 +48,7 @@ impl Component for Navbar {
});
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
// Botón de despliegue (colapso u offcanvas) para la barra.
fn button(cx: &mut Context, data_bs_toggle: &str, id_content: &str) -> Markup {
let id_content_target = util::join!("#", id_content);
@ -133,7 +133,7 @@ impl Component for Navbar {
@let id_content = offcanvas.id().unwrap_or_default();
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.borrow() {
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(self.items())))
}
},
@ -144,7 +144,7 @@ impl Component for Navbar {
(brand.render(cx))
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.borrow() {
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(self.items())))
}
},
@ -155,7 +155,7 @@ impl Component for Navbar {
(button(cx, TOGGLE_OFFCANVAS, &id_content))
(brand.render(cx))
@if let Some(oc) = offcanvas.borrow() {
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(self.items())))
}
},
@ -179,37 +179,37 @@ impl Navbar {
/// Crea una barra de navegación **con marca a la izquierda**, siempre visible.
pub fn simple_brand_left(brand: navbar::Brand) -> Self {
Self::default().with_layout(navbar::Layout::SimpleBrandLeft(Typed::with(brand)))
Self::default().with_layout(navbar::Layout::SimpleBrandLeft(Slot::with(brand)))
}
/// Crea una barra de navegación con **marca a la izquierda** y **botón a la derecha**.
pub fn brand_left(brand: navbar::Brand) -> Self {
Self::default().with_layout(navbar::Layout::BrandLeft(Typed::with(brand)))
Self::default().with_layout(navbar::Layout::BrandLeft(Slot::with(brand)))
}
/// Crea una barra de navegación con **botón a la izquierda** y **marca a la derecha**.
pub fn brand_right(brand: navbar::Brand) -> Self {
Self::default().with_layout(navbar::Layout::BrandRight(Typed::with(brand)))
Self::default().with_layout(navbar::Layout::BrandRight(Slot::with(brand)))
}
/// Crea una barra de navegación cuyo contenido se muestra en un **offcanvas**.
pub fn offcanvas(oc: Offcanvas) -> Self {
Self::default().with_layout(navbar::Layout::Offcanvas(Typed::with(oc)))
Self::default().with_layout(navbar::Layout::Offcanvas(Slot::with(oc)))
}
/// Crea una barra de navegación con **marca a la izquierda** y contenido en **offcanvas**.
pub fn offcanvas_brand_left(brand: navbar::Brand, oc: Offcanvas) -> Self {
Self::default().with_layout(navbar::Layout::OffcanvasBrandLeft(
Typed::with(brand),
Typed::with(oc),
Slot::with(brand),
Slot::with(oc),
))
}
/// Crea una barra de navegación con **marca a la derecha** y contenido en **offcanvas**.
pub fn offcanvas_brand_right(brand: navbar::Brand, oc: Offcanvas) -> Self {
Self::default().with_layout(navbar::Layout::OffcanvasBrandRight(
Typed::with(brand),
Typed::with(oc),
Slot::with(brand),
Slot::with(oc),
))
}
@ -262,10 +262,22 @@ impl Navbar {
self
}
/// Modifica la lista de contenidos (`children`) aplicando una operación [`TypedOp`].
/// Modifica la lista de contenidos de la barra aplicando una operación [`ChildOp`].
///
/// Para añadir elementos usa [`Child::with(item)`](Child::with):
///
/// ```rust,ignore
/// navbar.with_items(ChildOp::Add(Child::with(navbar::Item::nav(...))));
/// navbar.with_items(ChildOp::AddMany(vec![
/// Child::with(navbar::Item::nav(...)),
/// Child::with(navbar::Item::text(...)),
/// ]));
/// ```
///
/// Para la mayoría de los casos, [`add_item()`](Self::add_item) es más directo.
#[builder_fn]
pub fn with_items(mut self, op: TypedOp<navbar::Item>) -> Self {
self.items.alter_typed(op);
pub fn with_items(mut self, op: ChildOp) -> Self {
self.items.alter_child(op);
self
}
}

View file

@ -7,7 +7,7 @@ use crate::prelude::*;
/// Cada variante determina qué se renderiza y cómo. Estos elementos se colocan **dentro del
/// contenido** de la barra (la parte colapsable, el *offcanvas* o el bloque simple), por lo que son
/// independientes de la marca o del botón que ya pueda definir el propio [`navbar::Layout`].
#[derive(AutoDefault, Debug)]
#[derive(AutoDefault, Clone, Debug)]
pub enum Item {
/// Sin contenido, no produce salida.
#[default]
@ -17,9 +17,9 @@ pub enum Item {
/// Útil cuando el [`navbar::Layout`] no incluye marca, y se quiere incluir dentro del área
/// colapsable/*offcanvas*. Si el *layout* ya muestra una marca, esta variante no la sustituye,
/// sólo añade otra dentro del bloque de contenidos.
Brand(Typed<navbar::Brand>),
Brand(Slot<navbar::Brand>),
/// Representa un menú de navegación [`Nav`](crate::theme::Nav).
Nav(Typed<Nav>),
Nav(Slot<Nav>),
/// Representa un *texto localizado* libre.
Text(L10n),
}
@ -38,20 +38,20 @@ impl Component for Item {
}
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
if let Self::Nav(nav) = self {
if let Some(mut nav) = nav.borrow_mut() {
if let Some(mut nav) = nav.get() {
nav.alter_classes(ClassesOp::Prepend, "navbar-nav");
}
}
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self {
Self::Void => html! {},
Self::Brand(brand) => html! { (brand.render(cx)) },
Self::Nav(nav) => {
if let Some(nav) = nav.borrow() {
if let Some(nav) = nav.get() {
let items = nav.items().render(cx);
if items.is_empty() {
return Ok(html! {});
@ -80,12 +80,12 @@ impl Item {
/// Pensado para barras colapsables u offcanvas donde se quiere que la marca aparezca en la zona
/// desplegable.
pub fn brand(brand: navbar::Brand) -> Self {
Self::Brand(Typed::with(brand))
Self::Brand(Slot::with(brand))
}
/// Crea un elemento de tipo [`Nav`] para añadir al contenido de [`Navbar`].
pub fn nav(item: Nav) -> Self {
Self::Nav(Typed::with(item))
Self::Nav(Slot::with(item))
}
/// Crea un elemento con un *texto localizado*, mostrado sin interacción.

View file

@ -5,7 +5,7 @@ use crate::prelude::*;
// **< Layout >*************************************************************************************
/// Representa los diferentes tipos de presentación de una barra de navegación [`Navbar`].
#[derive(AutoDefault, Debug)]
#[derive(AutoDefault, Clone, Debug)]
pub enum Layout {
/// Barra simple, sin marca de identidad y sin botón de despliegue.
///
@ -19,24 +19,24 @@ pub enum Layout {
/// Barra simple, con marca de identidad a la izquierda y sin botón de despliegue.
///
/// La barra de navegación no se colapsa.
SimpleBrandLeft(Typed<navbar::Brand>),
SimpleBrandLeft(Slot<navbar::Brand>),
/// Barra con marca de identidad a la izquierda y botón de despliegue a la derecha.
BrandLeft(Typed<navbar::Brand>),
BrandLeft(Slot<navbar::Brand>),
/// Barra con botón de despliegue a la izquierda y marca de identidad a la derecha.
BrandRight(Typed<navbar::Brand>),
BrandRight(Slot<navbar::Brand>),
/// Contenido en [`Offcanvas`], con botón de despliegue a la izquierda y sin marca de identidad.
Offcanvas(Typed<Offcanvas>),
Offcanvas(Slot<Offcanvas>),
/// Contenido en [`Offcanvas`], con marca de identidad a la izquierda y botón de despliegue a la
/// derecha.
OffcanvasBrandLeft(Typed<navbar::Brand>, Typed<Offcanvas>),
OffcanvasBrandLeft(Slot<navbar::Brand>, Slot<Offcanvas>),
/// Contenido en [`Offcanvas`], con botón de despliegue a la izquierda y marca de identidad a la
/// derecha.
OffcanvasBrandRight(Typed<navbar::Brand>, Typed<Offcanvas>),
OffcanvasBrandRight(Slot<navbar::Brand>, Slot<Offcanvas>),
}
// **< Position >***********************************************************************************

View file

@ -21,7 +21,7 @@ use crate::LOCALES_BOOTSIER;
///
/// Ver ejemplo en el módulo [`offcanvas`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[derive(AutoDefault, Debug, Getters)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Offcanvas {
#[getters(skip)]
id: AttrId,
@ -52,7 +52,7 @@ impl Component for Offcanvas {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, {
let mut classes = "offcanvas".to_string();
self.breakpoint().push_class(&mut classes, "offcanvas", "");
@ -62,7 +62,7 @@ impl Component for Offcanvas {
});
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(self.render_offcanvas(cx, None))
}
}

View file

@ -5,9 +5,9 @@ use pagetop::prelude::*;
/// Tipo de función para manipular componentes y su contexto de renderizado.
///
/// Se usa en [`action::component::BeforeRender`] y [`action::component::AfterRender`] para alterar
/// el comportamiento predefinido de los componentes.
/// el comportamiento predefinido de los componentes antes y después de renderizarlos.
///
/// Recibe referencias mutables (`&mut`) del componente `component` y del contexto `cx`.
/// Recibe referencias mutables del componente `component` y del contexto `cx`.
pub type FnActionWithComponent<C> = fn(component: &mut C, cx: &mut Context);
/// Tipo de función para alterar el [`Markup`] generado por un componente.
@ -18,10 +18,12 @@ pub type FnActionWithComponent<C> = fn(component: &mut C, cx: &mut Context);
/// sustituciones, concatenaciones y cualquier otra primitiva de trabajo con cadenas.
///
/// La función recibe una referencia inmutable al componente `component` (el renderizado ya ha
/// concluido, solo se necesita leer su estado), una referencia mutable al contexto `cx`, y toma
/// posesión del `markup` producido hasta ese momento. Devuelve el nuevo [`Markup`] transformado,
/// que se encadena como entrada para la siguiente acción registrada, si la hay.
pub type FnActionTransformMarkup<C> = fn(component: &C, cx: &mut Context, markup: Markup) -> Markup;
/// concluido, solo se necesita leer su estado), y al contexto `cx` (solo para consulta), y toma
/// posesión del `markup` producido hasta ese momento.
///
/// Devuelve el nuevo [`Markup`] transformado, que se encadena como entrada para la siguiente acción
/// registrada, si la hay.
pub type FnActionTransformMarkup<C> = fn(component: &C, cx: &Context, markup: Markup) -> Markup;
mod before_render_component;
pub use before_render_component::*;

View file

@ -54,7 +54,7 @@ impl<C: Component> TransformMarkup<C> {
/// Despacha las acciones encadenando el [`Markup`] entre cada una.
#[inline]
pub(crate) fn dispatch(component: &C, cx: &mut Context, markup: Markup) -> Markup {
pub(crate) fn dispatch(component: &C, cx: &Context, markup: Markup) -> Markup {
let mut output = markup;
// Primero despacha las acciones para el tipo de componente.

View file

@ -4,7 +4,7 @@ use crate::prelude::*;
///
/// Los bloques se utilizan como contenedores de otros componentes o contenidos, con un título
/// opcional y un cuerpo que sólo se renderiza si existen componentes hijos (*children*).
#[derive(AutoDefault, Debug, Getters)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Block {
#[getters(skip)]
id: AttrId,
@ -25,11 +25,11 @@ impl Component for Block {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, "block");
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let block_body = self.children().render(cx);
if block_body.is_empty() {

View file

@ -1,6 +1,7 @@
use crate::prelude::*;
use std::fmt;
use std::sync::Arc;
/// Componente básico que renderiza dinámicamente código HTML según el contexto.
///
@ -31,7 +32,8 @@ use std::fmt;
/// }
/// });
/// ```
pub struct Html(Box<dyn Fn(&mut Context) -> Markup + Send + Sync>);
#[derive(Clone)]
pub struct Html(Arc<dyn Fn(&mut Context) -> Markup + Send + Sync>);
impl fmt::Debug for Html {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@ -52,7 +54,7 @@ impl Component for Html {
Self::default()
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(self.html(cx))
}
}
@ -62,26 +64,26 @@ impl Html {
/// Crea una instancia que generará el `Markup`, con acceso opcional al contexto.
///
/// El método [`Self::prepare_component()`] delega el renderizado a la función que aquí se
/// proporciona, que recibe una referencia mutable al [`Context`].
/// El método [`Self::prepare()`] delega el renderizado a la función que aquí se proporciona,
/// con una llamada que requiere una referencia mutable al [`Context`].
pub fn with<F>(f: F) -> Self
where
F: Fn(&mut Context) -> Markup + Send + Sync + 'static,
{
Html(Box::new(f))
Html(Arc::new(f))
}
/// Sustituye la función que genera el `Markup`.
///
/// Permite a otras extensiones modificar la función de renderizado que se ejecutará cuando
/// [`Self::prepare_component()`] invoque esta instancia. La nueva función también recibe una
/// referencia al [`Context`].
/// [`Self::prepare()`] invoque esta instancia. La nueva función también recibe una referencia
/// mutable al [`Context`].
#[builder_fn]
pub fn with_fn<F>(mut self, f: F) -> Self
where
F: Fn(&mut Context) -> Markup + Send + Sync + 'static,
{
self.0 = Box::new(f);
self.0 = Arc::new(f);
self
}
@ -91,8 +93,8 @@ impl Html {
///
/// Normalmente no se invoca manualmente, ya que el proceso de renderizado de los componentes lo
/// invoca automáticamente durante la construcción de la página. Puede usarse, no obstante, para
/// sobrescribir [`prepare_component()`](crate::core::component::Component::prepare_component)
/// y alterar el comportamiento del componente.
/// sobrescribir [`prepare()`](crate::core::component::Component::prepare) y alterar el
/// comportamiento del componente.
pub fn html(&self, cx: &mut Context) -> Markup {
(self.0)(cx)
}

View file

@ -76,7 +76,7 @@ pub enum IntroOpening {
/// })),
/// );
/// ```
#[derive(Debug, Getters)]
#[derive(Clone, Debug, Getters)]
pub struct Intro {
/// Devuelve el título de entrada.
title: L10n,
@ -109,13 +109,10 @@ impl Component for Intro {
Self::default()
}
fn setup_before_prepare(&mut self, cx: &mut Context) {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
cx.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/css/intro.css").with_version(PAGETOP_VERSION),
));
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
if *self.opening() == IntroOpening::PageTop {
cx.alter_assets(AssetsOp::AddJavaScript(JavaScript::on_load_async("intro-js", |cx|
util::indoc!(r#"

View file

@ -8,7 +8,7 @@ const LINK: &str = "<a href=\"https://pagetop.cillero.es\" rel=\"noopener norefe
/// Por defecto, usando [`default()`](Self::default) sólo se muestra un reconocimiento a PageTop.
/// Sin embargo, se puede usar [`new()`](Self::new) para crear una instancia con un texto de
/// copyright predeterminado.
#[derive(AutoDefault, Debug, Getters)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct PoweredBy {
/// Devuelve el texto de copyright actual, si existe.
copyright: Option<String>,
@ -25,7 +25,7 @@ impl Component for PoweredBy {
PoweredBy { copyright: Some(c) }
}
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(html! {
div id=[self.id()] class="poweredby" {
@if let Some(c) = self.copyright() {

View file

@ -6,12 +6,13 @@ mod error;
pub use error::ComponentError;
mod definition;
pub use definition::{Component, ComponentRender};
pub use definition::{Component, ComponentClone, ComponentRender};
mod children;
pub use children::Slot;
pub use children::Children;
pub use children::ComponentGuard;
pub use children::{Child, ChildOp};
pub use children::{Typed, TypedOp};
mod message;
pub use message::{MessageLevel, StatusMessage};
@ -29,7 +30,7 @@ pub use context::{AssetsOp, Context, ContextError, Contextual};
///
/// ```rust
/// # use pagetop::prelude::*;
/// #[derive(AutoDefault)]
/// #[derive(AutoDefault, Clone)]
/// struct SampleComponent {
/// renderable: Option<FnIsRenderable>,
/// }
@ -39,12 +40,12 @@ pub use context::{AssetsOp, Context, ContextError, Contextual};
/// Self::default()
/// }
///
/// fn is_renderable(&self, cx: &mut Context) -> bool {
/// fn is_renderable(&self, cx: &Context) -> bool {
/// // Si hay callback, se usa; en caso contrario, se renderiza por defecto.
/// self.renderable.map_or(true, |f| f(cx))
/// }
///
/// fn prepare_component(&self, _cx: &mut Context) -> Result<Markup, ComponentError> {
/// fn prepare(&self, _cx: &mut Context) -> Result<Markup, ComponentError> {
/// Ok(html! { "Visible component" })
/// }
/// }

View file

@ -2,27 +2,28 @@ use crate::core::component::{Component, Context};
use crate::html::{html, Markup};
use crate::{builder_fn, AutoDefault, UniqueId};
use parking_lot::RwLock;
use parking_lot::Mutex;
pub use parking_lot::RwLockReadGuard as ComponentReadGuard;
pub use parking_lot::RwLockWriteGuard as ComponentWriteGuard;
pub use parking_lot::MutexGuard as ComponentGuard;
use std::fmt;
use std::sync::Arc;
use std::vec::IntoIter;
/// Representa un componente encapsulado de forma segura y compartida.
///
/// Esta estructura permite manipular y renderizar un componente que implemente [`Component`], y
/// habilita acceso concurrente mediante [`Arc<RwLock<_>>`].
#[derive(AutoDefault, Clone)]
pub struct Child(Option<Arc<RwLock<dyn Component>>>);
/// Representa un componente hijo encapsulado para su uso en una lista [`Children`].
#[derive(AutoDefault)]
pub struct Child(Option<Mutex<Box<dyn Component>>>);
impl Clone for Child {
fn clone(&self) -> Self {
Child(self.0.as_ref().map(|m| Mutex::new(m.lock().clone_box())))
}
}
impl fmt::Debug for Child {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.0 {
None => write!(f, "Child(None)"),
Some(c) => write!(f, "Child({})", c.read().name()),
Some(c) => write!(f, "Child({})", c.lock().name()),
}
}
}
@ -30,7 +31,7 @@ impl fmt::Debug for Child {
impl Child {
/// Crea un nuevo `Child` a partir de un componente.
pub fn with(component: impl Component) -> Self {
Child(Some(Arc::new(RwLock::new(component))))
Child(Some(Mutex::new(Box::new(component))))
}
// **< Child BUILDER >**************************************************************************
@ -40,11 +41,7 @@ impl Child {
/// Si se proporciona `Some(component)`, se encapsula como [`Child`]; y si es `None`, se limpia.
#[builder_fn]
pub fn with_component<C: Component>(mut self, component: Option<C>) -> Self {
if let Some(c) = component {
self.0 = Some(Arc::new(RwLock::new(c)));
} else {
self.0 = None;
}
self.0 = component.map(|c| Mutex::new(Box::new(c) as Box<dyn Component>));
self
}
@ -53,14 +50,14 @@ impl Child {
/// Devuelve el identificador del componente, si existe y está definido.
#[inline]
pub fn id(&self) -> Option<String> {
self.0.as_ref().and_then(|c| c.read().id())
self.0.as_ref().and_then(|c| c.lock().id())
}
// **< Child RENDER >***************************************************************************
/// Renderiza el componente con el contexto proporcionado.
pub fn render(&self, cx: &mut Context) -> Markup {
self.0.as_ref().map_or(html! {}, |c| c.write().render(cx))
self.0.as_ref().map_or(html! {}, |c| c.lock().render(cx))
}
// **< Child HELPERS >**************************************************************************
@ -68,121 +65,128 @@ impl Child {
/// Devuelve el [`UniqueId`] del tipo del componente, si existe.
#[inline]
fn type_id(&self) -> Option<UniqueId> {
self.0.as_ref().map(|c| c.read().type_id())
self.0.as_ref().map(|c| c.lock().type_id())
}
}
impl<C: Component + 'static> From<Slot<C>> for Child {
/// Convierte un [`Slot<C>`] en un [`Child`], consumiendo el componente tipado.
///
/// Útil cuando se tiene un [`Slot`] y se necesita añadirlo a una lista [`Children`]:
///
/// ```rust,ignore
/// children.add(Child::from(my_slot));
/// // o equivalentemente:
/// children.add(my_slot.into());
/// ```
fn from(typed: Slot<C>) -> Self {
if let Some(m) = typed.0 {
Child(Some(Mutex::new(
Box::new(m.into_inner()) as Box<dyn Component>
)))
} else {
Child(None)
}
}
}
impl<T: Component + 'static> From<T> for Child {
#[inline]
fn from(component: T) -> Self {
Child::with(component)
}
}
// *************************************************************************************************
/// Variante tipada de [`Child`] para evitar conversiones de tipo durante el uso.
/// Variante tipada de [`Child`] para componentes con un tipo concreto conocido.
///
/// Esta estructura permite manipular y renderizar un componente concreto que implemente
/// [`Component`], y habilita acceso concurrente mediante [`Arc<RwLock<_>>`].
#[derive(AutoDefault, Clone)]
pub struct Typed<C: Component>(Option<Arc<RwLock<C>>>);
/// A diferencia de [`Child`], que encapsula cualquier componente como `dyn Component`, `Slot`
/// mantiene el tipo concreto `C` y permite acceder directamente a sus métodos específicos a través
/// de [`get()`](Slot::get).
///
/// Se utiliza habitualmente para incrustar un componente dentro de otro cuando no se necesita una
/// lista completa de hijos ([`Children`]), sino un único componente tipado en un campo concreto.
#[derive(AutoDefault)]
pub struct Slot<C: Component>(Option<Mutex<C>>);
impl<C: Component> fmt::Debug for Typed<C> {
impl<C: Component + Clone> Clone for Slot<C> {
fn clone(&self) -> Self {
Slot(self.0.as_ref().map(|m| Mutex::new(m.lock().clone())))
}
}
impl<C: Component> fmt::Debug for Slot<C> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.0 {
None => write!(f, "Typed(None)"),
Some(c) => write!(f, "Typed({})", c.read().name()),
None => write!(f, "Slot(None)"),
Some(c) => write!(f, "Slot({})", c.lock().name()),
}
}
}
impl<C: Component> Typed<C> {
/// Crea un nuevo `Typed` a partir de un componente.
impl<C: Component> Slot<C> {
/// Crea un nuevo `Slot` a partir de un componente.
pub fn with(component: C) -> Self {
Typed(Some(Arc::new(RwLock::new(component))))
Slot(Some(Mutex::new(component)))
}
// **< Typed BUILDER >**************************************************************************
// **< Slot BUILDER >*********************************************************************
/// Establece un componente nuevo, o lo vacía.
///
/// Si se proporciona `Some(component)`, se encapsula como [`Typed`]; y si es `None`, se limpia.
/// Si se proporciona `Some(component)`, se encapsula como [`Slot`]; y si es `None`, se
/// limpia.
#[builder_fn]
pub fn with_component(mut self, component: Option<C>) -> Self {
self.0 = component.map(|c| Arc::new(RwLock::new(c)));
self.0 = component.map(Mutex::new);
self
}
// **< Typed GETTERS >**************************************************************************
// **< Slot GETTERS >*********************************************************************
/// Devuelve el identificador del componente, si existe y está definido.
#[inline]
pub fn id(&self) -> Option<String> {
self.0.as_ref().and_then(|c| c.read().id())
self.0.as_ref().and_then(|c| c.lock().id())
}
/// Devuelve una **referencia inmutable** al componente interno.
/// Devuelve un acceso al componente interno.
///
/// - Devuelve `Some(ComponentReadGuard<C>)` si existe el componente, o `None` si está vacío.
/// - Permite realizar **múltiples lecturas concurrentes**.
/// - Mientras el *guard* esté activo, no se pueden realizar escrituras concurrentes (ver
/// [`borrow_mut`](Self::borrow_mut)).
/// - Devuelve `Some(ComponentGuard<C>)` si existe el componente, o `None` si está vacío.
/// - El acceso es **exclusivo**: mientras el *guard* esté activo, no habrá otros accesos.
/// - Se recomienda mantener el *guard* **el menor tiempo posible** para evitar bloqueos
/// innecesarios.
/// - Para modificar el componente, declara el *guard* como `mut`:
/// `if let Some(mut c) = child.get() { c.alter_title(...); }`.
///
/// # Ejemplo
///
/// Lectura del nombre del componente:
///
/// ```rust
/// # use pagetop::prelude::*;
/// let typed = Typed::with(Html::with(|_| html! { "Prueba" }));
/// let child = Slot::with(Html::with(|_| html! { "Prueba" }));
/// {
/// if let Some(component) = typed.borrow() {
/// if let Some(component) = child.get() {
/// assert_eq!(component.name(), "Html");
/// }
/// }; // El *guard* se libera aquí, antes del *drop* de `typed`.
/// ```
pub fn borrow(&self) -> Option<ComponentReadGuard<'_, C>> {
self.0.as_ref().map(|a| a.read())
}
/// Obtiene una **referencia mutable exclusiva** al componente interno.
/// }; // El *guard* se libera aquí, antes del *drop* de `child`.
///
/// - Devuelve `Some(ComponentWriteGuard<C>)` si existe el componente, o `None` si está vacío.
/// - **Exclusivo**: mientras el *guard* esté activo, no habrá otros lectores ni escritores.
/// - Usar sólo para operaciones que **modifican** el estado interno.
/// - Igual que con [`borrow`](Self::borrow), se recomienda mantener el *guard* en un **ámbito
/// reducido**.
///
/// # Ejemplo
///
/// Acceso mutable (ámbito corto):
///
/// ```rust
/// # use pagetop::prelude::*;
/// let typed = Typed::with(Block::new().with_title(L10n::n("Título")));
/// let child = Slot::with(Block::new().with_title(L10n::n("Título")));
/// {
/// if let Some(mut component) = typed.borrow_mut() {
/// if let Some(mut component) = child.get() {
/// component.alter_title(L10n::n("Nuevo título"));
/// }
/// }; // El *guard* se libera aquí, antes del *drop* de `typed`.
/// }; // El *guard* se libera aquí, antes del *drop* de `child`.
/// ```
pub fn borrow_mut(&self) -> Option<ComponentWriteGuard<'_, C>> {
self.0.as_ref().map(|a| a.write())
pub fn get(&self) -> Option<ComponentGuard<'_, C>> {
self.0.as_ref().map(|m| m.lock())
}
// **< Typed RENDER >***************************************************************************
// **< Slot RENDER >**********************************************************************
/// Renderiza el componente con el contexto proporcionado.
pub fn render(&self, cx: &mut Context) -> Markup {
self.0.as_ref().map_or(html! {}, |c| c.write().render(cx))
}
// **< Typed HELPERS >**************************************************************************
/// Método interno para convertir un componente tipado en un [`Child`].
#[inline]
fn into(self) -> Child {
if let Some(c) = &self.0 {
Child(Some(c.clone()))
} else {
Child(None)
}
self.0.as_ref().map_or(html! {}, |c| c.lock().render(cx))
}
}
@ -202,20 +206,6 @@ pub enum ChildOp {
Reset,
}
/// Operaciones con un componente hijo tipado [`Typed<C>`] en una lista [`Children`].
pub enum TypedOp<C: Component> {
Add(Typed<C>),
AddIfEmpty(Typed<C>),
AddMany(Vec<Typed<C>>),
InsertAfterId(&'static str, Typed<C>),
InsertBeforeId(&'static str, Typed<C>),
Prepend(Typed<C>),
PrependMany(Vec<Typed<C>>),
RemoveById(&'static str),
ReplaceById(&'static str, Typed<C>),
Reset,
}
/// Lista ordenada de componentes hijo ([`Child`]) mantenida por un componente padre.
///
/// Esta lista permite añadir, modificar, renderizar y consultar componentes hijo en orden de
@ -235,15 +225,6 @@ impl Children {
Self::default().with_child(ChildOp::Add(child))
}
/// Fusiona varias listas de `Children` en una sola.
pub(crate) fn merge(mixes: &[Option<&Children>]) -> Self {
let mut opt = Children::default();
for m in mixes.iter().flatten() {
opt.0.extend(m.0.iter().cloned());
}
opt
}
// **< Children BUILDER >***********************************************************************
/// Ejecuta una operación con [`ChildOp`] en la lista.
@ -263,23 +244,6 @@ impl Children {
}
}
/// Ejecuta una operación con [`TypedOp`] en la lista.
#[builder_fn]
pub fn with_typed<C: Component>(mut self, op: TypedOp<C>) -> Self {
match op {
TypedOp::Add(typed) => self.add(typed.into()),
TypedOp::AddIfEmpty(typed) => self.add_if_empty(typed.into()),
TypedOp::AddMany(many) => self.add_many(many.into_iter().map(Typed::<C>::into)),
TypedOp::InsertAfterId(id, typed) => self.insert_after_id(id, typed.into()),
TypedOp::InsertBeforeId(id, typed) => self.insert_before_id(id, typed.into()),
TypedOp::Prepend(typed) => self.prepend(typed.into()),
TypedOp::PrependMany(many) => self.prepend_many(many.into_iter().map(Typed::<C>::into)),
TypedOp::RemoveById(id) => self.remove_by_id(id),
TypedOp::ReplaceById(id, typed) => self.replace_by_id(id, typed.into()),
TypedOp::Reset => self.reset(),
}
}
/// Añade un componente hijo al final de la lista.
///
/// Es un atajo para `children.alter_child(ChildOp::Add(child))`.

View file

@ -10,6 +10,7 @@ use crate::service::HttpRequest;
use crate::{builder_fn, util, CowStr};
use std::any::Any;
use std::cell::Cell;
use std::collections::HashMap;
use std::fmt;
@ -214,7 +215,7 @@ pub trait Contextual: LangId {
/// nombre corto del tipo en minúsculas y `<n>` un contador incremental interno del contexto. Es
/// útil para asignar identificadores HTML predecibles cuando el componente no recibe uno
/// explícito.
fn required_id<T>(&mut self, id: Option<String>) -> String;
fn required_id<T>(&self, id: Option<String>) -> String;
/// Acumula un [`StatusMessage`] en el contexto para notificar al visitante.
///
@ -291,7 +292,7 @@ pub struct Context {
javascripts: Assets<JavaScript>, // Scripts JavaScript.
regions : ChildrenInRegions, // Regiones de componentes para renderizar.
params : HashMap<&'static str, (Box<dyn Any>, &'static str)>, // Parámetros en ejecución.
id_counter : usize, // Contador para generar identificadores únicos.
id_counter : Cell<usize>, // Cell permite incrementarlo desde &self en required_id().
messages : Vec<StatusMessage>, // Mensajes de usuario acumulados.
}
@ -319,7 +320,7 @@ impl Context {
javascripts: Assets::<JavaScript>::new(),
regions : ChildrenInRegions::default(),
params : HashMap::default(),
id_counter : 0,
id_counter : Cell::new(0),
messages : Vec::new(),
}
}
@ -593,7 +594,7 @@ impl Contextual for Context {
// **< Contextual HELPERS >*********************************************************************
fn required_id<T>(&mut self, id: Option<String>) -> String {
fn required_id<T>(&self, id: Option<String>) -> String {
if let Some(id) = id {
id
} else {
@ -607,8 +608,8 @@ impl Contextual for Context {
} else {
prefix
};
self.id_counter += 1;
util::join!(prefix, "-", self.id_counter.to_string())
self.id_counter.set(self.id_counter.get() + 1);
util::join!(prefix, "-", self.id_counter.get().to_string())
}
}

View file

@ -4,6 +4,16 @@ use crate::core::theme::ThemeRef;
use crate::core::{AnyInfo, TypeInfo};
use crate::html::{html, Markup};
/// Permite clonar un componente.
///
/// Se implementa automáticamente para todo tipo que implemente [`Component`] y [`Clone`]. El método
/// [`clone_box`](Self::clone_box) devuelve una copia en la *pila* del componente original, lo que
/// permite clonar componentes sin conocer su tipo concreto en tiempo de compilación.
pub trait ComponentClone {
/// Devuelve un clon del componente encapsulado en un [`Box<dyn Component>`].
fn clone_box(&self) -> Box<dyn Component>;
}
/// Define la función de renderizado para todos los componentes.
///
/// Este *trait* se implementa automáticamente en cualquier tipo (componente) que implemente
@ -20,7 +30,16 @@ pub trait ComponentRender {
/// [`Getters`](crate::Getters) para acceder a sus datos. Deberán implementar explícitamente el
/// método [`new()`](Self::new) y podrán sobrescribir los demás métodos para personalizar su
/// comportamiento.
pub trait Component: AnyInfo + ComponentRender + Send + Sync {
///
/// # Requisito: derivar `Clone`
///
/// Todo tipo que implemente `Component` **debe** derivar también [`Clone`]. Aunque el compilador
/// no lo exige directamente —hacerlo rompería la seguridad de objeto de `dyn Component`—,
/// [`ComponentClone`] se implementa automáticamente mediante una *impl* blanket solo para los
/// tipos que sean `Component + Clone + 'static`. Sin `Clone`, habría que implementar
/// [`ComponentClone`] a mano, y el componente no podría registrarse en
/// [`InRegion`](crate::core::theme::InRegion).
pub trait Component: AnyInfo + ComponentClone + ComponentRender + Send + Sync {
/// Crea una nueva instancia del componente.
///
/// Por convención suele devolver `Self::default()`.
@ -54,57 +73,70 @@ pub trait Component: AnyInfo + ComponentRender + Send + Sync {
///
/// Por defecto, todos los componentes son renderizables (`true`). Sin embargo, este método
/// puede sobrescribirse para decidir dinámicamente si los componentes de este tipo se
/// renderizan o no en función del contexto de renderizado.
/// renderizan o no en función del contexto de renderizado. Recibe solo una referencia
/// compartida al contexto porque su único propósito es consultar datos, no modificarlos.
///
/// También puede asignarse una función [`FnIsRenderable`](super::FnIsRenderable) a un campo del
/// componente para permitir que instancias concretas del mismo puedan decidir dinámicamente si
/// se renderizan o no.
#[allow(unused_variables)]
fn is_renderable(&self, cx: &mut Context) -> bool {
fn is_renderable(&self, cx: &Context) -> bool {
true
}
/// Configura el componente justo antes de preparar el renderizado.
/// Configura el estado interno del componente antes de generar el marcado.
///
/// Este método puede sobrescribirse para modificar la estructura interna del componente o el
/// contexto antes de renderizarlo. Por defecto no hace nada.
/// Segundo paso del [ciclo de renderizado](ComponentRender): se ejecuta tras comprobar
/// [`is_renderable()`](Self::is_renderable) y antes de la acción
/// [`BeforeRender`](crate::base::action::component::BeforeRender) y de
/// [`prepare()`](Self::prepare). Recibe solo una referencia compartida al contexto porque su
/// propósito es mutar el propio componente, no el contexto. Por defecto no hace nada.
#[allow(unused_variables)]
fn setup_before_prepare(&mut self, cx: &mut Context) {}
fn setup(&mut self, cx: &Context) {}
/// Versión del componente para preparar su propio renderizado.
/// Genera el marcado HTML del componente cuando ningún tema lo sobrescribe.
///
/// Este método forma parte del ciclo de vida de los componentes y se invoca automáticamente
/// durante el proceso de construcción del documento cuando ningún tema sobrescribe el
/// renderizado mediante [`Theme::handle_component()`](crate::core::theme::Theme::handle_component).
/// Cuarto paso del [ciclo de renderizado](ComponentRender): se invoca tras
/// [`setup()`](Self::setup) y la acción
/// [`BeforeRender`](crate::base::action::component::BeforeRender), pero solo si ningún tema
/// en la cadena devuelve `Some` en
/// [`Theme::handle_component()`](crate::core::theme::Theme::handle_component).
///
/// Se recomienda obtener los datos del componente a través de sus propios métodos para que los
/// temas puedan implementar [`Theme::handle_component()`](crate::core::theme::Theme::handle_component)
/// sin depender de los detalles internos del componente.
/// temas puedan implementar `handle_component()` sin depender de los detalles internos.
///
/// Por defecto, devuelve un [`Markup`] vacío (`Ok(html! {})`).
///
/// En caso de error, devuelve un [`ComponentError`] que puede incluir un marcado alternativo
/// (*fallback*) para sustituir al componente fallido.
/// Por defecto, devuelve un [`Markup`] vacío (`Ok(html! {})`). En caso de error, devuelve un
/// [`ComponentError`] que puede incluir un marcado alternativo (*fallback*).
#[allow(unused_variables)]
fn prepare_component(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(html! {})
}
}
// *************************************************************************************************
impl<T: Component + Clone + 'static> ComponentClone for T {
fn clone_box(&self) -> Box<dyn Component> {
Box::new(self.clone())
}
}
// *************************************************************************************************
/// Implementa [`render()`](ComponentRender::render) para todos los componentes.
///
/// El proceso de renderizado de cada componente sigue esta secuencia:
///
/// 1. Ejecuta [`is_renderable()`](Component::is_renderable) para ver si puede renderizarse en el
/// contexto actual. Si no es así, devuelve un [`Markup`] vacío.
/// 2. Ejecuta [`setup_before_prepare()`](Component::setup_before_prepare) para que el componente
/// pueda ajustar su estructura interna o modificar el contexto.
/// 2. Ejecuta [`setup()`](Component::setup) para que el componente
/// pueda ajustar su estructura interna.
/// 3. Despacha [`action::component::BeforeRender<C>`](crate::base::action::component::BeforeRender)
/// para que las extensiones puedan hacer ajustes previos.
/// 4. **Prepara el renderizado del componente** recorriendo la cadena de temas (hijo → padre →
/// abuelo…) llamando a [`Theme::handle_component()`](crate::core::theme::Theme::handle_component)
/// en cada nivel hasta que uno devuelva `Some`. Si ninguno lo sobrescribe, llama a
/// [`Component::prepare_component()`](Component::prepare_component) del propio componente.
/// [`Component::prepare()`](Component::prepare) del propio componente.
/// 5. Despacha [`action::component::AfterRender<C>`](crate::base::action::component::AfterRender)
/// para que las extensiones puedan reaccionar con sus últimos ajustes.
/// 6. Despacha [`action::component::TransformMarkup<C>`](crate::base::action::component::TransformMarkup)
@ -118,7 +150,7 @@ impl<C: Component> ComponentRender for C {
}
// Configura el componente antes de preparar.
self.setup_before_prepare(cx);
self.setup(cx);
// Acciones de las extensiones antes de renderizar el componente.
action::component::BeforeRender::dispatch(self, cx);
@ -132,7 +164,7 @@ impl<C: Component> ComponentRender for C {
}
t = theme.parent();
}
self.prepare_component(cx)
self.prepare(cx)
} {
Ok(markup) => markup,
Err(error) => {

View file

@ -3,7 +3,7 @@ use crate::{AutoDefault, Getters};
/// Error producido durante el renderizado de un componente.
///
/// Se usa en [`Component::prepare_component()`](super::Component::prepare_component) para devolver
/// Se usa en [`Component::prepare()`](super::Component::prepare) para devolver
/// un [`Err`]. Puede incluir un marcado HTML alternativo para renderizar el componente de manera
/// diferente en caso de error.
///
@ -11,10 +11,11 @@ use crate::{AutoDefault, Getters};
///
/// ```rust
/// # use pagetop::prelude::*;
/// # #[derive(Clone)]
/// # struct MyComponent;
/// # impl Component for MyComponent {
/// # fn new() -> Self { MyComponent }
/// fn prepare_component(&self, _cx: &mut Context) -> Result<Markup, ComponentError> {
/// fn prepare(&self, _cx: &mut Context) -> Result<Markup, ComponentError> {
/// Err(ComponentError::new("Database connection failed")
/// .with_fallback(html! { p class="error" { "Content temporarily unavailable." } }))
/// }

View file

@ -195,7 +195,7 @@ pub trait Theme: Extension + Send + Sync {
///
/// - `None` si este tema no sobrescribe el renderizado. Es la implementación por defecto. El
/// sistema continúa con el siguiente tema de la cadena y, si ninguno lo sobrescribe, usa
/// [`Component::prepare_component()`](crate::core::component::Component::prepare_component).
/// [`Component::prepare()`](crate::core::component::Component::prepare).
/// El tema puede mutar el componente antes de devolver `None`, dejando que otro nivel de la
/// cadena se encargue del renderizado.
/// - `Some(Ok(markup))` con el HTML generado por el tema para el componente.

View file

@ -1,19 +1,42 @@
use crate::core::component::{Child, ChildOp, Children};
use crate::core::component::{Child, ChildOp, Children, Component};
use crate::core::theme::{DefaultRegion, RegionRef, ThemeRef};
use crate::{builder_fn, AutoDefault, UniqueId};
use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::sync::{Arc, LazyLock};
// Conjunto de regiones globales asociadas a un tema específico.
static THEME_REGIONS: LazyLock<RwLock<HashMap<UniqueId, ChildrenInRegions>>> =
// Permite almacenar un componente como prototipo en regiones globales.
//
// Se implementa automáticamente para todo tipo que implemente [`Component`] y [`Clone`]. En cada
// llamada a [`as_child`](Self::as_child) produce un clon fresco del estado original, de modo que
// cada página renderiza el componente desde su estado inicial sin acumular mutaciones de peticiones
// anteriores.
trait ComponentGlobal: Send + Sync {
// Devuelve un nuevo [`Child`] con una copia independiente del componente original.
fn as_child(&self) -> Child;
}
impl<T: Component + Clone + 'static> ComponentGlobal for T {
#[inline]
fn as_child(&self) -> Child {
Child::with(self.clone())
}
}
// Mapa de nombre de región a lista de prototipos de componentes.
type RegionComponents = HashMap<String, Vec<Arc<dyn ComponentGlobal>>>;
// Regiones globales con prototipos asociados a un tema específico.
static THEME_REGIONS: LazyLock<RwLock<HashMap<UniqueId, RegionComponents>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
// Conjunto de regiones globales comunes a todos los temas.
static COMMON_REGIONS: LazyLock<RwLock<ChildrenInRegions>> =
LazyLock::new(|| RwLock::new(ChildrenInRegions::default()));
// Regiones globales con prototipos comunes a todos los temas.
static COMMON_REGIONS: LazyLock<RwLock<RegionComponents>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
// *************************************************************************************************
// Contenedor interno de componentes agrupados por región.
#[derive(AutoDefault)]
@ -35,25 +58,67 @@ impl ChildrenInRegions {
self
}
pub fn children_for(&self, theme_ref: ThemeRef, region_ref: RegionRef) -> Children {
/// Construye una lista de componentes frescos para la región indicada.
///
/// El orden es: prototipos globales comunes → children propios de la página →
/// prototipos específicos del tema activo.
///
/// Los prototipos globales se clonan en cada llamada (clon profundo gracias a
/// [`ComponentClone`]), garantizando que `setup()` siempre parte del estado
/// inicial. Los children propios de la página se mueven (son por petición y no necesitan
/// clonarse).
///
/// [`ComponentClone`]: crate::core::component::ComponentClone
pub fn children_for(&mut self, theme_ref: ThemeRef, region_ref: RegionRef) -> Children {
let name = region_ref.name();
let common = COMMON_REGIONS.read();
let themed = THEME_REGIONS.read();
if let Some(r) = themed.get(&theme_ref.type_id()) {
Children::merge(&[common.0.get(name), self.0.get(name), r.0.get(name)])
} else {
Children::merge(&[common.0.get(name), self.0.get(name)])
let mut result = Children::new();
// 1. Prototipos globales comunes — clon fresco por cada página.
if let Some(protos) = common.get(name) {
for proto in protos {
result.add(proto.as_child());
}
}
// 2. Children propios de la página — se mueven (son por petición, no requieren clonado).
if let Some(page_children) = self.0.remove(name) {
for child in page_children {
result.add(child);
}
}
// 3. Prototipos del tema activo — clon fresco por cada página.
if let Some(theme_map) = themed.get(&theme_ref.type_id()) {
if let Some(protos) = theme_map.get(name) {
for proto in protos {
result.add(proto.as_child());
}
}
}
result
}
}
// *************************************************************************************************
/// Añade componentes a regiones globales o específicas de un tema.
///
/// Cada variante indica la región en la que se añade el componente usando [`Self::add()`]. Los
/// componentes añadidos se mantienen durante toda la ejecución y se inyectan automáticamente al
/// renderizar los documentos HTML que utilizan esas regiones, como las páginas de contenido
/// ([`Page`](crate::response::page::Page)).
/// Los componentes se almacenan como **prototipos**: cada página recibe un clon fresco en el
/// momento del renderizado, de modo que `setup()` se ejecuta siempre sobre un
/// estado inicial limpio sin acumular mutaciones de peticiones anteriores.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// // Banner global en la región de contenido.
/// InRegion::Content.add(Html::with(|_| html! { "🎉 ¡Bienvenido!" }));
///
/// // Texto en la cabecera, visible en todos los temas.
/// InRegion::Global(&DefaultRegion::Header).add(Html::with(|_| html! { "Publicidad" }));
/// ```
pub enum InRegion {
/// Región principal de **contenido** por defecto.
///
@ -81,50 +146,55 @@ pub enum InRegion {
}
impl InRegion {
/// Añade un componente a la región indicada por la variante.
/// Añade un componente como prototipo en la región indicada por la variante.
///
/// El componente se almacena internamente como prototipo. Cada vez que se renderiza una página,
/// se genera un clon fresco del estado original, garantizando que `setup()` no
/// acumula estado entre peticiones.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// // Banner global en la región por defecto.
/// InRegion::Content.add(Child::with(Html::with(|_| {
/// InRegion::Content.add(Html::with(|_| {
/// html! { "🎉 ¡Bienvenido!" }
/// })));
/// }));
///
/// // Texto en la cabecera.
/// InRegion::Global(&DefaultRegion::Header).add(Child::with(Html::with(|_| {
/// InRegion::Global(&DefaultRegion::Header).add(Html::with(|_| {
/// html! { "Publicidad" }
/// })));
/// }));
///
/// // Contenido sólo para la región del pie de página en un tema concreto.
/// InRegion::ForTheme(&theme::Basic, &DefaultRegion::Footer).add(Child::with(Html::with(|_| {
/// InRegion::ForTheme(&theme::Basic, &DefaultRegion::Footer).add(Html::with(|_| {
/// html! { "Aviso legal" }
/// })));
/// }));
/// ```
pub fn add(&self, child: Child) -> &Self {
pub fn add(&self, component: impl Component + Clone + 'static) -> &Self {
let proto: Arc<dyn ComponentGlobal> = Arc::new(component);
match self {
InRegion::Content => Self::add_to_common(&DefaultRegion::Content, child),
InRegion::Global(region_ref) => Self::add_to_common(*region_ref, child),
InRegion::Content => Self::add_to_common(&DefaultRegion::Content, proto),
InRegion::Global(region_ref) => Self::add_to_common(*region_ref, proto),
InRegion::ForTheme(theme_ref, region_ref) => {
let mut regions = THEME_REGIONS.write();
if let Some(r) = regions.get_mut(&theme_ref.type_id()) {
r.alter_child_in(*region_ref, ChildOp::Add(child));
} else {
regions.insert(
theme_ref.type_id(),
ChildrenInRegions::with(*region_ref, child),
);
}
THEME_REGIONS
.write()
.entry(theme_ref.type_id())
.or_default()
.entry((*region_ref).name().to_owned())
.or_default()
.push(proto);
}
}
self
}
#[inline]
fn add_to_common(region_ref: RegionRef, child: Child) {
fn add_to_common(region_ref: RegionRef, proto: Arc<dyn ComponentGlobal>) {
COMMON_REGIONS
.write()
.alter_child_in(region_ref, ChildOp::Add(child));
.entry(region_ref.name().to_owned())
.or_default()
.push(proto);
}
}

View file

@ -377,7 +377,7 @@ impl Contextual for Page {
// **< Contextual HELPERS >*********************************************************************
fn required_id<T>(&mut self, id: Option<String>) -> String {
fn required_id<T>(&self, id: Option<String>) -> String {
self.context.required_id::<T>(id)
}

322
tests/component_children.rs Normal file
View file

@ -0,0 +1,322 @@
use pagetop::prelude::*;
// **< TestComp — componente mínimo para los tests >************************************************
//
// Componente con id configurable y texto fijo de salida. El id permite probar las operaciones de
// `Children` basadas en identificador (`InsertAfterId`, `RemoveById`, etc.).
#[derive(AutoDefault, Clone)]
struct TestComp {
id: AttrId,
text: String,
}
impl Component for TestComp {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn prepare(&self, _cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(html! { (self.text) })
}
}
impl TestComp {
/// Crea un componente con id y texto de salida fijos.
fn tagged(id: &str, text: &str) -> Self {
let mut c = Self::default();
c.id.alter_id(id);
c.text = text.to_string();
c
}
/// Crea un componente sin id, con texto de salida fijo.
fn text(text: &str) -> Self {
let mut c = Self::default();
c.text = text.to_string();
c
}
}
// **< Child >***************************************************************************************
#[pagetop::test]
async fn child_default_is_empty() {
let child = Child::default();
assert!(child.id().is_none());
assert!(child.render(&mut Context::default()).is_empty());
}
#[pagetop::test]
async fn child_with_stores_component_and_renders_it() {
let child = Child::with(TestComp::text("hola"));
assert_eq!(child.render(&mut Context::default()).into_string(), "hola");
}
#[pagetop::test]
async fn child_id_returns_component_id() {
let child = Child::with(TestComp::tagged("my-id", "texto"));
assert_eq!(child.id(), Some("my-id".to_string()));
}
#[pagetop::test]
async fn child_from_component_is_equivalent_to_with() {
let child: Child = TestComp::text("desde from").into();
assert_eq!(child.render(&mut Context::default()).into_string(), "desde from");
}
#[pagetop::test]
async fn child_clone_is_deep() {
// Modificar el clon no debe afectar al original.
let original = Child::with(TestComp::text("original"));
let clone = original.clone();
assert_eq!(original.render(&mut Context::default()).into_string(), "original");
assert_eq!(clone.render(&mut Context::default()).into_string(), "original");
}
// **< Children + ChildOp >*************************************************************************
#[pagetop::test]
async fn children_new_is_empty() {
let c = Children::new();
assert!(c.is_empty());
assert_eq!(c.len(), 0);
}
#[pagetop::test]
async fn children_add_appends_in_order() {
let c = Children::new()
.with_child(ChildOp::Add(Child::with(TestComp::text("a"))))
.with_child(ChildOp::Add(Child::with(TestComp::text("b"))))
.with_child(ChildOp::Add(Child::with(TestComp::text("c"))));
assert_eq!(c.len(), 3);
assert_eq!(c.render(&mut Context::default()).into_string(), "abc");
}
#[pagetop::test]
async fn children_add_if_empty_only_adds_when_list_is_empty() {
let mut cx = Context::default();
// Se añade porque la lista está vacía.
let c = Children::new()
.with_child(ChildOp::AddIfEmpty(Child::with(TestComp::text("primero"))));
assert_eq!(c.len(), 1);
// No se añade porque ya hay un elemento.
let c = c.with_child(ChildOp::AddIfEmpty(Child::with(TestComp::text("segundo"))));
assert_eq!(c.len(), 1);
assert_eq!(c.render(&mut cx).into_string(), "primero");
}
#[pagetop::test]
async fn children_add_many_appends_all_in_order() {
let c = Children::new().with_child(ChildOp::AddMany(vec![
Child::with(TestComp::text("x")),
Child::with(TestComp::text("y")),
Child::with(TestComp::text("z")),
]));
assert_eq!(c.len(), 3);
assert_eq!(c.render(&mut Context::default()).into_string(), "xyz");
}
#[pagetop::test]
async fn children_prepend_inserts_at_start() {
let c = Children::new()
.with_child(ChildOp::Add(Child::with(TestComp::text("b"))))
.with_child(ChildOp::Prepend(Child::with(TestComp::text("a"))));
assert_eq!(c.render(&mut Context::default()).into_string(), "ab");
}
#[pagetop::test]
async fn children_prepend_many_inserts_all_at_start_maintaining_order() {
let c = Children::new()
.with_child(ChildOp::Add(Child::with(TestComp::text("c"))))
.with_child(ChildOp::PrependMany(vec![
Child::with(TestComp::text("a")),
Child::with(TestComp::text("b")),
]));
assert_eq!(c.render(&mut Context::default()).into_string(), "abc");
}
#[pagetop::test]
async fn children_insert_after_id_inserts_after_matching_element() {
let c = Children::new()
.with_child(ChildOp::Add(Child::with(TestComp::tagged("first", "a"))))
.with_child(ChildOp::Add(Child::with(TestComp::text("c"))))
.with_child(ChildOp::InsertAfterId("first", Child::with(TestComp::text("b"))));
assert_eq!(c.render(&mut Context::default()).into_string(), "abc");
}
#[pagetop::test]
async fn children_insert_after_id_appends_when_id_not_found() {
let c = Children::new()
.with_child(ChildOp::Add(Child::with(TestComp::text("a"))))
.with_child(ChildOp::InsertAfterId("no-existe", Child::with(TestComp::text("b"))));
assert_eq!(c.render(&mut Context::default()).into_string(), "ab");
}
#[pagetop::test]
async fn children_insert_before_id_inserts_before_matching_element() {
let c = Children::new()
.with_child(ChildOp::Add(Child::with(TestComp::text("a"))))
.with_child(ChildOp::Add(Child::with(TestComp::tagged("last", "c"))))
.with_child(ChildOp::InsertBeforeId("last", Child::with(TestComp::text("b"))));
assert_eq!(c.render(&mut Context::default()).into_string(), "abc");
}
#[pagetop::test]
async fn children_insert_before_id_prepends_when_id_not_found() {
let c = Children::new()
.with_child(ChildOp::Add(Child::with(TestComp::text("b"))))
.with_child(ChildOp::InsertBeforeId("no-existe", Child::with(TestComp::text("a"))));
assert_eq!(c.render(&mut Context::default()).into_string(), "ab");
}
#[pagetop::test]
async fn children_remove_by_id_removes_first_matching_element() {
let c = Children::new()
.with_child(ChildOp::Add(Child::with(TestComp::tagged("keep", "a"))))
.with_child(ChildOp::Add(Child::with(TestComp::tagged("drop", "b"))))
.with_child(ChildOp::Add(Child::with(TestComp::text("c"))))
.with_child(ChildOp::RemoveById("drop"));
assert_eq!(c.len(), 2);
assert_eq!(c.render(&mut Context::default()).into_string(), "ac");
}
#[pagetop::test]
async fn children_remove_by_id_does_nothing_when_id_not_found() {
let c = Children::new()
.with_child(ChildOp::Add(Child::with(TestComp::text("a"))))
.with_child(ChildOp::RemoveById("no-existe"));
assert_eq!(c.len(), 1);
}
#[pagetop::test]
async fn children_replace_by_id_replaces_first_matching_element() {
let c = Children::new()
.with_child(ChildOp::Add(Child::with(TestComp::tagged("target", "viejo"))))
.with_child(ChildOp::Add(Child::with(TestComp::text("b"))))
.with_child(ChildOp::ReplaceById(
"target",
Child::with(TestComp::text("nuevo")),
));
assert_eq!(c.len(), 2);
assert_eq!(c.render(&mut Context::default()).into_string(), "nuevob");
}
#[pagetop::test]
async fn children_reset_clears_all_elements() {
let c = Children::new()
.with_child(ChildOp::Add(Child::with(TestComp::text("a"))))
.with_child(ChildOp::Add(Child::with(TestComp::text("b"))))
.with_child(ChildOp::Reset);
assert!(c.is_empty());
}
#[pagetop::test]
async fn children_get_by_id_returns_first_matching_child() {
let c = Children::new()
.with_child(ChildOp::Add(Child::with(TestComp::tagged("uno", "a"))))
.with_child(ChildOp::Add(Child::with(TestComp::tagged("dos", "b"))));
assert!(c.get_by_id("uno").is_some());
assert!(c.get_by_id("dos").is_some());
assert!(c.get_by_id("tres").is_none());
}
#[pagetop::test]
async fn children_iter_by_id_yields_all_matching_children() {
let c = Children::new()
.with_child(ChildOp::Add(Child::with(TestComp::tagged("rep", "a"))))
.with_child(ChildOp::Add(Child::with(TestComp::tagged("rep", "b"))))
.with_child(ChildOp::Add(Child::with(TestComp::tagged("otro", "c"))));
assert_eq!(c.iter_by_id("rep").count(), 2);
assert_eq!(c.iter_by_id("otro").count(), 1);
assert_eq!(c.iter_by_id("ninguno").count(), 0);
}
#[pagetop::test]
async fn children_render_concatenates_all_outputs_in_order() {
let c = Children::new()
.with_child(ChildOp::Add(Child::with(TestComp::text("uno "))))
.with_child(ChildOp::Add(Child::with(TestComp::text("dos "))))
.with_child(ChildOp::Add(Child::with(TestComp::text("tres"))));
assert_eq!(c.render(&mut Context::default()).into_string(), "uno dos tres");
}
// **< Slot >****************************************************************************************
#[pagetop::test]
async fn slot_default_is_empty() {
let slot: Slot<TestComp> = Slot::default();
assert!(slot.id().is_none());
assert!(slot.render(&mut Context::default()).is_empty());
assert!(slot.get().is_none());
}
#[pagetop::test]
async fn slot_with_stores_component() {
let slot = Slot::with(TestComp::text("contenido"));
assert!(slot.get().is_some());
assert_eq!(slot.render(&mut Context::default()).into_string(), "contenido");
}
#[pagetop::test]
async fn slot_id_returns_component_id() {
let slot = Slot::with(TestComp::tagged("slot-id", "texto"));
assert_eq!(slot.id(), Some("slot-id".to_string()));
}
#[pagetop::test]
async fn slot_get_is_some_when_component_present() {
let slot = Slot::with(TestComp::tagged("abc", "hola"));
// `get()` devuelve Some; la lectura del id verifica que accede al componente correctamente.
assert!(slot.get().is_some());
assert_eq!(slot.id(), Some("abc".to_string()));
}
#[pagetop::test]
async fn slot_get_allows_mutating_component() {
let slot = Slot::with(TestComp::tagged("orig", "texto"));
// El `;` final convierte el `if let` en sentencia y libera el guard antes que `slot`.
if let Some(mut comp) = slot.get() {
comp.id.alter_id("modificado");
};
assert_eq!(slot.id(), Some("modificado".to_string()));
}
#[pagetop::test]
async fn slot_with_component_replaces_content() {
let slot = Slot::with(TestComp::text("primero"))
.with_component(Some(TestComp::text("segundo")));
assert_eq!(slot.render(&mut Context::default()).into_string(), "segundo");
}
#[pagetop::test]
async fn slot_with_component_none_empties_slot() {
let slot = Slot::with(TestComp::text("algo")).with_component(None);
assert!(slot.get().is_none());
assert!(slot.render(&mut Context::default()).is_empty());
}
#[pagetop::test]
async fn slot_clone_is_deep() {
let original = Slot::with(TestComp::tagged("orig", "texto"));
let clone = original.clone();
// Mutar el clon no debe afectar al original.
if let Some(mut comp) = clone.get() {
comp.id.alter_id("clone-id");
}
assert_eq!(original.id(), Some("orig".to_string()));
assert_eq!(clone.id(), Some("clone-id".to_string()));
}
#[pagetop::test]
async fn slot_converts_into_child() {
let slot = Slot::with(TestComp::text("desde slot"));
let child = Child::from(slot);
assert_eq!(child.render(&mut Context::default()).into_string(), "desde slot");
}

View file

@ -3,7 +3,7 @@ use pagetop::prelude::*;
/// Componente mínimo para probar `Markup` pasando por el ciclo real de renderizado de componentes
/// (`ComponentRender`). El parámetro de contexto `"renderable"` se usará para controlar si el
/// componente se renderiza (`true` por defecto).
#[derive(AutoDefault)]
#[derive(AutoDefault, Clone)]
struct TestMarkupComponent {
markup: Markup,
}
@ -13,11 +13,11 @@ impl Component for TestMarkupComponent {
Self::default()
}
fn is_renderable(&self, cx: &mut Context) -> bool {
fn is_renderable(&self, cx: &Context) -> bool {
cx.param_or::<bool>("renderable", true)
}
fn prepare_component(&self, _cx: &mut Context) -> Result<Markup, ComponentError> {
fn prepare(&self, _cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(self.markup.clone())
}
}