🧑‍💻 Mejora funcionalidad del componente Html

- Amplía la documentación del componente.
- Aplica la nueva funcionalidad en la página de bienvenida usando el
  nuevo renderizado dinámico con contexto.
- Añade pruebas unitarias para el componente.
This commit is contained in:
Manuel Cillero 2025-08-02 20:26:39 +02:00
parent 3a3e3b810f
commit ef8d16f41f
7 changed files with 155 additions and 27 deletions

View file

@ -1,28 +1,76 @@
use crate::prelude::*; use crate::prelude::*;
/// Componente básico para renderizar directamente código HTML. /// Componente básico para renderizar dinámicamente código HTML recibiendo el contexto.
#[derive(AutoDefault)] ///
pub struct Html(Markup); /// Este componente permite generar contenido HTML arbitrario, usando la macro `html!` y accediendo
/// opcionalmente al contexto de renderizado.
///
/// # Ejemplo
///
/// ```rust
/// use pagetop::prelude::*;
///
/// let component = Html::with(|_| {
/// html! {
/// div class="example" {
/// p { "Hello from PageTop." }
/// }
/// }
/// });
/// ```
///
/// Para renderizar contenido que dependa del contexto, se puede acceder a él dentro del *closure*:
///
/// ```rust
/// use pagetop::prelude::*;
///
/// let component = Html::with(|cx| {
/// let user = cx.get_param::<String>("username").unwrap_or(String::from("visitor"));
/// html! {
/// h1 { "Hello, " (user) }
/// }
/// });
/// ```
pub struct Html(Box<dyn Fn(&mut Context) -> Markup + Send + Sync>);
impl Default for Html {
fn default() -> Self {
Html::with(|_| html! {})
}
}
impl ComponentTrait for Html { impl ComponentTrait for Html {
fn new() -> Self { fn new() -> Self {
Html::default() Html::default()
} }
fn prepare_component(&self, _cx: &mut Context) -> PrepareMarkup { fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
PrepareMarkup::With(html! { (self.0) }) PrepareMarkup::With((self.0)(cx))
} }
} }
impl Html { impl Html {
/// Crear una instancia con el código HTML del argumento. /// Crea una instancia que generará el `Markup`, con acceso opcional al contexto.
pub fn with(html: Markup) -> Self { ///
Html(html) /// El método [`prepare_component`](crate::core::component::ComponentTrait::prepare_component)
/// delega el renderizado en la función proporcionada, que recibe una referencia mutable
/// al contexto de renderizado ([`Context`]).
pub fn with<F>(f: F) -> Self
where
F: Fn(&mut Context) -> Markup + Send + Sync + 'static,
{
Html(Box::new(f))
} }
/// Modifica el código HTML de la instancia con el nuevo código del argumento. /// Sustituye la función que genera el `Markup`.
pub fn alter_html(&mut self, html: Markup) -> &mut Self { ///
self.0 = html; /// Permite a otras extensiones modificar la función de renderizado que se ejecutará cuando
/// [`prepare_component`](crate::core::component::ComponentTrait::prepare_component) invoque
/// esta instancia. La nueva función también recibe una referencia al contexto ([`Context`]).
pub fn alter_html<F>(&mut self, f: F) -> &mut Self
where
F: Fn(&mut Context) -> Markup + Send + Sync + 'static,
{
self.0 = Box::new(f);
self self
} }
} }

View file

@ -24,11 +24,11 @@ impl ExtensionTrait for Welcome {
async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> { async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
let app = &global::SETTINGS.app.name; let app = &global::SETTINGS.app.name;
Page::new(request) Page::new(Some(request))
.with_title(L10n::l("welcome_page")) .with_title(L10n::l("welcome_page"))
.with_theme("Basic") .with_theme("Basic")
.with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/welcome.css"))) .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/welcome.css")))
.with_component(Html::with(html! { .with_component(Html::with(move |_| html! {
div id="main-header" { div id="main-header" {
header { header {
h1 id="header-title" aria-label=(L10n::l("welcome_aria").with_arg("app", app)) { h1 id="header-title" aria-label=(L10n::l("welcome_aria").with_arg("app", app)) {

View file

@ -53,8 +53,11 @@ pub trait ComponentTrait: AnyInfo + ComponentRender + Send + Sync {
/// Devuelve una representación estructurada del componente lista para renderizar. /// Devuelve una representación estructurada del componente lista para renderizar.
/// ///
/// Puede sobrescribirse para generar dinámicamente el contenido HTML. Por defecto, devuelve /// Este método forma parte del ciclo de vida de los componentes y se invoca automáticamente
/// [`PrepareMarkup::None`]. /// durante el proceso de construcción del documento. Puede sobrescribirse para generar
/// dinámicamente el contenido HTML con acceso al contexto de renderizado.
///
/// Por defecto, devuelve [`PrepareMarkup::None`].
#[allow(unused_variables)] #[allow(unused_variables)]
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup { fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
PrepareMarkup::None PrepareMarkup::None

View file

@ -107,7 +107,7 @@ impl Error for ErrorParam {}
/// ``` /// ```
#[rustfmt::skip] #[rustfmt::skip]
pub struct Context { pub struct Context {
request : HttpRequest, // Solicitud HTTP de origen. request : Option<HttpRequest>, // Solicitud HTTP de origen.
langid : &'static LanguageIdentifier, // Identificador de idioma. langid : &'static LanguageIdentifier, // Identificador de idioma.
theme : ThemeRef, // Referencia al tema para renderizar. theme : ThemeRef, // Referencia al tema para renderizar.
layout : &'static str, // Composición del documento para renderizar. layout : &'static str, // Composición del documento para renderizar.
@ -124,7 +124,7 @@ impl Context {
/// El contexto inicializa el idioma, tema y composición por defecto, sin favicon ni recursos /// El contexto inicializa el idioma, tema y composición por defecto, sin favicon ni recursos
/// cargados. /// cargados.
#[rustfmt::skip] #[rustfmt::skip]
pub fn new(request: HttpRequest) -> Self { pub fn new(request: Option<HttpRequest>) -> Self {
Context { Context {
request, request,
langid : &DEFAULT_LANGID, langid : &DEFAULT_LANGID,
@ -197,9 +197,9 @@ impl Context {
// Context GETTERS ***************************************************************************** // Context GETTERS *****************************************************************************
/// Devuelve la solicitud HTTP asociada al documento. /// Devuelve una referencia a la solicitud HTTP asociada, si existe.
pub fn request(&self) -> &HttpRequest { pub fn request(&self) -> Option<&HttpRequest> {
&self.request self.request.as_ref()
} }
/// Devuelve el identificador de idioma asociado al documento. /// Devuelve el identificador de idioma asociado al documento.

View file

@ -30,9 +30,12 @@ pub struct Page {
} }
impl Page { impl Page {
/// Crea una nueva instancia de página con contexto basado en la petición HTTP. /// Crea una nueva instancia de página.
///
/// Si se proporciona la solicitud HTTP, se guardará en el contexto de renderizado de la página
/// para poder ser recuperada por los componentes si es necesario.
#[rustfmt::skip] #[rustfmt::skip]
pub fn new(request: HttpRequest) -> Self { pub fn new(request: Option<HttpRequest>) -> Self {
Page { Page {
title : OptionTranslated::default(), title : OptionTranslated::default(),
description : OptionTranslated::default(), description : OptionTranslated::default(),
@ -165,7 +168,7 @@ impl Page {
} }
/// Devuelve la solicitud HTTP asociada. /// Devuelve la solicitud HTTP asociada.
pub fn request(&self) -> &HttpRequest { pub fn request(&self) -> Option<&HttpRequest> {
self.context.request() self.context.request()
} }

View file

@ -28,12 +28,12 @@ impl fmt::Display for ErrorPage {
ErrorPage::BadRequest(_) => write!(f, "Bad Client Data"), ErrorPage::BadRequest(_) => write!(f, "Bad Client Data"),
// Error 403. // Error 403.
ErrorPage::AccessDenied(request) => { ErrorPage::AccessDenied(request) => {
let mut error_page = Page::new(request.clone()); let mut error_page = Page::new(Some(request.clone()));
let error403 = error_page.theme().error403(&mut error_page); let error403 = error_page.theme().error403(&mut error_page);
if let Ok(page) = error_page if let Ok(page) = error_page
.with_title(L10n::n("Error FORBIDDEN")) .with_title(L10n::n("Error FORBIDDEN"))
.with_layout("error") .with_layout("error")
.with_component(Html::with(error403)) .with_component(Html::with(move |_| error403.clone()))
.render() .render()
{ {
write!(f, "{}", page.into_string()) write!(f, "{}", page.into_string())
@ -43,12 +43,12 @@ impl fmt::Display for ErrorPage {
} }
// Error 404. // Error 404.
ErrorPage::NotFound(request) => { ErrorPage::NotFound(request) => {
let mut error_page = Page::new(request.clone()); let mut error_page = Page::new(Some(request.clone()));
let error404 = error_page.theme().error404(&mut error_page); let error404 = error_page.theme().error404(&mut error_page);
if let Ok(page) = error_page if let Ok(page) = error_page
.with_title(L10n::n("Error RESOURCE NOT FOUND")) .with_title(L10n::n("Error RESOURCE NOT FOUND"))
.with_layout("error") .with_layout("error")
.with_component(Html::with(error404)) .with_component(Html::with(move |_| error404.clone()))
.render() .render()
{ {
write!(f, "{}", page.into_string()) write!(f, "{}", page.into_string())

74
tests/component_html.rs Normal file
View file

@ -0,0 +1,74 @@
use pagetop::prelude::*;
#[pagetop::test]
async fn component_html_renders_static_markup() {
let component = Html::with(|_| {
html! {
p { "Test" }
}
});
let markup = component
.prepare_component(&mut Context::new(None))
.render();
assert_eq!(markup.0, "<p>Test</p>");
}
#[pagetop::test]
async fn component_html_renders_using_context_param() {
let mut cx = Context::new(None).with_param("username", String::from("Alice"));
let component = Html::with(|cx| {
let name = cx.get_param::<String>("username").unwrap_or_default();
html! {
span { (name) }
}
});
let markup = component.prepare_component(&mut cx).render();
assert_eq!(markup.0, "<span>Alice</span>");
}
#[pagetop::test]
async fn component_html_allows_replacing_render_function() {
let mut component = Html::with(|_| html! { div { "Original" } });
component.alter_html(|_| html! { div { "Modified" } });
let markup = component
.prepare_component(&mut Context::new(None))
.render();
assert_eq!(markup.0, "<div>Modified</div>");
}
#[pagetop::test]
async fn component_html_default_renders_empty_markup() {
let component = Html::default();
let markup = component
.prepare_component(&mut Context::new(None))
.render();
assert_eq!(markup.0, "");
}
#[pagetop::test]
async fn component_html_can_access_http_method() {
let req = service::test::TestRequest::with_uri("/").to_http_request();
let mut cx = Context::new(Some(req));
let component = Html::with(|cx| {
let method = cx
.request()
.map(|r| r.method().to_string())
.unwrap_or_default();
html! { span { (method) } }
});
let markup = component.prepare_component(&mut cx).render();
assert_eq!(markup.0, "<span>GET</span>");
}