Compare commits
3 commits
55159f6d8f
...
b9d9cdf601
| Author | SHA1 | Date | |
|---|---|---|---|
| b9d9cdf601 | |||
| 8573aca29e | |||
| e7f2563967 |
33 changed files with 1758 additions and 444 deletions
|
|
@ -102,7 +102,7 @@ impl Extension for SuperMenu {
|
||||||
)),
|
)),
|
||||||
));
|
));
|
||||||
|
|
||||||
InRegion::Global(&DefaultRegions::Header).add(
|
InRegion::Global(&CoreRegion::Header).add(
|
||||||
bs::Container::new()
|
bs::Container::new()
|
||||||
.with_width(bs::container::Width::FluidMax(UnitValue::RelRem(75.0)))
|
.with_width(bs::container::Width::FluidMax(UnitValue::RelRem(75.0)))
|
||||||
.with_child(navbar_menu),
|
.with_child(navbar_menu),
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ authors.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
pagetop.workspace = true
|
pagetop.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
pagetop-build.workspace = true
|
pagetop-build.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,27 @@ async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Cuando los valores se construyen en tiempo de ejecución o quieres que una extensión aplique estos
|
||||||
|
atributos sin que el componente dependa de HTMX, usa `Props` junto con las constantes de `hx` en
|
||||||
|
lugar de escribirlos como literales en `html!`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pagetop::prelude::*;
|
||||||
|
use pagetop_htmx::prelude::*;
|
||||||
|
|
||||||
|
async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||||
|
let props = Props::new(hx::GET, "/api/hello")
|
||||||
|
.with_prop(PropsOp::set(hx::TARGET, "#result"));
|
||||||
|
|
||||||
|
Page::new(request)
|
||||||
|
.with_child(Html::with(move |_| html! {
|
||||||
|
button (props) { "Say hello" }
|
||||||
|
div #result {}
|
||||||
|
}))
|
||||||
|
.render().await
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Créditos
|
## Créditos
|
||||||
|
|
||||||
Este *crate* integra la biblioteca [HTMX 2.0.10](https://htmx.org), distribuida bajo licencia
|
Este *crate* integra la biblioteca [HTMX 2.0.10](https://htmx.org), distribuida bajo licencia
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,9 @@
|
||||||
//!
|
//!
|
||||||
//! ```rust,no_run
|
//! ```rust,no_run
|
||||||
//! use pagetop::prelude::*;
|
//! use pagetop::prelude::*;
|
||||||
//! use pagetop_htmx::hx;
|
//! use pagetop_htmx::prelude::*;
|
||||||
//!
|
//!
|
||||||
//! let endpoint = "/api/items"; // Calculado en tiempo de ejecución.
|
//! let props = Props::new(hx::GET, "/api/items")
|
||||||
//!
|
|
||||||
//! let props = Props::new(hx::GET, endpoint)
|
|
||||||
//! .with_prop(PropsOp::set(hx::TARGET, "#list"))
|
//! .with_prop(PropsOp::set(hx::TARGET, "#list"))
|
||||||
//! .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML));
|
//! .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML));
|
||||||
//!
|
//!
|
||||||
|
|
@ -45,7 +43,7 @@
|
||||||
//!
|
//!
|
||||||
//! ```rust,no_run
|
//! ```rust,no_run
|
||||||
//! use pagetop::prelude::*;
|
//! use pagetop::prelude::*;
|
||||||
//! use pagetop_htmx::hx;
|
//! use pagetop_htmx::prelude::*;
|
||||||
//!
|
//!
|
||||||
//! #[derive(AutoDefault, Getters)]
|
//! #[derive(AutoDefault, Getters)]
|
||||||
//! pub struct MyButton {
|
//! pub struct MyButton {
|
||||||
|
|
@ -75,7 +73,7 @@
|
||||||
//!
|
//!
|
||||||
//! ```rust,no_run
|
//! ```rust,no_run
|
||||||
//! use pagetop::prelude::*;
|
//! use pagetop::prelude::*;
|
||||||
//! use pagetop_htmx::hx;
|
//! use pagetop_htmx::prelude::*;
|
||||||
//!
|
//!
|
||||||
//! // Evento nativo del DOM: hx-on:click="..."
|
//! // Evento nativo del DOM: hx-on:click="..."
|
||||||
//! // Evento propio de HTMX: hx-on::after-swap="..."
|
//! // Evento propio de HTMX: hx-on::after-swap="..."
|
||||||
|
|
@ -91,7 +89,7 @@
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// # use pagetop::prelude::*;
|
/// # use pagetop::prelude::*;
|
||||||
/// # use pagetop_htmx::hx;
|
/// # use pagetop_htmx::prelude::*;
|
||||||
/// let props = Props::new(hx::GET, "/api/search")
|
/// let props = Props::new(hx::GET, "/api/search")
|
||||||
/// .with_prop(PropsOp::set(hx::TARGET, "#results"));
|
/// .with_prop(PropsOp::set(hx::TARGET, "#results"));
|
||||||
/// ```
|
/// ```
|
||||||
|
|
@ -113,7 +111,7 @@ pub const PATCH: &str = "hx-patch";
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// # use pagetop::prelude::*;
|
/// # use pagetop::prelude::*;
|
||||||
/// # use pagetop_htmx::hx;
|
/// # use pagetop_htmx::prelude::*;
|
||||||
/// // Al eliminar un elemento, reemplazarlo con respuesta vacía borra el nodo del DOM.
|
/// // Al eliminar un elemento, reemplazarlo con respuesta vacía borra el nodo del DOM.
|
||||||
/// let props = Props::new(hx::DELETE, "/api/item/42")
|
/// let props = Props::new(hx::DELETE, "/api/item/42")
|
||||||
/// .with_prop(PropsOp::set(hx::TARGET, "closest li"))
|
/// .with_prop(PropsOp::set(hx::TARGET, "closest li"))
|
||||||
|
|
@ -130,7 +128,7 @@ pub const DELETE: &str = "hx-delete";
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// # use pagetop::prelude::*;
|
/// # use pagetop::prelude::*;
|
||||||
/// # use pagetop_htmx::hx;
|
/// # use pagetop_htmx::prelude::*;
|
||||||
/// let props = Props::new(hx::GET, "/api/detalles")
|
/// let props = Props::new(hx::GET, "/api/detalles")
|
||||||
/// .with_prop(PropsOp::set(hx::TARGET, "closest article"));
|
/// .with_prop(PropsOp::set(hx::TARGET, "closest article"));
|
||||||
/// ```
|
/// ```
|
||||||
|
|
@ -144,7 +142,7 @@ pub const TARGET: &str = "hx-target";
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// # use pagetop::prelude::*;
|
/// # use pagetop::prelude::*;
|
||||||
/// # use pagetop_htmx::hx;
|
/// # use pagetop_htmx::prelude::*;
|
||||||
/// // Reemplaza el elemento completo con una transición de 300 ms.
|
/// // Reemplaza el elemento completo con una transición de 300 ms.
|
||||||
/// let props = Props::new(hx::SWAP, "outerHTML swap:300ms");
|
/// let props = Props::new(hx::SWAP, "outerHTML swap:300ms");
|
||||||
/// // O usando la constante tipada más los modificadores:
|
/// // O usando la constante tipada más los modificadores:
|
||||||
|
|
@ -176,7 +174,7 @@ pub const SELECT_OOB: &str = "hx-select-oob";
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// # use pagetop::prelude::*;
|
/// # use pagetop::prelude::*;
|
||||||
/// # use pagetop_htmx::hx;
|
/// # use pagetop_htmx::prelude::*;
|
||||||
/// // Buscar mientras se escribe, con 400 ms de espera y sólo si el valor cambia:
|
/// // Buscar mientras se escribe, con 400 ms de espera y sólo si el valor cambia:
|
||||||
/// let props = Props::new(hx::GET, "/api/search")
|
/// let props = Props::new(hx::GET, "/api/search")
|
||||||
/// .with_prop(PropsOp::set(hx::TRIGGER, "keyup changed delay:400ms"))
|
/// .with_prop(PropsOp::set(hx::TRIGGER, "keyup changed delay:400ms"))
|
||||||
|
|
@ -294,6 +292,11 @@ pub const PRESERVE: &str = "hx-preserve";
|
||||||
/// - `"sse"` - soporte Server-Sent Events.
|
/// - `"sse"` - soporte Server-Sent Events.
|
||||||
/// - `"json-enc"` - codifica la petición como JSON en lugar de form-urlencoded.
|
/// - `"json-enc"` - codifica la petición como JSON en lugar de form-urlencoded.
|
||||||
/// - `"loading-states"` - gestión avanzada de estados de carga.
|
/// - `"loading-states"` - gestión avanzada de estados de carga.
|
||||||
|
///
|
||||||
|
/// `pagetop-htmx` sólo integra el *core* de HTMX: usar cualquiera de estas extensiones (ver el
|
||||||
|
/// [catálogo oficial](https://htmx.org/extensions/)) requiere añadir su script correspondiente por
|
||||||
|
/// separado, por ejemplo con [`JavaScript::defer()`](pagetop::html::JavaScript::defer) en
|
||||||
|
/// [`dependencies()`](pagetop::core::extension::Extension::dependencies).
|
||||||
pub const EXT: &str = "hx-ext";
|
pub const EXT: &str = "hx-ext";
|
||||||
|
|
||||||
/// Atributos HTMX que los elementos descendientes NO heredarán de este elemento.
|
/// Atributos HTMX que los elementos descendientes NO heredarán de este elemento.
|
||||||
|
|
@ -348,7 +351,7 @@ pub const DISABLE: &str = "hx-disable";
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// # use pagetop::prelude::*;
|
/// # use pagetop::prelude::*;
|
||||||
/// # use pagetop_htmx::hx;
|
/// # use pagetop_htmx::prelude::*;
|
||||||
/// let props = Props::new(hx::on("click"), "this.classList.toggle('active')")
|
/// let props = Props::new(hx::on("click"), "this.classList.toggle('active')")
|
||||||
/// .with_prop(PropsOp::set(hx::on("mouseenter"), "this.style.opacity='0.8'"));
|
/// .with_prop(PropsOp::set(hx::on("mouseenter"), "this.style.opacity='0.8'"));
|
||||||
/// ```
|
/// ```
|
||||||
|
|
@ -364,7 +367,7 @@ pub fn on(event: &str) -> String {
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// # use pagetop::prelude::*;
|
/// # use pagetop::prelude::*;
|
||||||
/// # use pagetop_htmx::hx;
|
/// # use pagetop_htmx::prelude::*;
|
||||||
/// let props = Props::new(hx::on_htmx("before-request"), "console.log('enviando...')")
|
/// let props = Props::new(hx::on_htmx("before-request"), "console.log('enviando...')")
|
||||||
/// .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "initTooltips()"));
|
/// .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "initTooltips()"));
|
||||||
/// ```
|
/// ```
|
||||||
|
|
@ -382,7 +385,7 @@ pub fn on_htmx(event: &str) -> String {
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// use pagetop::prelude::*;
|
/// use pagetop::prelude::*;
|
||||||
/// use pagetop_htmx::hx;
|
/// use pagetop_htmx::prelude::*;
|
||||||
///
|
///
|
||||||
/// async fn handler(request: HttpRequest) {
|
/// async fn handler(request: HttpRequest) {
|
||||||
/// if let Some(target) = request.headers().get(hx::request::TARGET) {
|
/// if let Some(target) = request.headers().get(hx::request::TARGET) {
|
||||||
|
|
@ -417,18 +420,19 @@ pub mod request {
|
||||||
/// manualmente, aunque lo habitual es usar el constructor [`HtmxResponse`](crate::HtmxResponse).
|
/// manualmente, aunque lo habitual es usar el constructor [`HtmxResponse`](crate::HtmxResponse).
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// use pagetop_htmx::hx;
|
/// use pagetop::prelude::*;
|
||||||
/// use pagetop::web::http::{HeaderMap, HeaderName, HeaderValue};
|
/// use pagetop_htmx::prelude::*;
|
||||||
///
|
///
|
||||||
/// let mut headers = HeaderMap::new();
|
/// let mut headers = web::http::HeaderMap::new();
|
||||||
/// headers.insert(
|
/// headers.insert(
|
||||||
/// hx::response::TRIGGER.parse::<HeaderName>().unwrap(),
|
/// hx::response::TRIGGER.parse::<web::http::HeaderName>().unwrap(),
|
||||||
/// HeaderValue::from_static("itemAdded"),
|
/// web::http::HeaderValue::from_static("itemAdded"),
|
||||||
/// );
|
/// );
|
||||||
/// ```
|
/// ```
|
||||||
pub mod response {
|
pub mod response {
|
||||||
/// Redirige mediante AJAX a la URL o configuración JSON indicada. Ver
|
/// Redirige mediante AJAX a la URL o configuración JSON indicada. Ver
|
||||||
/// [`HtmxResponse::location()`](crate::HtmxResponse::location).
|
/// [`HtmxResponse::location()`](crate::HtmxResponse::location) y
|
||||||
|
/// [`HtmxResponse::location_json()`](crate::HtmxResponse::location_json).
|
||||||
pub const LOCATION: &str = "HX-Location";
|
pub const LOCATION: &str = "HX-Location";
|
||||||
/// Empuja la URL indicada al historial del navegador. Ver
|
/// Empuja la URL indicada al historial del navegador. Ver
|
||||||
/// [`HtmxResponse::push_url()`](crate::HtmxResponse::push_url).
|
/// [`HtmxResponse::push_url()`](crate::HtmxResponse::push_url).
|
||||||
|
|
@ -475,7 +479,7 @@ pub mod response {
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// # use pagetop::prelude::*;
|
/// # use pagetop::prelude::*;
|
||||||
/// # use pagetop_htmx::hx;
|
/// # use pagetop_htmx::prelude::*;
|
||||||
/// // Reemplaza el elemento con una transición de 200 ms y desplaza al inicio:
|
/// // Reemplaza el elemento con una transición de 200 ms y desplaza al inicio:
|
||||||
/// let props = Props::new(hx::SWAP, format!("{} swap:200ms scroll:top", hx::swap::OUTER_HTML));
|
/// let props = Props::new(hx::SWAP, format!("{} swap:200ms scroll:top", hx::swap::OUTER_HTML));
|
||||||
/// ```
|
/// ```
|
||||||
|
|
@ -515,7 +519,7 @@ pub mod swap {
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// # use pagetop::prelude::*;
|
/// # use pagetop::prelude::*;
|
||||||
/// # use pagetop_htmx::hx;
|
/// # use pagetop_htmx::prelude::*;
|
||||||
/// // Búsqueda progresiva: petición 400 ms después de que el usuario deje de escribir.
|
/// // Búsqueda progresiva: petición 400 ms después de que el usuario deje de escribir.
|
||||||
/// let search = Props::new(hx::TRIGGER, "keyup changed delay:400ms");
|
/// let search = Props::new(hx::TRIGGER, "keyup changed delay:400ms");
|
||||||
///
|
///
|
||||||
|
|
|
||||||
74
extensions/pagetop-htmx/src/hx_table.rs
Normal file
74
extensions/pagetop-htmx/src/hx_table.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
//! Soporte HTMX al componente [`Table`].
|
||||||
|
|
||||||
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
|
use crate::hx;
|
||||||
|
|
||||||
|
// **< sort_link() >********************************************************************************
|
||||||
|
|
||||||
|
/// Construye un [`SortLink`](pagetop::base::component::table::SortLink) para actualizar el orden de
|
||||||
|
/// la tabla sin recargar la página.
|
||||||
|
///
|
||||||
|
/// [`Table`] y `SortLink` no requieren HTMX. Cada extensión que quiera aplicar una navegación sin
|
||||||
|
/// recarga debe añadir sus propios atributos `hx-*` usando
|
||||||
|
/// [`SortLink::with_prop()`](pagetop::base::component::table::SortLink::with_prop). Como esos
|
||||||
|
/// cuatro atributos son siempre los mismos para cualquier cabecera ordenable (`hx-get` igual al
|
||||||
|
/// `href`, `hx-swap="outerHTML"` y `hx-push-url="true"`, y sólo `hx-target` cambia según la tabla),
|
||||||
|
/// [`sort_link()`] evita reescribirlos en cada columna de cada listado.
|
||||||
|
///
|
||||||
|
/// El enlace resultante funciona igual con o sin HTMX: `href` es siempre la URL real del nuevo
|
||||||
|
/// estado de orden, así que navega correctamente aunque HTMX no esté disponible en el cliente.
|
||||||
|
///
|
||||||
|
/// # Argumentos
|
||||||
|
///
|
||||||
|
/// - `href`: URL completa hacia el nuevo estado de orden, reflejando ya el campo y la dirección
|
||||||
|
/// que resultarán de pulsar esta cabecera. Acepta cualquier tipo convertible a [`RoutePath`],
|
||||||
|
/// normalmente el resultado de [`Context::route()`](pagetop::core::component::Context::route),
|
||||||
|
/// para que el enlace preserve el parámetro `lang` cuando corresponda.
|
||||||
|
/// - `target`: selector CSS del elemento que HTMX debe reemplazar (`hx-target`), típicamente el
|
||||||
|
/// contenedor que envuelve la tabla completa.
|
||||||
|
/// - `dir`: dirección de orden vigente de esta columna, o `None` si la tabla está ordenada
|
||||||
|
/// actualmente por otra columna. Se traslada tal cual a
|
||||||
|
/// [`SortLink::with_dir()`](pagetop::base::component::table::SortLink::with_dir).
|
||||||
|
///
|
||||||
|
/// # Ejemplo
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pagetop::prelude::*;
|
||||||
|
/// use pagetop_htmx::prelude::*;
|
||||||
|
///
|
||||||
|
/// # fn build_column(cx: &Context) -> table::Column {
|
||||||
|
/// let current_field = "username"; // Estado vigente de la tabla.
|
||||||
|
/// let current_dir = html::SortDir::Asc; // Ordenada por "username" en ascendente.
|
||||||
|
///
|
||||||
|
/// let field = "username"; // Cabecera de la propia columna "username".
|
||||||
|
/// let is_active = field == current_field; // En el ejemplo, coincide con el campo vigente.
|
||||||
|
/// let active = is_active.then_some(current_dir); // `Some` sólo si esta columna ordena ahora.
|
||||||
|
/// let next_dir = html::SortDir::next_for(active); // El siguiente clic alterna la dirección.
|
||||||
|
///
|
||||||
|
/// // `cx` es el `Context` de la petición en curso.
|
||||||
|
/// let href = cx
|
||||||
|
/// .route("/admin/users")
|
||||||
|
/// .with_param("sort", field)
|
||||||
|
/// .with_param("dir", next_dir);
|
||||||
|
///
|
||||||
|
/// table::Column::new(L10n::n("User"))
|
||||||
|
/// .with_sort(hx_table::sort_link(href, "#user-table-wrapper", active))
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
pub fn sort_link(
|
||||||
|
href: impl Into<RoutePath>,
|
||||||
|
target: impl AsRef<str>,
|
||||||
|
dir: impl Into<Option<SortDir>>,
|
||||||
|
) -> table::SortLink {
|
||||||
|
// Se materializa como `String` propio porque el mismo valor sirve para dos llamadas: como
|
||||||
|
// `RoutePath` en `SortLink::new()` (vía `href.as_str()`) y como `CowStr` en `PropsOp::set()`.
|
||||||
|
let href = href.into().to_string();
|
||||||
|
let target = target.as_ref().to_owned();
|
||||||
|
table::SortLink::new(href.as_ref())
|
||||||
|
.with_dir(dir)
|
||||||
|
.with_prop(PropsOp::set(hx::GET, href))
|
||||||
|
.with_prop(PropsOp::set(hx::TARGET, target))
|
||||||
|
.with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML))
|
||||||
|
.with_prop(PropsOp::set(hx::PUSH_URL, "true"))
|
||||||
|
}
|
||||||
|
|
@ -64,11 +64,35 @@ async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||||
.render().await
|
.render().await
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Cuando los valores se construyen en tiempo de ejecución o quieres que una extensión aplique estos
|
||||||
|
atributos sin que el componente dependa de HTMX, usa `Props` junto con las constantes de `hx` en
|
||||||
|
lugar de escribirlos como literales en `html!`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use pagetop::prelude::*;
|
||||||
|
use pagetop_htmx::prelude::*;
|
||||||
|
|
||||||
|
async fn homepage(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||||
|
let props = Props::new(hx::GET, "/api/hello")
|
||||||
|
.with_prop(PropsOp::set(hx::TARGET, "#result"));
|
||||||
|
|
||||||
|
Page::new(request)
|
||||||
|
.with_child(Html::with(move |_| html! {
|
||||||
|
button (props) { "Say hello" }
|
||||||
|
div #result {}
|
||||||
|
}))
|
||||||
|
.render().await
|
||||||
|
}
|
||||||
|
```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
|
include_locales!(LOCALES_HTMX);
|
||||||
|
|
||||||
pub mod hx;
|
pub mod hx;
|
||||||
|
pub mod hx_table;
|
||||||
|
|
||||||
mod request;
|
mod request;
|
||||||
pub use request::HtmxRequestExt;
|
pub use request::HtmxRequestExt;
|
||||||
|
|
@ -76,7 +100,14 @@ pub use request::HtmxRequestExt;
|
||||||
mod response;
|
mod response;
|
||||||
pub use response::HtmxResponse;
|
pub use response::HtmxResponse;
|
||||||
|
|
||||||
include_locales!(LOCALES_HTMX);
|
/// Prelude de `pagetop-htmx`.
|
||||||
|
pub mod prelude {
|
||||||
|
pub use crate::hx;
|
||||||
|
pub use crate::hx_table;
|
||||||
|
|
||||||
|
pub use crate::request::HtmxRequestExt;
|
||||||
|
pub use crate::response::HtmxResponse;
|
||||||
|
}
|
||||||
|
|
||||||
/// Integra HTMX 2 en cualquier aplicación PageTop.
|
/// Integra HTMX 2 en cualquier aplicación PageTop.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ use pagetop::prelude::*;
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// use pagetop::prelude::*;
|
/// use pagetop::prelude::*;
|
||||||
/// use pagetop_htmx::{HtmxRequestExt, HtmxResponse};
|
/// use pagetop_htmx::prelude::*;
|
||||||
///
|
///
|
||||||
/// async fn list_items(request: HttpRequest) -> Response {
|
/// async fn list_items(request: HttpRequest) -> Response {
|
||||||
/// if request.is_htmx() {
|
/// if request.is_htmx() {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ use pagetop::prelude::*;
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// use pagetop::prelude::*;
|
/// use pagetop::prelude::*;
|
||||||
/// use pagetop_htmx::{HtmxResponse, hx};
|
/// use pagetop_htmx::prelude::*;
|
||||||
///
|
///
|
||||||
/// async fn add_item(request: HttpRequest) -> impl IntoResponse {
|
/// async fn add_item(request: HttpRequest) -> impl IntoResponse {
|
||||||
/// let new_item = html! { li #item-42 { "New item" } };
|
/// let new_item = html! { li #item-42 { "New item" } };
|
||||||
|
|
@ -37,7 +37,7 @@ use pagetop::prelude::*;
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// use pagetop::prelude::*;
|
/// use pagetop::prelude::*;
|
||||||
/// use pagetop_htmx::HtmxResponse;
|
/// use pagetop_htmx::prelude::*;
|
||||||
///
|
///
|
||||||
/// async fn delete_item() -> impl IntoResponse {
|
/// async fn delete_item() -> impl IntoResponse {
|
||||||
/// HtmxResponse::empty().redirect("/items")
|
/// HtmxResponse::empty().redirect("/items")
|
||||||
|
|
@ -59,7 +59,7 @@ use pagetop::prelude::*;
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// use pagetop::prelude::*;
|
/// use pagetop::prelude::*;
|
||||||
/// use pagetop_htmx::HtmxResponse;
|
/// use pagetop_htmx::prelude::*;
|
||||||
///
|
///
|
||||||
/// // Dos eventos sin datos:
|
/// // Dos eventos sin datos:
|
||||||
/// HtmxResponse::empty().trigger("itemAdded, listUpdated");
|
/// HtmxResponse::empty().trigger("itemAdded, listUpdated");
|
||||||
|
|
@ -67,6 +67,7 @@ use pagetop::prelude::*;
|
||||||
/// // Evento con datos en JSON:
|
/// // Evento con datos en JSON:
|
||||||
/// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42}}"#);
|
/// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42}}"#);
|
||||||
/// ```
|
/// ```
|
||||||
|
#[must_use]
|
||||||
pub struct HtmxResponse {
|
pub struct HtmxResponse {
|
||||||
markup: Markup,
|
markup: Markup,
|
||||||
headers: web::http::HeaderMap,
|
headers: web::http::HeaderMap,
|
||||||
|
|
@ -91,45 +92,123 @@ impl HtmxResponse {
|
||||||
/// Hace que HTMX realice una navegación AJAX a la URL indicada sin recargar la página.
|
/// Hace que HTMX realice una navegación AJAX a la URL indicada sin recargar la página.
|
||||||
///
|
///
|
||||||
/// A diferencia de [`redirect()`](Self::redirect), la navegación usa HTMX y actualiza sólo el
|
/// A diferencia de [`redirect()`](Self::redirect), la navegación usa HTMX y actualiza sólo el
|
||||||
/// objetivo definido por el destino. Acepta una URL o un objeto JSON con claves `path`,
|
/// objetivo definido por el destino. Para personalizar `target`, `swap`, `select` o `values`,
|
||||||
/// `target`, `swap`, `select` y `values` para personalizar la navegación:
|
/// usa [`location_json()`](Self::location_json).
|
||||||
|
///
|
||||||
|
/// Usa [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal
|
||||||
|
/// para que la URL preserve el parámetro `lang` cuando corresponda:
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// use pagetop::prelude::*;
|
/// use pagetop::prelude::*;
|
||||||
/// use pagetop_htmx::HtmxResponse;
|
/// use pagetop_htmx::prelude::*;
|
||||||
///
|
///
|
||||||
/// // Navegación simple:
|
/// # fn build_response(cx: &Context) -> HtmxResponse {
|
||||||
/// HtmxResponse::empty().location("/items");
|
/// HtmxResponse::empty().location(cx.route("/items"))
|
||||||
///
|
/// # }
|
||||||
/// // Navegación con destino personalizado:
|
|
||||||
/// HtmxResponse::empty()
|
|
||||||
/// .location(r##"{"path": "/items", "target": "#content"}"##);
|
|
||||||
/// ```
|
/// ```
|
||||||
pub fn location(self, url: impl Into<String>) -> Self {
|
pub fn location(self, url: impl Into<RoutePath>) -> Self {
|
||||||
self.set_header(b"hx-location", url)
|
self.set_header(b"hx-location", url.into().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hace que HTMX realice una navegación AJAX personalizada, con un objeto JSON de configuración
|
||||||
|
/// en lugar de una URL simple.
|
||||||
|
///
|
||||||
|
/// Acepta un objeto JSON con las claves `path`, `target`, `swap`, `select` y `values` (ver la
|
||||||
|
/// [documentación de HTMX](https://htmx.org/reference/#response_headers) para el detalle de
|
||||||
|
/// cada una). Al no ser una URL, no admite `Context::route()`: si `path` necesita el parámetro
|
||||||
|
/// `lang`, hay que componerlo a mano antes de construir el JSON. Para una navegación simple sin
|
||||||
|
/// estas opciones, usa [`location()`](Self::location).
|
||||||
|
///
|
||||||
|
/// Si `json` no es sintácticamente válido, la cabecera se descarta y se registra un aviso; el
|
||||||
|
/// resto de la respuesta no se ve afectado. Esta comprobación sólo valida la sintaxis JSON, no
|
||||||
|
/// que las claves sean las que espera HTMX.
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pagetop::prelude::*;
|
||||||
|
/// use pagetop_htmx::prelude::*;
|
||||||
|
///
|
||||||
|
/// HtmxResponse::empty()
|
||||||
|
/// .location_json(r##"{"path": "/items", "target": "#content"}"##);
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// Si algún valor se calcula en tiempo de ejecución, constrúyelo con [`serde_json::json!`] en
|
||||||
|
/// lugar de interpolarlo a mano con `format!()`: evita comillas u otros caracteres sin escapar
|
||||||
|
/// que romperían la estructura del JSON.
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pagetop::prelude::*;
|
||||||
|
/// use pagetop_htmx::prelude::*;
|
||||||
|
///
|
||||||
|
/// let item_name = "Alice's item"; // Contiene una comilla: no es seguro interpolarlo a mano.
|
||||||
|
///
|
||||||
|
/// let json = serde_json::json!({
|
||||||
|
/// "path": "/items",
|
||||||
|
/// "values": { "name": item_name },
|
||||||
|
/// })
|
||||||
|
/// .to_string();
|
||||||
|
///
|
||||||
|
/// HtmxResponse::empty().location_json(json);
|
||||||
|
/// ```
|
||||||
|
pub fn location_json(self, json: impl Into<String>) -> Self {
|
||||||
|
let json = json.into();
|
||||||
|
if let Err(error) = serde_json::from_str::<serde_json::Value>(&json) {
|
||||||
|
trace::warn!(
|
||||||
|
json = %json,
|
||||||
|
%error,
|
||||||
|
"HtmxResponse: invalid JSON in location_json(), header discarded",
|
||||||
|
);
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
self.set_header(b"hx-location", json)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Empuja la URL indicada al historial del navegador.
|
/// Empuja la URL indicada al historial del navegador.
|
||||||
///
|
///
|
||||||
/// El usuario podrá navegar hacia atrás hasta esa URL. Usar `"false"` para desactivar el empuje
|
/// El usuario podrá navegar hacia atrás hasta esa URL. Usar `"false"` para desactivar el empuje
|
||||||
/// aunque esté habilitado por el atributo `hx-push-url` del elemento.
|
/// aunque esté habilitado por el atributo `hx-push-url` del elemento.
|
||||||
pub fn push_url(self, url: impl Into<String>) -> Self {
|
///
|
||||||
self.set_header(b"hx-push-url", url)
|
/// Usa [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal
|
||||||
|
/// para que la URL preserve el parámetro `lang` cuando corresponda:
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pagetop::prelude::*;
|
||||||
|
/// use pagetop_htmx::prelude::*;
|
||||||
|
///
|
||||||
|
/// # fn build_response(cx: &Context) -> HtmxResponse {
|
||||||
|
/// HtmxResponse::empty().push_url(cx.route("/items"))
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
pub fn push_url(self, url: impl Into<RoutePath>) -> Self {
|
||||||
|
self.set_header(b"hx-push-url", url.into().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reemplaza la URL actual en el historial sin añadir una nueva entrada.
|
/// Reemplaza la URL actual en el historial sin añadir una nueva entrada.
|
||||||
///
|
///
|
||||||
/// Usar `"false"` para desactivar el reemplazo.
|
/// Usar `"false"` para desactivar el reemplazo. Usa
|
||||||
pub fn replace_url(self, url: impl Into<String>) -> Self {
|
/// [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal para
|
||||||
self.set_header(b"hx-replace-url", url)
|
/// que la URL preserve el parámetro `lang` cuando corresponda.
|
||||||
|
pub fn replace_url(self, url: impl Into<RoutePath>) -> Self {
|
||||||
|
self.set_header(b"hx-replace-url", url.into().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provoca una redirección completa del navegador a la URL indicada.
|
/// Provoca una redirección completa del navegador a la URL indicada.
|
||||||
///
|
///
|
||||||
/// A diferencia de [`location()`](Self::location), esta redirección recarga la página por
|
/// A diferencia de [`location()`](Self::location), esta redirección recarga la página por
|
||||||
/// completo, como un `window.location.href = url` en JavaScript.
|
/// completo, como un `window.location.href = url` en JavaScript.
|
||||||
pub fn redirect(self, url: impl Into<String>) -> Self {
|
///
|
||||||
self.set_header(b"hx-redirect", url)
|
/// Usa [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal
|
||||||
|
/// para que la URL preserve el parámetro `lang` cuando corresponda:
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pagetop::prelude::*;
|
||||||
|
/// use pagetop_htmx::prelude::*;
|
||||||
|
///
|
||||||
|
/// # fn build_response(cx: &Context) -> HtmxResponse {
|
||||||
|
/// HtmxResponse::empty().redirect(cx.route("/items"))
|
||||||
|
/// # }
|
||||||
|
/// ```
|
||||||
|
pub fn redirect(self, url: impl Into<RoutePath>) -> Self {
|
||||||
|
self.set_header(b"hx-redirect", url.into().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provoca una recarga completa de la página actual.
|
/// Provoca una recarga completa de la página actual.
|
||||||
|
|
@ -170,7 +249,7 @@ impl HtmxResponse {
|
||||||
///
|
///
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// use pagetop::prelude::*;
|
/// use pagetop::prelude::*;
|
||||||
/// use pagetop_htmx::HtmxResponse;
|
/// use pagetop_htmx::prelude::*;
|
||||||
///
|
///
|
||||||
/// // Evento simple:
|
/// // Evento simple:
|
||||||
/// HtmxResponse::empty().trigger("itemAdded");
|
/// HtmxResponse::empty().trigger("itemAdded");
|
||||||
|
|
@ -181,6 +260,21 @@ impl HtmxResponse {
|
||||||
/// // Evento con datos en JSON:
|
/// // Evento con datos en JSON:
|
||||||
/// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42, "name": "Example"}}"#);
|
/// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42, "name": "Example"}}"#);
|
||||||
/// ```
|
/// ```
|
||||||
|
///
|
||||||
|
/// Si el dato del evento se calcula en tiempo de ejecución, constrúyelo con
|
||||||
|
/// [`serde_json::json!`] en lugar de interpolarlo a mano con `format!()`, para evitar comillas
|
||||||
|
/// u otros caracteres sin escapar que romperían la estructura del JSON:
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pagetop::prelude::*;
|
||||||
|
/// use pagetop_htmx::prelude::*;
|
||||||
|
///
|
||||||
|
/// let item_name = "Alice's item"; // Contiene una comilla: no es seguro interpolarlo a mano.
|
||||||
|
///
|
||||||
|
/// let json = serde_json::json!({ "itemAdded": { "name": item_name } }).to_string();
|
||||||
|
///
|
||||||
|
/// HtmxResponse::empty().trigger(json);
|
||||||
|
/// ```
|
||||||
pub fn trigger(self, event: impl Into<String>) -> Self {
|
pub fn trigger(self, event: impl Into<String>) -> Self {
|
||||||
self.set_header(b"hx-trigger", event)
|
self.set_header(b"hx-trigger", event)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
62
extensions/pagetop-htmx/tests/extension.rs
Normal file
62
extensions/pagetop-htmx/tests/extension.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
|
use pagetop_htmx::Htmx;
|
||||||
|
|
||||||
|
struct TestApp;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Extension for TestApp {
|
||||||
|
fn dependencies(&self) -> Vec<ExtensionRef> {
|
||||||
|
vec![&Htmx]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_router(&self, router: Router) -> Router {
|
||||||
|
router.route("/page", web::get(render_page))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn render_page(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||||
|
Page::new(request)
|
||||||
|
.with_child(Html::with(|_| html! { p { "hello" } }))
|
||||||
|
.render()
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
// All tests in this file share the same root extension (`TestApp`), since `EXTENSIONS` is a global
|
||||||
|
// `OnceLock` initialized only once per test binary (see `core/extension/all.rs`).
|
||||||
|
|
||||||
|
// **< Static assets >******************************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn htmx_script_is_served_at_the_expected_static_path() {
|
||||||
|
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
|
||||||
|
|
||||||
|
let req = web::test::TestRequest::get()
|
||||||
|
.uri("/htmx/js/htmx.min.js")
|
||||||
|
.to_request();
|
||||||
|
let resp = web::test::send_request(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), web::http::StatusCode::OK);
|
||||||
|
|
||||||
|
let body = web::test::read_body_text(resp).await;
|
||||||
|
assert!(!body.is_empty());
|
||||||
|
assert!(body.contains("htmx"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Automatic script injection (BeforeRenderBody) >***********************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn rendered_pages_automatically_include_the_pinned_htmx_script_tag() {
|
||||||
|
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
|
||||||
|
|
||||||
|
let req = web::test::TestRequest::get().uri("/page").to_request();
|
||||||
|
let resp = web::test::send_request(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(resp.status(), web::http::StatusCode::OK);
|
||||||
|
|
||||||
|
let body = web::test::read_body_text(resp).await;
|
||||||
|
// The version must stay in sync with the bundled `assets/js/htmx.min.js`; a mismatch here
|
||||||
|
// would mean the browser caches a stale script under a version tag that no longer matches it.
|
||||||
|
assert!(body.contains(r#"src="/htmx/js/htmx.min.js?v=2.0.10""#));
|
||||||
|
assert!(body.contains("defer"));
|
||||||
|
assert!(body.contains("<p>hello</p>"));
|
||||||
|
}
|
||||||
160
extensions/pagetop-htmx/tests/hx.rs
Normal file
160
extensions/pagetop-htmx/tests/hx.rs
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
use pagetop_htmx::prelude::*;
|
||||||
|
|
||||||
|
// **< HTTP Methods >*******************************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn http_method_constants_match_the_htmx_attribute_names() {
|
||||||
|
assert_eq!(hx::GET, "hx-get");
|
||||||
|
assert_eq!(hx::POST, "hx-post");
|
||||||
|
assert_eq!(hx::PUT, "hx-put");
|
||||||
|
assert_eq!(hx::PATCH, "hx-patch");
|
||||||
|
assert_eq!(hx::DELETE, "hx-delete");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Target and Swap >****************************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn target_and_swap_constants_match_the_htmx_attribute_names() {
|
||||||
|
assert_eq!(hx::TARGET, "hx-target");
|
||||||
|
assert_eq!(hx::SWAP, "hx-swap");
|
||||||
|
assert_eq!(hx::SWAP_OOB, "hx-swap-oob");
|
||||||
|
assert_eq!(hx::SELECT, "hx-select");
|
||||||
|
assert_eq!(hx::SELECT_OOB, "hx-select-oob");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Trigger >************************************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn trigger_related_constants_match_the_htmx_attribute_names() {
|
||||||
|
assert_eq!(hx::TRIGGER, "hx-trigger");
|
||||||
|
assert_eq!(hx::BOOST, "hx-boost");
|
||||||
|
assert_eq!(hx::PUSH_URL, "hx-push-url");
|
||||||
|
assert_eq!(hx::REPLACE_URL, "hx-replace-url");
|
||||||
|
assert_eq!(hx::SYNC, "hx-sync");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Request Data >*******************************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn request_data_constants_match_the_htmx_attribute_names() {
|
||||||
|
assert_eq!(hx::INCLUDE, "hx-include");
|
||||||
|
assert_eq!(hx::PARAMS, "hx-params");
|
||||||
|
assert_eq!(hx::VALS, "hx-vals");
|
||||||
|
assert_eq!(hx::HEADERS, "hx-headers");
|
||||||
|
assert_eq!(hx::ENCODING, "hx-encoding");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Element Behavior >***************************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn element_behavior_constants_match_the_htmx_attribute_names() {
|
||||||
|
assert_eq!(hx::INDICATOR, "hx-indicator");
|
||||||
|
assert_eq!(hx::DISABLED_ELT, "hx-disabled-elt");
|
||||||
|
assert_eq!(hx::CONFIRM, "hx-confirm");
|
||||||
|
assert_eq!(hx::PROMPT, "hx-prompt");
|
||||||
|
assert_eq!(hx::VALIDATE, "hx-validate");
|
||||||
|
assert_eq!(hx::PRESERVE, "hx-preserve");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Config and Extensions >**********************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn config_and_extension_constants_match_the_htmx_attribute_names() {
|
||||||
|
assert_eq!(hx::EXT, "hx-ext");
|
||||||
|
assert_eq!(hx::DISINHERIT, "hx-disinherit");
|
||||||
|
assert_eq!(hx::INHERIT, "hx-inherit");
|
||||||
|
assert_eq!(hx::REQUEST, "hx-request");
|
||||||
|
assert_eq!(hx::HISTORY, "hx-history");
|
||||||
|
assert_eq!(hx::HISTORY_ELT, "hx-history-elt");
|
||||||
|
assert_eq!(hx::DISABLE, "hx-disable");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Inline Events (hx-on) >**********************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn on_builds_the_dom_event_attribute_name() {
|
||||||
|
assert_eq!(hx::on("click"), "hx-on:click");
|
||||||
|
assert_eq!(hx::on("mouseenter"), "hx-on:mouseenter");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn on_htmx_builds_the_htmx_lifecycle_event_attribute_name() {
|
||||||
|
assert_eq!(hx::on_htmx("before-request"), "hx-on::before-request");
|
||||||
|
assert_eq!(hx::on_htmx("after-swap"), "hx-on::after-swap");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn on_and_on_htmx_use_a_different_separator_for_the_same_event_name() {
|
||||||
|
// The single/double colon is the only thing that distinguishes a native DOM event from an
|
||||||
|
// HTMX lifecycle event with the same name; a typo here would silently listen to the wrong one.
|
||||||
|
let event = "after-swap";
|
||||||
|
assert_ne!(hx::on(event), hx::on_htmx(event));
|
||||||
|
assert_eq!(hx::on(event), "hx-on:after-swap");
|
||||||
|
assert_eq!(hx::on_htmx(event), "hx-on::after-swap");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< HTMX Request Headers (hx::request) >*********************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn request_header_constants_match_the_lowercase_htmx_header_names() {
|
||||||
|
assert_eq!(hx::request::REQUEST, "hx-request");
|
||||||
|
assert_eq!(hx::request::BOOSTED, "hx-boosted");
|
||||||
|
assert_eq!(hx::request::CURRENT_URL, "hx-current-url");
|
||||||
|
assert_eq!(
|
||||||
|
hx::request::HISTORY_RESTORE_REQUEST,
|
||||||
|
"hx-history-restore-request"
|
||||||
|
);
|
||||||
|
assert_eq!(hx::request::PROMPT, "hx-prompt");
|
||||||
|
assert_eq!(hx::request::TARGET, "hx-target");
|
||||||
|
assert_eq!(hx::request::TRIGGER, "hx-trigger");
|
||||||
|
assert_eq!(hx::request::TRIGGER_NAME, "hx-trigger-name");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< HTMX Response Headers (hx::response) >*******************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn response_header_constants_match_the_capitalized_htmx_header_names() {
|
||||||
|
// Unlike the request headers, HTMX documents the response headers in their canonical
|
||||||
|
// capitalized form (`HX-Location`, not `hx-location`); the constants mirror that on purpose.
|
||||||
|
assert_eq!(hx::response::LOCATION, "HX-Location");
|
||||||
|
assert_eq!(hx::response::PUSH_URL, "HX-Push-Url");
|
||||||
|
assert_eq!(hx::response::REDIRECT, "HX-Redirect");
|
||||||
|
assert_eq!(hx::response::REFRESH, "HX-Refresh");
|
||||||
|
assert_eq!(hx::response::REPLACE_URL, "HX-Replace-Url");
|
||||||
|
assert_eq!(hx::response::RESWAP, "HX-Reswap");
|
||||||
|
assert_eq!(hx::response::RETARGET, "HX-Retarget");
|
||||||
|
assert_eq!(hx::response::RESELECT, "HX-Reselect");
|
||||||
|
assert_eq!(hx::response::TRIGGER, "HX-Trigger");
|
||||||
|
assert_eq!(
|
||||||
|
hx::response::TRIGGER_AFTER_SETTLE,
|
||||||
|
"HX-Trigger-After-Settle"
|
||||||
|
);
|
||||||
|
assert_eq!(hx::response::TRIGGER_AFTER_SWAP, "HX-Trigger-After-Swap");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< hx-swap Values (hx::swap) >******************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn swap_value_constants_match_the_htmx_swap_strategies() {
|
||||||
|
assert_eq!(hx::swap::INNER_HTML, "innerHTML");
|
||||||
|
assert_eq!(hx::swap::OUTER_HTML, "outerHTML");
|
||||||
|
assert_eq!(hx::swap::BEFORE_BEGIN, "beforebegin");
|
||||||
|
assert_eq!(hx::swap::AFTER_BEGIN, "afterbegin");
|
||||||
|
assert_eq!(hx::swap::BEFORE_END, "beforeend");
|
||||||
|
assert_eq!(hx::swap::AFTER_END, "afterend");
|
||||||
|
assert_eq!(hx::swap::DELETE, "delete");
|
||||||
|
assert_eq!(hx::swap::NONE, "none");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< hx-trigger Values (hx::trigger) >************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn trigger_value_constants_match_the_htmx_event_names() {
|
||||||
|
assert_eq!(hx::trigger::CLICK, "click");
|
||||||
|
assert_eq!(hx::trigger::CHANGE, "change");
|
||||||
|
assert_eq!(hx::trigger::SUBMIT, "submit");
|
||||||
|
assert_eq!(hx::trigger::KEYUP, "keyup");
|
||||||
|
assert_eq!(hx::trigger::LOAD, "load");
|
||||||
|
assert_eq!(hx::trigger::REVEALED, "revealed");
|
||||||
|
assert_eq!(hx::trigger::INTERSECT, "intersect");
|
||||||
|
}
|
||||||
162
extensions/pagetop-htmx/tests/hx_table.rs
Normal file
162
extensions/pagetop-htmx/tests/hx_table.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
|
use pagetop_htmx::prelude::*;
|
||||||
|
|
||||||
|
// Forces an effective language different from the default negotiated one (en-US, with no `?lang` in
|
||||||
|
// the request), so that `Context::route()` decides to propagate `?lang=...` in local routes.
|
||||||
|
fn cx_with_lang(lang: &str) -> Context {
|
||||||
|
Context::new(None).with_langid(&Locale::resolve(lang))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn render_column(column: table::Column) -> String {
|
||||||
|
let mut table = Table::new().with_column(column);
|
||||||
|
table.render(&mut Context::default()).await.into_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< sort_link() - htmx attributes >**************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn sort_link_sets_the_four_fixed_htmx_attributes() {
|
||||||
|
let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link(
|
||||||
|
"/admin/users",
|
||||||
|
"#user-table",
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
|
||||||
|
let html = render_column(column).await;
|
||||||
|
|
||||||
|
assert!(html.contains(r#"hx-get="/admin/users""#));
|
||||||
|
assert!(html.contains(r##"hx-target="#user-table""##));
|
||||||
|
assert!(html.contains(r#"hx-swap="outerHTML""#));
|
||||||
|
assert!(html.contains(r#"hx-push-url="true""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn sort_link_href_matches_the_hx_get_value() {
|
||||||
|
// The link must work with or without HTMX: `href` is the real destination, and `hx-get` must
|
||||||
|
// request that very same URL so both navigation paths land on the same state.
|
||||||
|
let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link(
|
||||||
|
"/admin/users?sort=username",
|
||||||
|
"#user-table",
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
|
||||||
|
let html = render_column(column).await;
|
||||||
|
|
||||||
|
assert!(html.contains(r#"href="/admin/users?sort=username""#));
|
||||||
|
assert!(html.contains(r#"hx-get="/admin/users?sort=username""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn sort_link_target_is_configurable_per_table() {
|
||||||
|
let column = table::Column::new(L10n::n("Email")).with_sort(hx_table::sort_link(
|
||||||
|
"/admin/users",
|
||||||
|
"#other-wrapper",
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
|
||||||
|
let html = render_column(column).await;
|
||||||
|
|
||||||
|
assert!(html.contains(r##"hx-target="#other-wrapper""##));
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< sort_link() - sort direction propagation >***************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn sort_link_without_active_direction_marks_aria_sort_none() {
|
||||||
|
let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link(
|
||||||
|
"/admin/users",
|
||||||
|
"#user-table",
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
|
||||||
|
let html = render_column(column).await;
|
||||||
|
|
||||||
|
assert!(html.contains(r#"aria-sort="none""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn sort_link_with_active_direction_marks_aria_sort_and_css_class() {
|
||||||
|
let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link(
|
||||||
|
"/admin/users",
|
||||||
|
"#user-table",
|
||||||
|
SortDir::Desc,
|
||||||
|
));
|
||||||
|
|
||||||
|
let html = render_column(column).await;
|
||||||
|
|
||||||
|
assert!(html.contains(r#"aria-sort="descending""#));
|
||||||
|
assert!(html.contains("table-sort table-sort-desc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< sort_link() - RoutePath / Context::route() integration >*************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn sort_link_with_a_bare_literal_href_never_adds_lang() {
|
||||||
|
// `sort_link()` does not receive `cx`, so it cannot add `lang` on its own: passing a raw
|
||||||
|
// literal must leave both `href` and `hx-get` exactly as given.
|
||||||
|
let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link(
|
||||||
|
"/admin/users",
|
||||||
|
"#user-table",
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
|
||||||
|
let html = render_column(column).await;
|
||||||
|
|
||||||
|
assert!(html.contains(r#"href="/admin/users""#));
|
||||||
|
assert!(!html.contains("lang="));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn sort_link_carries_through_a_lang_aware_href_unchanged() {
|
||||||
|
// The caller is expected to resolve `href` with `cx.route(...)` beforehand (see the type's own
|
||||||
|
// doc example); `sort_link()` must not re-encode or otherwise alter what it receives.
|
||||||
|
let cx = cx_with_lang("es-ES");
|
||||||
|
let href = cx.route("/admin/users");
|
||||||
|
|
||||||
|
let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link(
|
||||||
|
href,
|
||||||
|
"#user-table",
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
|
||||||
|
let html = render_column(column).await;
|
||||||
|
|
||||||
|
assert!(html.contains(r#"href="/admin/users?lang=es-ES""#));
|
||||||
|
assert!(html.contains(r#"hx-get="/admin/users?lang=es-ES""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn sort_link_carries_through_extra_query_params_in_order() {
|
||||||
|
let cx = cx_with_lang("es-ES");
|
||||||
|
let href = cx
|
||||||
|
.route("/admin/users")
|
||||||
|
.with_param("sort", "username")
|
||||||
|
.with_param("dir", "desc");
|
||||||
|
|
||||||
|
let column = table::Column::new(L10n::n("User")).with_sort(hx_table::sort_link(
|
||||||
|
href,
|
||||||
|
"#user-table",
|
||||||
|
SortDir::Desc,
|
||||||
|
));
|
||||||
|
|
||||||
|
let html = render_column(column).await;
|
||||||
|
|
||||||
|
// `&` is escaped to `&` because this ends up inside an HTML attribute value.
|
||||||
|
assert!(html.contains(r#"href="/admin/users?lang=es-ES&sort=username&dir=desc""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn sort_link_with_an_external_href_is_left_untouched() {
|
||||||
|
// `Context::route()` never adds `lang` to a URL that looks external; `sort_link()` must not
|
||||||
|
// reintroduce it either, since it only forwards whatever `RoutePath` it receives.
|
||||||
|
let cx = cx_with_lang("es-ES");
|
||||||
|
let href = cx.route("https://example.com/export");
|
||||||
|
|
||||||
|
let column =
|
||||||
|
table::Column::new(L10n::n("Export")).with_sort(hx_table::sort_link(href, "#table", None));
|
||||||
|
|
||||||
|
let html = render_column(column).await;
|
||||||
|
|
||||||
|
assert!(html.contains(r#"href="https://example.com/export""#));
|
||||||
|
assert!(!html.contains("lang="));
|
||||||
|
}
|
||||||
169
extensions/pagetop-htmx/tests/request.rs
Normal file
169
extensions/pagetop-htmx/tests/request.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
|
use pagetop_htmx::HtmxRequestExt;
|
||||||
|
|
||||||
|
struct TestApp;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Extension for TestApp {
|
||||||
|
fn configure_router(&self, router: Router) -> Router {
|
||||||
|
router.route("/echo", web::get(echo_request))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reports every `HtmxRequestExt` value as JSON, so a single route can back every test in this file
|
||||||
|
// without needing a dedicated handler per header.
|
||||||
|
async fn echo_request(request: HttpRequest) -> String {
|
||||||
|
serde_json::json!({
|
||||||
|
"is_htmx": request.is_htmx(),
|
||||||
|
"is_boosted": request.is_boosted(),
|
||||||
|
"is_history_restore": request.is_history_restore(),
|
||||||
|
"current_url": request.hx_current_url(),
|
||||||
|
"target": request.hx_target(),
|
||||||
|
"trigger_id": request.hx_trigger_id(),
|
||||||
|
"trigger_name": request.hx_trigger_name(),
|
||||||
|
"prompt": request.hx_prompt(),
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn echo(app: &Router, headers: &[(&str, &str)]) -> serde_json::Value {
|
||||||
|
let mut req = web::test::TestRequest::get().uri("/echo");
|
||||||
|
for (name, value) in headers {
|
||||||
|
req = req.header(*name, *value);
|
||||||
|
}
|
||||||
|
let resp = web::test::send_request(app, req.to_request()).await;
|
||||||
|
let body = web::test::read_body_text(resp).await;
|
||||||
|
serde_json::from_str(&body).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// All tests in this file share the same root extension (`TestApp`), since `EXTENSIONS` is a global
|
||||||
|
// `OnceLock` initialized only once per test binary (see `core/extension/all.rs`).
|
||||||
|
|
||||||
|
// **< is_htmx() >**********************************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn is_htmx_is_true_only_when_hx_request_is_exactly_true() {
|
||||||
|
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
|
||||||
|
|
||||||
|
let with_header = echo(&app, &[("hx-request", "true")]).await;
|
||||||
|
assert_eq!(with_header["is_htmx"], true);
|
||||||
|
|
||||||
|
let without_header = echo(&app, &[]).await;
|
||||||
|
assert_eq!(without_header["is_htmx"], false);
|
||||||
|
|
||||||
|
// A stray/incorrect value must not be treated as a truthy HTMX request.
|
||||||
|
let wrong_value = echo(&app, &[("hx-request", "false")]).await;
|
||||||
|
assert_eq!(wrong_value["is_htmx"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< is_boosted() >*******************************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn is_boosted_reflects_the_hx_boosted_header() {
|
||||||
|
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
|
||||||
|
|
||||||
|
let boosted = echo(&app, &[("hx-boosted", "true")]).await;
|
||||||
|
assert_eq!(boosted["is_boosted"], true);
|
||||||
|
|
||||||
|
let not_boosted = echo(&app, &[]).await;
|
||||||
|
assert_eq!(not_boosted["is_boosted"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< is_history_restore() >***********************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn is_history_restore_reflects_the_hx_history_restore_request_header() {
|
||||||
|
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
|
||||||
|
|
||||||
|
let restoring = echo(&app, &[("hx-history-restore-request", "true")]).await;
|
||||||
|
assert_eq!(restoring["is_history_restore"], true);
|
||||||
|
|
||||||
|
let not_restoring = echo(&app, &[]).await;
|
||||||
|
assert_eq!(not_restoring["is_history_restore"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< hx_current_url() / hx_target() / hx_trigger_id() / hx_trigger_name() / hx_prompt() >*********
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn hx_current_url_reads_the_hx_current_url_header_when_present() {
|
||||||
|
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
|
||||||
|
|
||||||
|
let with_url = echo(&app, &[("hx-current-url", "/admin/users?page=2")]).await;
|
||||||
|
assert_eq!(with_url["current_url"], "/admin/users?page=2");
|
||||||
|
|
||||||
|
let without_url = echo(&app, &[]).await;
|
||||||
|
assert!(without_url["current_url"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn hx_target_reads_the_hx_target_header_when_present() {
|
||||||
|
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
|
||||||
|
|
||||||
|
let with_target = echo(&app, &[("hx-target", "user-table")]).await;
|
||||||
|
assert_eq!(with_target["target"], "user-table");
|
||||||
|
|
||||||
|
let without_target = echo(&app, &[]).await;
|
||||||
|
assert!(without_target["target"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn hx_trigger_id_reads_the_hx_trigger_header_when_present() {
|
||||||
|
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
|
||||||
|
|
||||||
|
let with_trigger = echo(&app, &[("hx-trigger", "save-button")]).await;
|
||||||
|
assert_eq!(with_trigger["trigger_id"], "save-button");
|
||||||
|
|
||||||
|
let without_trigger = echo(&app, &[]).await;
|
||||||
|
assert!(without_trigger["trigger_id"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn hx_trigger_name_reads_the_hx_trigger_name_header_when_present() {
|
||||||
|
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
|
||||||
|
|
||||||
|
let with_name = echo(&app, &[("hx-trigger-name", "email")]).await;
|
||||||
|
assert_eq!(with_name["trigger_name"], "email");
|
||||||
|
|
||||||
|
let without_name = echo(&app, &[]).await;
|
||||||
|
assert!(without_name["trigger_name"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn hx_prompt_reads_the_hx_prompt_header_when_present() {
|
||||||
|
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
|
||||||
|
|
||||||
|
let with_prompt = echo(&app, &[("hx-prompt", "Are you sure?")]).await;
|
||||||
|
assert_eq!(with_prompt["prompt"], "Are you sure?");
|
||||||
|
|
||||||
|
let without_prompt = echo(&app, &[]).await;
|
||||||
|
assert!(without_prompt["prompt"].is_null());
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< A realistic combined request >***************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn a_realistic_htmx_request_reports_all_fields_consistently() {
|
||||||
|
// Simulates a table sort click: a boosted-free HTMX request triggered by a link with an `id`,
|
||||||
|
// targeting the table wrapper.
|
||||||
|
let app = web::test::init_router(Application::prepare(&TestApp).await.test());
|
||||||
|
|
||||||
|
let result = echo(
|
||||||
|
&app,
|
||||||
|
&[
|
||||||
|
("hx-request", "true"),
|
||||||
|
("hx-target", "user-table"),
|
||||||
|
("hx-trigger", "sort-username"),
|
||||||
|
("hx-current-url", "/admin/users"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(result["is_htmx"], true);
|
||||||
|
assert_eq!(result["is_boosted"], false);
|
||||||
|
assert_eq!(result["is_history_restore"], false);
|
||||||
|
assert_eq!(result["target"], "user-table");
|
||||||
|
assert_eq!(result["trigger_id"], "sort-username");
|
||||||
|
assert_eq!(result["current_url"], "/admin/users");
|
||||||
|
assert!(result["trigger_name"].is_null());
|
||||||
|
assert!(result["prompt"].is_null());
|
||||||
|
}
|
||||||
233
extensions/pagetop-htmx/tests/response.rs
Normal file
233
extensions/pagetop-htmx/tests/response.rs
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
|
use pagetop_htmx::prelude::*;
|
||||||
|
|
||||||
|
// Forces an effective language different from the default negotiated one (en-US, with no `?lang` in
|
||||||
|
// the request), so that `Context::route()` decides to propagate `?lang=...` in local routes.
|
||||||
|
fn cx_with_lang(lang: &str) -> Context {
|
||||||
|
Context::new(None).with_langid(&Locale::resolve(lang))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header<'a>(response: &'a web::Response, name: &str) -> Option<&'a str> {
|
||||||
|
response.headers().get(name)?.to_str().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< HtmxResponse::new() / empty() >**************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn new_renders_the_given_markup_with_an_html_content_type() {
|
||||||
|
let response = HtmxResponse::new(html! { li #item-42 { "New item" } }).into_response();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
header(&response, "content-type"),
|
||||||
|
Some("text/html; charset=utf-8")
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = web::test::read_body_text(response).await;
|
||||||
|
assert_eq!(body, r#"<li id="item-42">New item</li>"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn empty_has_no_body_but_keeps_the_html_content_type() {
|
||||||
|
let response = HtmxResponse::empty().into_response();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
header(&response, "content-type"),
|
||||||
|
Some("text/html; charset=utf-8")
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = web::test::read_body_text(response).await;
|
||||||
|
assert_eq!(body, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< location() / location_json() >***************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn location_sets_hx_location_from_a_route_path() {
|
||||||
|
let response = HtmxResponse::empty().location("/items").into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-location"), Some("/items"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn location_preserves_lang_when_built_from_context_route() {
|
||||||
|
let cx = cx_with_lang("es-ES");
|
||||||
|
|
||||||
|
let response = HtmxResponse::empty()
|
||||||
|
.location(cx.route("/items"))
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-location"), Some("/items?lang=es-ES"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn location_json_sets_hx_location_when_the_json_is_syntactically_valid() {
|
||||||
|
let json = r##"{"path": "/items", "target": "#content"}"##;
|
||||||
|
|
||||||
|
let response = HtmxResponse::empty().location_json(json).into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-location"), Some(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn location_json_discards_the_header_when_the_json_is_malformed() {
|
||||||
|
// Missing closing brace: invalid JSON. The header must be silently dropped rather than sending
|
||||||
|
// a broken payload to the client.
|
||||||
|
let response = HtmxResponse::empty()
|
||||||
|
.location_json(r##"{"path": "/items""##)
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-location"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn location_json_only_validates_syntax_not_the_expected_keys() {
|
||||||
|
// A key HTMX does not recognize (`"tagret"` instead of `"target"`) is still valid JSON, so it
|
||||||
|
// passes this check; the mistake would only surface client-side. This documents that limit.
|
||||||
|
let json = r##"{"path": "/items", "tagret": "#content"}"##;
|
||||||
|
|
||||||
|
let response = HtmxResponse::empty().location_json(json).into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-location"), Some(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< push_url() / replace_url() / redirect() >****************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn push_url_sets_hx_push_url_from_a_route_path() {
|
||||||
|
let cx = cx_with_lang("es-ES");
|
||||||
|
|
||||||
|
let response = HtmxResponse::empty()
|
||||||
|
.push_url(cx.route("/items"))
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-push-url"), Some("/items?lang=es-ES"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn push_url_accepts_the_false_sentinel_to_disable_pushing() {
|
||||||
|
let response = HtmxResponse::empty().push_url("false").into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-push-url"), Some("false"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn replace_url_sets_hx_replace_url_from_a_route_path() {
|
||||||
|
let response = HtmxResponse::empty()
|
||||||
|
.replace_url("/items/42")
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-replace-url"), Some("/items/42"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn redirect_sets_hx_redirect_from_a_route_path() {
|
||||||
|
let cx = cx_with_lang("es-ES");
|
||||||
|
|
||||||
|
let response = HtmxResponse::empty()
|
||||||
|
.redirect(cx.route("/items"))
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-redirect"), Some("/items?lang=es-ES"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< refresh() / retarget() / reswap() / reselect() >*********************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn refresh_sets_hx_refresh_to_true() {
|
||||||
|
let response = HtmxResponse::empty().refresh().into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-refresh"), Some("true"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn retarget_reswap_and_reselect_set_the_expected_headers() {
|
||||||
|
let response = HtmxResponse::empty()
|
||||||
|
.retarget("#message")
|
||||||
|
.reswap(hx::swap::BEFORE_END)
|
||||||
|
.reselect("#fragment")
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-retarget"), Some("#message"));
|
||||||
|
assert_eq!(header(&response, "hx-reswap"), Some("beforeend"));
|
||||||
|
assert_eq!(header(&response, "hx-reselect"), Some("#fragment"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< trigger() / trigger_after_settle() / trigger_after_swap() >**********************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn trigger_accepts_a_single_event_name() {
|
||||||
|
let response = HtmxResponse::empty().trigger("itemAdded").into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-trigger"), Some("itemAdded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn trigger_accepts_multiple_comma_separated_events() {
|
||||||
|
let response = HtmxResponse::empty()
|
||||||
|
.trigger("itemAdded, listUpdated")
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
header(&response, "hx-trigger"),
|
||||||
|
Some("itemAdded, listUpdated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn trigger_accepts_a_json_payload_with_event_data() {
|
||||||
|
let json = r#"{"itemAdded": {"id": 42, "name": "Example"}}"#;
|
||||||
|
|
||||||
|
let response = HtmxResponse::empty().trigger(json).into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-trigger"), Some(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn trigger_after_settle_and_trigger_after_swap_use_their_own_headers() {
|
||||||
|
let response = HtmxResponse::empty()
|
||||||
|
.trigger_after_settle("settled")
|
||||||
|
.trigger_after_swap("swapped")
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
header(&response, "hx-trigger-after-settle"),
|
||||||
|
Some("settled")
|
||||||
|
);
|
||||||
|
assert_eq!(header(&response, "hx-trigger-after-swap"), Some("swapped"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Builder chaining behavior >******************************************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn chaining_several_methods_sets_all_their_headers_at_once() {
|
||||||
|
let response = HtmxResponse::new(html! { ul { li { "Item 1" } li { "Item 2" } } })
|
||||||
|
.retarget("#list")
|
||||||
|
.reswap(hx::swap::BEFORE_END)
|
||||||
|
.push_url("/items")
|
||||||
|
.trigger("itemAdded")
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-retarget"), Some("#list"));
|
||||||
|
assert_eq!(header(&response, "hx-reswap"), Some("beforeend"));
|
||||||
|
assert_eq!(header(&response, "hx-push-url"), Some("/items"));
|
||||||
|
assert_eq!(header(&response, "hx-trigger"), Some("itemAdded"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn calling_the_same_method_twice_the_last_call_wins() {
|
||||||
|
let response = HtmxResponse::empty()
|
||||||
|
.trigger("first")
|
||||||
|
.trigger("second")
|
||||||
|
.into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-trigger"), Some("second"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn a_header_value_with_control_characters_is_silently_discarded() {
|
||||||
|
// `\n` is forbidden in an HTTP header value; `set_header()` must drop it rather than panicking
|
||||||
|
// or producing a malformed response.
|
||||||
|
let response = HtmxResponse::empty().retarget("foo\nbar").into_response();
|
||||||
|
|
||||||
|
assert_eq!(header(&response, "hx-retarget"), None);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
//! Componentes nativos proporcionados por PageTop.
|
//! Componentes nativos proporcionados por PageTop.
|
||||||
|
|
||||||
|
pub mod layout;
|
||||||
|
|
||||||
mod block;
|
mod block;
|
||||||
pub use block::Block;
|
pub use block::Block;
|
||||||
|
|
||||||
|
|
|
||||||
7
src/base/component/layout.rs
Normal file
7
src/base/component/layout.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
//! Definiciones para la composición de documentos ([`Region`] y [`Template`]).
|
||||||
|
|
||||||
|
mod region;
|
||||||
|
pub use region::Region;
|
||||||
|
|
||||||
|
mod template;
|
||||||
|
pub use template::Template;
|
||||||
111
src/base/component/layout/region.rs
Normal file
111
src/base/component/layout/region.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// Componente que renderiza una región del `<body>`.
|
||||||
|
///
|
||||||
|
/// No recibe ningún contenido de quien lo construye. Lo obtiene directamente del [`Context`] en el
|
||||||
|
/// momento de renderizarse (ver [`Context::render_region()`]). Si la región no tiene contenido, no
|
||||||
|
/// se renderiza nada.
|
||||||
|
///
|
||||||
|
/// Si un tema necesita maquetar una región determinada de forma distinta, puede capturar este
|
||||||
|
/// componente en [`Theme::handle_component()`](crate::core::theme::Theme::handle_component) y hacer
|
||||||
|
/// [`downcast_ref()`](crate::core::AnyCast::downcast_ref) sobre el [`RegionRef`] que devuelve
|
||||||
|
/// [`Self::region()`], para compararlo con la variante deseada.
|
||||||
|
///
|
||||||
|
/// Como cualquier otro componente, participa también en el despacho de las
|
||||||
|
/// [acciones de componentes](crate::base::action::component) para que otras extensiones puedan
|
||||||
|
/// intervenir en su renderizado.
|
||||||
|
///
|
||||||
|
/// # Ejemplo
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use pagetop::prelude::*;
|
||||||
|
///
|
||||||
|
/// struct Sidebar;
|
||||||
|
///
|
||||||
|
/// impl RegionName for Sidebar {
|
||||||
|
/// fn name(&self) -> &'static str {
|
||||||
|
/// "sidebar"
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// fn label(&self) -> L10n {
|
||||||
|
/// L10n::n("Sidebar")
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// let header = layout::Region::header();
|
||||||
|
/// let sidebar = layout::Region::of(&Sidebar);
|
||||||
|
/// ```
|
||||||
|
#[derive(Clone, Getters)]
|
||||||
|
pub struct Region {
|
||||||
|
/// Devuelve la región subyacente.
|
||||||
|
#[getters(copy)]
|
||||||
|
region: RegionRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Region {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("Region")
|
||||||
|
.field("region", &self.region().name())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Region {
|
||||||
|
fn default() -> Self {
|
||||||
|
Region {
|
||||||
|
region: &CoreRegion::Content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Component for Region {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve el nombre de la región subyacente como identificador del componente.
|
||||||
|
fn id(&self) -> Option<String> {
|
||||||
|
Some(self.region().name().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||||
|
let name = self.region().name();
|
||||||
|
let content = cx.render_region(self.region()).await;
|
||||||
|
Ok(html! {
|
||||||
|
@if !content.is_empty() {
|
||||||
|
div
|
||||||
|
id=[self.id()]
|
||||||
|
class=(util::join!("region region-", name))
|
||||||
|
role="region"
|
||||||
|
aria-label=[self.region().label().lookup(cx)]
|
||||||
|
{
|
||||||
|
(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Region {
|
||||||
|
/// Define el componente que renderizará [`CoreRegion::Header`].
|
||||||
|
pub fn header() -> Self {
|
||||||
|
Region {
|
||||||
|
region: &CoreRegion::Header,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Define el componente que renderizará [`CoreRegion::Footer`].
|
||||||
|
pub fn footer() -> Self {
|
||||||
|
Region {
|
||||||
|
region: &CoreRegion::Footer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Define el componente que renderizará la región indicada.
|
||||||
|
pub fn of(region: RegionRef) -> Self {
|
||||||
|
Region { region }
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/base/component/layout/template.rs
Normal file
81
src/base/component/layout/template.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// Componente que renderiza el cuerpo de una plantilla de regiones.
|
||||||
|
///
|
||||||
|
/// La composición por defecto usa el componente [`Region`](crate::base::component::layout::Region)
|
||||||
|
/// para mostrar, en este orden, las regiones [`CoreRegion::Header`], [`CoreRegion::Content`] y
|
||||||
|
/// [`CoreRegion::Footer`].
|
||||||
|
///
|
||||||
|
/// No incluye las regiones reservadas
|
||||||
|
/// [`ReservedRegion::PageTop`](crate::response::ReservedRegion::PageTop) y
|
||||||
|
/// [`ReservedRegion::PageBottom`](crate::response::ReservedRegion::PageBottom) porque el propio
|
||||||
|
/// [`Page::render()`](crate::response::Page::render) las añade antes y después del resultado de
|
||||||
|
/// [`Theme::render_page_body()`](crate::core::theme::Theme::render_page_body) para que se
|
||||||
|
/// rendericen siempre, independientemente de la plantilla que se use.
|
||||||
|
///
|
||||||
|
/// Si un tema necesita maquetar una plantilla determinada de forma distinta, puede capturar este
|
||||||
|
/// componente en [`Theme::handle_component()`](crate::core::theme::Theme::handle_component) y hacer
|
||||||
|
/// [`downcast_ref()`](crate::core::AnyCast::downcast_ref) sobre el [`TemplateRef`] que devuelve
|
||||||
|
/// [`Self::template()`], para compararlo con la variante deseada.
|
||||||
|
///
|
||||||
|
/// Como cualquier otro componente, participa también en el despacho de las
|
||||||
|
/// [acciones de componentes](crate::base::action::component) para que otras extensiones puedan
|
||||||
|
/// intervenir en su renderizado.
|
||||||
|
#[derive(Clone, Getters)]
|
||||||
|
pub struct Template {
|
||||||
|
/// Devuelve la plantilla subyacente.
|
||||||
|
#[getters(copy)]
|
||||||
|
template: TemplateRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Template {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("Template")
|
||||||
|
.field("template", &self.template().name())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Template {
|
||||||
|
fn default() -> Self {
|
||||||
|
Template {
|
||||||
|
template: &CoreTemplate::Standard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Component for Template {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve el nombre de la plantilla subyacente como identificador del componente.
|
||||||
|
fn id(&self) -> Option<String> {
|
||||||
|
Some(self.template().name().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||||
|
Ok(html! {
|
||||||
|
(layout::Region::header().render(cx).await)
|
||||||
|
(layout::Region::default().render(cx).await)
|
||||||
|
(layout::Region::footer().render(cx).await)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Template {
|
||||||
|
/// Define el componente que renderizará [`CoreTemplate::Admin`].
|
||||||
|
pub fn admin() -> Self {
|
||||||
|
Template {
|
||||||
|
template: &CoreTemplate::Admin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Define el componente que renderizará la plantilla indicada.
|
||||||
|
pub fn of(template: TemplateRef) -> Self {
|
||||||
|
Template { template }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,7 @@ impl Theme for Basic {
|
||||||
.with_weight(-99),
|
.with_weight(-99),
|
||||||
))
|
))
|
||||||
.alter_child_in(
|
.alter_child_in(
|
||||||
&DefaultRegions::Footer,
|
&CoreRegion::Footer,
|
||||||
ChildOp::AddIfEmpty(PoweredBy::new().into()),
|
ChildOp::AddIfEmpty(PoweredBy::new().into()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ use crate::auth::CurrentUser;
|
||||||
use crate::core::TypeInfo;
|
use crate::core::TypeInfo;
|
||||||
use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage};
|
use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage};
|
||||||
use crate::core::theme::all::DEFAULT_THEME;
|
use crate::core::theme::all::DEFAULT_THEME;
|
||||||
use crate::core::theme::{ChildrenInRegions, DefaultRegions, RegionRef, TemplateRef, ThemeRef};
|
use crate::core::theme::{ChildrenInRegions, CoreRegion, CoreTemplate};
|
||||||
|
use crate::core::theme::{RegionRef, TemplateRef, ThemeRef};
|
||||||
use crate::html::{Assets, Favicon, JavaScript, Preload, StyleSheet};
|
use crate::html::{Assets, Favicon, JavaScript, Preload, StyleSheet};
|
||||||
use crate::html::{Markup, Props, PropsOp, RoutePath, html};
|
use crate::html::{Markup, Props, PropsOp, RoutePath, html};
|
||||||
use crate::locale::L10n;
|
use crate::locale::L10n;
|
||||||
|
|
@ -77,7 +78,7 @@ pub enum ContextError {
|
||||||
/// fn prepare_context<C: Contextual>(cx: C) -> C {
|
/// fn prepare_context<C: Contextual>(cx: C) -> C {
|
||||||
/// cx.with_langid(&Locale::resolve("es-ES"))
|
/// cx.with_langid(&Locale::resolve("es-ES"))
|
||||||
/// .with_theme(&Aliner)
|
/// .with_theme(&Aliner)
|
||||||
/// .with_template(&DefaultTemplates::Standard)
|
/// .with_template(&CoreTemplate::Standard)
|
||||||
/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico"))))
|
/// .with_assets(AssetsOp::SetFavicon(Some(Favicon::new().with_icon("/favicon.ico"))))
|
||||||
/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/app.css")))
|
/// .with_assets(AssetsOp::AddStyleSheet(StyleSheet::from("/css/app.css")))
|
||||||
/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/app.js")))
|
/// .with_assets(AssetsOp::AddJavaScript(JavaScript::defer("/js/app.js")))
|
||||||
|
|
@ -137,7 +138,7 @@ pub trait Contextual: LangId {
|
||||||
/// Añade un componente o aplica una operación [`ChildOp`] en una región específica del
|
/// Añade un componente o aplica una operación [`ChildOp`] en una región específica del
|
||||||
/// documento.
|
/// documento.
|
||||||
#[builder_fn]
|
#[builder_fn]
|
||||||
fn with_child_in(self, region_ref: RegionRef, op: impl Into<ChildOp>) -> Self;
|
fn with_child_in(self, region: RegionRef, op: impl Into<ChildOp>) -> Self;
|
||||||
|
|
||||||
// **< Contextual GETTERS >*********************************************************************
|
// **< Contextual GETTERS >*********************************************************************
|
||||||
|
|
||||||
|
|
@ -236,28 +237,6 @@ pub trait Contextual: LangId {
|
||||||
fn remove_param(&mut self, key: &'static str) -> bool;
|
fn remove_param(&mut self, key: &'static str) -> bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cómo obtener la plantilla activa del contexto: la del tema (por defecto o de administración), o
|
|
||||||
// una fijada explícitamente. Se resuelve contra el tema activo al leer `Context::template()`, no al
|
|
||||||
// asignarla, para que un cambio de tema posterior con `with_theme()` se refleje automáticamente.
|
|
||||||
enum TemplateSource {
|
|
||||||
// Plantilla por defecto.
|
|
||||||
Default,
|
|
||||||
// Plantilla de administración del tema activo.
|
|
||||||
Admin,
|
|
||||||
// Plantilla fijada explícitamente con `with_template()`.
|
|
||||||
Explicit(TemplateRef),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TemplateSource {
|
|
||||||
fn resolve(&self, theme: ThemeRef) -> TemplateRef {
|
|
||||||
match self {
|
|
||||||
Self::Default => theme.default_template(),
|
|
||||||
Self::Admin => theme.admin_template(),
|
|
||||||
Self::Explicit(template) => *template,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementa un **contexto de renderizado** para un documento HTML.
|
/// Implementa un **contexto de renderizado** para un documento HTML.
|
||||||
///
|
///
|
||||||
/// Se crea una sola vez por petición usando [`Context::new()`] (típicamente a través de
|
/// Se crea una sola vez por petición usando [`Context::new()`] (típicamente a través de
|
||||||
|
|
@ -278,9 +257,8 @@ impl TemplateSource {
|
||||||
/// identificadores HTML únicos por tipo de componente.
|
/// identificadores HTML únicos por tipo de componente.
|
||||||
/// - [`push_message()`](Self::push_message)/[`messages()`](Self::messages) para acumular
|
/// - [`push_message()`](Self::push_message)/[`messages()`](Self::messages) para acumular
|
||||||
/// [`StatusMessage`] que mostrar en algún momento del renderizado.
|
/// [`StatusMessage`] que mostrar en algún momento del renderizado.
|
||||||
/// - [`render_assets()`](Self::render_assets)/[`render_region_named()`](Self::render_region_named),
|
/// - [`render_assets()`](Self::render_assets)/[`render_region()`](Self::render_region), usados
|
||||||
/// usados internamente por [`Page`](crate::response::Page) para producir el HTML final del
|
/// internamente por [`Page`](crate::response::Page) para producir el HTML final del documento.
|
||||||
/// documento.
|
|
||||||
///
|
///
|
||||||
/// # Ejemplos
|
/// # Ejemplos
|
||||||
///
|
///
|
||||||
|
|
@ -332,7 +310,7 @@ pub struct Context {
|
||||||
locale : RequestLocale, // Idioma asociado a la petición.
|
locale : RequestLocale, // Idioma asociado a la petición.
|
||||||
current_user: CurrentUser, // Identidad del usuario actual.
|
current_user: CurrentUser, // Identidad del usuario actual.
|
||||||
theme : ThemeRef, // Referencia al tema usado para renderizar.
|
theme : ThemeRef, // Referencia al tema usado para renderizar.
|
||||||
template : TemplateSource, // Plantilla usada para renderizar.
|
template : TemplateRef, // Plantilla usada para renderizar.
|
||||||
favicon : Option<Favicon>, // Favicon, si se ha definido.
|
favicon : Option<Favicon>, // Favicon, si se ha definido.
|
||||||
preloads : Assets<Preload>, // Recursos para precarga.
|
preloads : Assets<Preload>, // Recursos para precarga.
|
||||||
stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS.
|
stylesheets : Assets<StyleSheet>, // Hojas de estilo CSS.
|
||||||
|
|
@ -364,7 +342,7 @@ impl Context {
|
||||||
locale,
|
locale,
|
||||||
current_user,
|
current_user,
|
||||||
theme : *DEFAULT_THEME,
|
theme : *DEFAULT_THEME,
|
||||||
template : TemplateSource::Default,
|
template : &CoreTemplate::Standard,
|
||||||
favicon : None,
|
favicon : None,
|
||||||
preloads : Assets::<Preload>::new(),
|
preloads : Assets::<Preload>::new(),
|
||||||
stylesheets: Assets::<StyleSheet>::new(),
|
stylesheets: Assets::<StyleSheet>::new(),
|
||||||
|
|
@ -386,13 +364,6 @@ impl Context {
|
||||||
.unwrap_or(CurrentUser::Anonymous)
|
.unwrap_or(CurrentUser::Anonymous)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fuerza la plantilla de administración del tema activo (usada por `Page::admin()`). Se
|
|
||||||
// resuelve dinámicamente contra `self.theme`, igual que `TemplateSource::Default`, así que
|
|
||||||
// sigue reflejando cualquier cambio de tema posterior con `with_theme()`.
|
|
||||||
pub(crate) fn use_admin_template(&mut self) {
|
|
||||||
self.template = TemplateSource::Admin;
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< Context RENDER >*************************************************************************
|
// **< Context RENDER >*************************************************************************
|
||||||
|
|
||||||
/// Renderiza los recursos del contexto.
|
/// Renderiza los recursos del contexto.
|
||||||
|
|
@ -426,9 +397,13 @@ impl Context {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renderiza los componentes de una región.
|
/// Renderiza los componentes de una región.
|
||||||
pub async fn render_region_named(&mut self, region_name: &str) -> Markup {
|
///
|
||||||
|
/// Combina los componentes registrados para esta región en la petición actual con los
|
||||||
|
/// prototipos globales añadidos vía [`InRegion`](crate::core::theme::InRegion) (comunes o
|
||||||
|
/// específicos del tema activo).
|
||||||
|
pub async fn render_region(&mut self, region: RegionRef) -> Markup {
|
||||||
self.regions
|
self.regions
|
||||||
.assemble_region(self.theme, region_name)
|
.assemble_region(self.theme, region)
|
||||||
.render(self)
|
.render(self)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
@ -568,7 +543,7 @@ impl Contextual for Context {
|
||||||
|
|
||||||
#[builder_fn]
|
#[builder_fn]
|
||||||
fn with_template(mut self, template: TemplateRef) -> Self {
|
fn with_template(mut self, template: TemplateRef) -> Self {
|
||||||
self.template = TemplateSource::Explicit(template);
|
self.template = template;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -624,14 +599,13 @@ impl Contextual for Context {
|
||||||
|
|
||||||
#[builder_fn]
|
#[builder_fn]
|
||||||
fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
|
fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
|
||||||
self.regions
|
self.regions.alter_child_in(&CoreRegion::Content, op.into());
|
||||||
.alter_child_in(&DefaultRegions::Content, op.into());
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
#[builder_fn]
|
#[builder_fn]
|
||||||
fn with_child_in(mut self, region_ref: RegionRef, op: impl Into<ChildOp>) -> Self {
|
fn with_child_in(mut self, region: RegionRef, op: impl Into<ChildOp>) -> Self {
|
||||||
self.regions.alter_child_in(region_ref, op.into());
|
self.regions.alter_child_in(region, op.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -650,7 +624,7 @@ impl Contextual for Context {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn template(&self) -> TemplateRef {
|
fn template(&self) -> TemplateRef {
|
||||||
self.template.resolve(self.theme)
|
self.template
|
||||||
}
|
}
|
||||||
|
|
||||||
fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> {
|
fn param<T: 'static>(&self, key: &'static str) -> Result<&T, ContextError> {
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,15 @@ impl Route {
|
||||||
pub fn resolve(&self, cx: &Context) -> RoutePath {
|
pub fn resolve(&self, cx: &Context) -> RoutePath {
|
||||||
(self.0)(cx)
|
(self.0)(cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Como [`resolve()`](Self::resolve), pero devuelve `None` cuando la ruta calculada está vacía.
|
||||||
|
///
|
||||||
|
/// Útil para atributos HTML opcionales (por ejemplo `href`) que no deben renderizarse si la
|
||||||
|
/// ruta resultante no tiene contenido.
|
||||||
|
pub fn try_resolve(&self, cx: &Context) -> Option<RoutePath> {
|
||||||
|
let route = self.resolve(cx);
|
||||||
|
(!route.is_empty()).then_some(route)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for Route {
|
impl fmt::Debug for Route {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
//! API para añadir y gestionar nuevos temas.
|
//! API para añadir y gestionar nuevos temas.
|
||||||
//!
|
//!
|
||||||
//! Un tema es la *piel* de la aplicación: define estilos, tipografías, espaciados o comportamientos
|
//! Un tema es la *piel* de la aplicación: define estilos, tipografías, espaciados o comportamientos
|
||||||
//! interactivos. Para ello utiliza plantillas ([`Template`]) que describen cómo maquetar el cuerpo
|
//! interactivos. Usa plantillas ([`Template`](crate::base::component::layout::Template)) para
|
||||||
//! del documento a partir de regiones ([`Region`]). Cada región es un contenedor lógico
|
//! maquetar los contenidos en base a regiones ([`Region`](crate::base::component::layout::Region)).
|
||||||
//! identificado por un nombre para agrupar y renderizar componentes.
|
//! Cada región es un contenedor lógico identificado por un nombre para agrupar y renderizar
|
||||||
|
//! componentes.
|
||||||
//!
|
//!
|
||||||
//! Una página ([`Page`](crate::response::Page)) es un documento HTML completo. Implementa
|
//! Una página ([`Page`](crate::response::Page)) es un documento HTML completo. Implementa
|
||||||
//! [`Contextual`](crate::core::component::Contextual) para gestionar su propio [`Context`], donde
|
//! [`Contextual`](crate::core::component::Contextual) para gestionar su propio
|
||||||
//! mantiene el tema activo, la plantilla seleccionada y los componentes asociados a cada región a
|
//! [`Context`](crate::core::component::Context), donde mantiene el tema activo, la plantilla
|
||||||
//! renderizar.
|
//! seleccionada y los componentes asociados a cada región a renderizar.
|
||||||
//!
|
//!
|
||||||
//! Además, PageTop permite crear **temas hijo** que refinan el comportamiento de su tema padre. Un
|
//! Además, PageTop permite crear **temas hijo** que refinan el comportamiento de su tema padre. Un
|
||||||
//! tema hijo hereda automáticamente todos los métodos del padre y puede sobrescribirlos
|
//! tema hijo hereda automáticamente todos los métodos del padre y puede sobrescribirlos
|
||||||
|
|
@ -26,24 +27,34 @@
|
||||||
//! Un tema puede personalizarse en tres pasos, cada uno necesario sólo si lo que ofrece PageTop por
|
//! Un tema puede personalizarse en tres pasos, cada uno necesario sólo si lo que ofrece PageTop por
|
||||||
//! defecto no basta:
|
//! defecto no basta:
|
||||||
//!
|
//!
|
||||||
//! 1. **Definir regiones propias**. Por defecto PageTop define [`DefaultRegions`], con tres
|
//! 1. **Definir regiones nuevas**. Por defecto, PageTop define [`CoreRegion`] (`Header`, `Content`,
|
||||||
//! regiones (`Header`, `Content` y `Footer`) que usan la implementación por defecto de
|
//! `Footer`) como las regiones de plantilla que se asumen siempre disponibles, y
|
||||||
//! [`Region::render()`]. Un tema puede definir un *enum* propio que implemente [`Region`] para
|
//! [`ReservedRegion`](crate::response::ReservedRegion) (`PageTop`, `PageBottom`) como las
|
||||||
//! exponer sus propias regiones (por ejemplo, una barra lateral) o para cambiar cómo se muestra
|
//! regiones reservadas que se renderizan al margen de cualquier plantilla. Un tema puede definir
|
||||||
//! el contenido de una región ya existente (identificada por su nombre).
|
//! su propio *enum* que implemente [`RegionName`] para **añadir** nuevas regiones que PageTop no
|
||||||
//! 2. **Definir plantillas propias**. Por defecto existe [`DefaultTemplates`], con dos plantillas
|
//! ofrece (por ejemplo, una barra lateral). No es necesario redefinir las de [`CoreRegion`] ni
|
||||||
//! (`Standard` y `Admin`) que usan la implementación por defecto de [`Template::render()`] para
|
//! las de [`ReservedRegion`](crate::response::ReservedRegion), que ya existen y se asume que
|
||||||
//! renderizar [`DefaultRegions::Header`], [`DefaultRegions::Content`] y
|
//! cualquier tema respeta.
|
||||||
//! [`DefaultRegions::Footer`], en este orden. Un tema puede definir un *enum* propio que
|
//! 2. **Definir plantillas nuevas**. Por defecto existe [`CoreTemplate`], con las plantillas
|
||||||
//! implemente [`Template`] para crear nuevas plantillas, maquetar las regiones de otra forma,
|
//! `Standard` y `Admin` que usan `Page::new()` y `Page::admin()` respectivamente, y que son
|
||||||
//! cambiar su orden o envolverlas en contenedores adicionales.
|
//! siempre las mismas: no hay un método de `Theme` para elegir una plantilla predeterminada
|
||||||
//! 3. **Elegir las plantillas predeterminadas**. Por un lado, la plantilla por defecto vía
|
//! distinta. Un tema puede definir su propio *enum* que implemente [`TemplateName`] para
|
||||||
//! [`Theme::default_template()`] y, por otro, la plantilla para las páginas de administración,
|
//! **añadir** plantillas que PageTop no ofrece, y no para redefinir `Standard`/`Admin`.
|
||||||
//! [`Theme::admin_template()`]. De esta forma, las páginas creadas con `Page::new()` usarán
|
//! 3. **Cambiar cómo se renderiza** una región, una plantilla o un componente ya existente, se hace
|
||||||
//! automáticamente la plantilla `default_template()` del tema activo, y las páginas creadas con
|
//! capturando el componente ([`Region`](crate::base::component::layout::Region) o
|
||||||
//! `Page::admin()` usarán la de `admin_template()`, sin tener que llamar manualmente a
|
//! [`Template`](crate::base::component::layout::Template), o el componente que sea) en
|
||||||
//! [`with_template()`](crate::core::component::Contextual::with_template). Un tema que no
|
//! [`Theme::handle_component()`]. En el caso de regiones y plantillas, para distinguir *qué*
|
||||||
//! sobrescriba estos métodos sigue usando las plantillas por defecto de PageTop.
|
//! región o plantilla concreta envuelve el componente, sin comparar cadenas, basta con encadenar
|
||||||
|
//! el *getter* correspondiente
|
||||||
|
//! ([`Region::region()`](crate::base::component::layout::Region::region) o
|
||||||
|
//! [`Template::template()`](crate::base::component::layout::Template::template)) con
|
||||||
|
//! [`AnyCast::downcast_ref()`](crate::core::AnyCast::downcast_ref) hacia el tipo concreto (por
|
||||||
|
//! ejemplo, [`CoreTemplate`] o el propio *enum* del tema). `pagetop-bootsier` hace exactamente
|
||||||
|
//! esto para maquetar `Standard` y `Admin` de forma distinta, sin necesitar sus propias
|
||||||
|
//! variantes de plantilla.
|
||||||
|
//!
|
||||||
|
//! Para forzar una plantilla completamente distinta en una página concreta, se puede llamar
|
||||||
|
//! manualmente a [`with_template()`](crate::core::component::Contextual::with_template).
|
||||||
//!
|
//!
|
||||||
//! Las páginas de error (403, 404, y otros errores fatales) no tienen una plantilla propia: se
|
//! Las páginas de error (403, 404, y otros errores fatales) no tienen una plantilla propia: se
|
||||||
//! renderizan con la plantilla ya activa en la página, para que el usuario no pierda el contexto de
|
//! renderizan con la plantilla ya activa en la página, para que el usuario no pierda el contexto de
|
||||||
|
|
@ -51,9 +62,8 @@
|
||||||
//! [`Theme::error_403()`], [`Theme::error_404()`] o [`Theme::error_fatal()`], sin necesidad de una
|
//! [`Theme::error_403()`], [`Theme::error_404()`] o [`Theme::error_fatal()`], sin necesidad de una
|
||||||
//! plantilla distinta.
|
//! plantilla distinta.
|
||||||
//!
|
//!
|
||||||
//! El resto del comportamiento de un tema (renderizado del `<head>`, o intervención en el
|
//! El resto del comportamiento de un tema (por ejemplo, el renderizado del `<head>`) se sobrescribe
|
||||||
//! renderizado de componentes concretos con [`Theme::handle_component()`]) se sobrescribe de forma
|
//! de forma independiente de estos tres pasos y no es necesario para tener un tema funcional.
|
||||||
//! independiente de estos tres pasos y no es necesario para tener un tema funcional.
|
|
||||||
//!
|
//!
|
||||||
//! # Componentes que se procesan en todas las páginas
|
//! # Componentes que se procesan en todas las páginas
|
||||||
//!
|
//!
|
||||||
|
|
@ -69,7 +79,7 @@
|
||||||
//!
|
//!
|
||||||
//! ```rust,no_run
|
//! ```rust,no_run
|
||||||
//! # use pagetop::prelude::*;
|
//! # use pagetop::prelude::*;
|
||||||
//! InRegion::Global(&DefaultRegions::Footer).add(PoweredBy::new());
|
//! InRegion::Global(&CoreRegion::Footer).add(PoweredBy::new());
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! El componente se guarda como **prototipo**: cada página recibe un clon fresco en el momento del
|
//! El componente se guarda como **prototipo**: cada página recibe un clon fresco en el momento del
|
||||||
|
|
@ -82,90 +92,66 @@
|
||||||
//! sola vez y que decida por sí mismo cuándo mostrarse, por ejemplo según la ruta de la petición o
|
//! sola vez y que decida por sí mismo cuándo mostrarse, por ejemplo según la ruta de la petición o
|
||||||
//! si el usuario actual está autenticado.
|
//! si el usuario actual está autenticado.
|
||||||
|
|
||||||
use crate::async_trait;
|
use crate::AutoDefault;
|
||||||
use crate::core::component::Context;
|
use crate::core::AnyInfo;
|
||||||
use crate::html::{Markup, html};
|
|
||||||
use crate::locale::L10n;
|
use crate::locale::L10n;
|
||||||
use crate::{AutoDefault, util};
|
|
||||||
|
|
||||||
// **< Region >*************************************************************************************
|
// **< RegionName >*********************************************************************************
|
||||||
|
|
||||||
/// Interfaz común para las regiones lógicas de un documento.
|
/// Interfaz común para las regiones lógicas del `<body>`.
|
||||||
///
|
///
|
||||||
/// Una `Region` representa un contenedor lógico identificado por un nombre de región. Su contenido
|
/// Una `RegionName` representa un contenedor lógico identificado por un nombre de región. Su
|
||||||
/// se obtiene del [`Context`], donde los componentes suelen registrarse usando implementaciones de
|
/// contenido se obtiene del [`Context`](crate::core::component::Context), donde los componentes
|
||||||
/// métodos como [`Contextual::with_child_in()`](crate::core::component::Contextual::with_child_in).
|
/// suelen registrarse usando implementaciones de métodos como
|
||||||
|
/// [`Contextual::with_child_in()`](crate::core::component::Contextual::with_child_in).
|
||||||
///
|
///
|
||||||
/// El contenido de una región viene determinado únicamente por su nombre, no por su tipo. Distintas
|
/// El contenido de una región viene determinado únicamente por su nombre, no por su tipo. Distintas
|
||||||
/// implementaciones de [`Region`] que devuelvan el mismo nombre compartirán el mismo conjunto de
|
/// implementaciones de [`RegionName`] que devuelvan el mismo nombre comparten el mismo conjunto de
|
||||||
/// componentes registrados en el [`Context`], aunque cada región puede renderizar ese contenido de
|
/// componentes registrados en el [`Context`](crate::core::component::Context). Un *enum* propio que
|
||||||
/// forma diferente. Por ejemplo, [`DefaultRegions::Header`] y `BootsierRegions::Header` mostrarían
|
/// implemente [`RegionName`] está pensado para **añadir** regiones que PageTop no ofrece (con un
|
||||||
/// los mismos componentes si ambas devuelven el nombre `"header"`, pero podrían maquetarse de
|
/// nombre propio que no colisione con los de [`CoreRegion`] o
|
||||||
/// manera distinta.
|
/// [`ReservedRegion`](crate::response::ReservedRegion)).
|
||||||
///
|
///
|
||||||
/// El tema decide qué regiones mostrar en el cuerpo del documento, normalmente usando una plantilla
|
/// El tema decide qué regiones mostrar en el `<body>`, normalmente usando una plantilla
|
||||||
/// ([`Template`]) al renderizar la página ([`Page`](crate::response::Page)).
|
/// ([`TemplateName`]) al renderizar la página ([`Page`](crate::response::Page)).
|
||||||
#[async_trait]
|
///
|
||||||
pub trait Region: Send + Sync {
|
/// Requiere [`AnyInfo`] para que un [`RegionRef`] pueda recuperarse mediante
|
||||||
|
/// [`AnyCast::downcast_ref()`](crate::core::AnyCast::downcast_ref) hacia su tipo concreto (por
|
||||||
|
/// ejemplo, para que un tema distinga en
|
||||||
|
/// [`Theme::handle_component()`](crate::core::theme::Theme::handle_component) qué variante
|
||||||
|
/// concreta está renderizando el componente [`Region`](crate::base::component::layout::Region)).
|
||||||
|
pub trait RegionName: Send + Sync + AnyInfo {
|
||||||
/// Devuelve el nombre de la región.
|
/// Devuelve el nombre de la región.
|
||||||
///
|
///
|
||||||
/// Este nombre es el identificador lógico de la región y se usa como clave en el [`Context`]
|
/// Este nombre es el identificador lógico de la región y se usa como clave en el
|
||||||
/// para recuperar y renderizar el contenido registrado bajo ese nombre. Cualquier
|
/// [`Context`](crate::core::component::Context) para recuperar y renderizar el contenido
|
||||||
/// implementación de [`Region`] que devuelva el mismo nombre compartirá el mismo conjunto de
|
/// registrado bajo ese nombre. Cualquier implementación de [`RegionName`] que devuelva el mismo
|
||||||
/// componentes.
|
/// nombre compartirá el mismo conjunto de componentes.
|
||||||
///
|
|
||||||
/// En la implementación predeterminada de [`Self::render()`] también se utiliza para construir
|
|
||||||
/// las clases del contenedor de la región (`"region region-<name>"`).
|
|
||||||
fn name(&self) -> &'static str;
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
/// Devuelve un *texto localizado* como etiqueta de accesibilidad asociada a la región.
|
/// Devuelve un *texto localizado* como etiqueta de accesibilidad asociada a la región.
|
||||||
///
|
///
|
||||||
/// En la implementación predeterminada de [`Self::render()`], este valor se usa como
|
/// En la implementación predeterminada de [`Region`](crate::base::component::layout::Region),
|
||||||
/// `aria-label` del contenedor de la región.
|
/// este valor se usa como `aria-label` del contenedor de la región.
|
||||||
fn label(&self) -> L10n;
|
fn label(&self) -> L10n;
|
||||||
|
|
||||||
/// Renderiza el contenedor de la región.
|
|
||||||
///
|
|
||||||
/// Por defecto, recupera del [`Context`] el contenido de la región y, si no está vacío, lo
|
|
||||||
/// envuelve en un `<div>` con clases `"region region-<name>"` y un `aria-label` basado en el
|
|
||||||
/// *texto localizado* de la etiqueta asociada a la región:
|
|
||||||
///
|
|
||||||
/// ```html
|
|
||||||
/// <div class="region region-<name>" role="region" aria-label="<label>">
|
|
||||||
/// <!-- Componentes de la región "name" -->
|
|
||||||
/// </div>
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Se puede sobrescribir este método para modificar la estructura del contenedor, las clases
|
|
||||||
/// utilizadas o la semántica del marcado generado para cada región.
|
|
||||||
async fn render(&self, cx: &mut Context) -> Markup {
|
|
||||||
html! {
|
|
||||||
@let region = cx.render_region_named(self.name()).await;
|
|
||||||
@if !region.is_empty() {
|
|
||||||
div
|
|
||||||
class=(util::join!("region region-", self.name()))
|
|
||||||
role="region"
|
|
||||||
aria-label=[self.label().lookup(cx)]
|
|
||||||
{
|
|
||||||
(region)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Referencia estática a una región.
|
/// Referencia estática a una región.
|
||||||
pub type RegionRef = &'static dyn Region;
|
pub type RegionRef = &'static dyn RegionName;
|
||||||
|
|
||||||
// **< DefaultRegions >*****************************************************************************
|
// **< CoreRegion >*********************************************************************************
|
||||||
|
|
||||||
/// Regiones básicas que PageTop proporciona por defecto.
|
/// Regiones básicas que PageTop proporciona por defecto.
|
||||||
///
|
///
|
||||||
/// Estas regiones comparten sus nombres (`"header"`, `"content"`, `"footer"`) con cualquier región
|
/// Comparten sus nombres (`"header"`, `"content"`, `"footer"`) con otras regiones que implementen
|
||||||
/// equivalente definida por otros temas, por lo que comparten también el contenido registrado bajo
|
/// [`RegionName`], por lo que comparten también el contenido registrado bajo esos nombres. Por
|
||||||
/// esos nombres.
|
/// defecto, son las regiones usadas por [`Template`](crate::base::component::layout::Template).
|
||||||
|
///
|
||||||
|
/// A estas regiones hay que sumar también las regiones internas reservadas por
|
||||||
|
/// [`ReservedRegion`](crate::response::ReservedRegion) (`"page-top"` y `"page-bottom"`), que
|
||||||
|
/// [`Page::render()`](crate::response::Page::render) renderiza en cualquier caso.
|
||||||
#[derive(AutoDefault)]
|
#[derive(AutoDefault)]
|
||||||
pub enum DefaultRegions {
|
pub enum CoreRegion {
|
||||||
/// Región estándar para la **cabecera** del documento, de nombre `"header"`.
|
/// Región estándar para la **cabecera** del documento, de nombre `"header"`.
|
||||||
///
|
///
|
||||||
/// Suele emplearse para mostrar un logotipo, navegación principal, barras superiores, etc.
|
/// Suele emplearse para mostrar un logotipo, navegación principal, barras superiores, etc.
|
||||||
|
|
@ -184,7 +170,7 @@ pub enum DefaultRegions {
|
||||||
Footer,
|
Footer,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Region for DefaultRegions {
|
impl RegionName for CoreRegion {
|
||||||
#[inline]
|
#[inline]
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
|
@ -204,63 +190,64 @@ impl Region for DefaultRegions {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **< Template >***********************************************************************************
|
// **< TemplateName >*******************************************************************************
|
||||||
|
|
||||||
/// Interfaz común para definir plantillas de contenido.
|
/// Interfaz común para las plantillas lógicas de una página.
|
||||||
///
|
///
|
||||||
/// Una `Template` puede proporcionar una o más variantes para decidir la composición del `<body>`
|
/// Representa una variante identificada por un nombre. Un tema puede usar este nombre para decidir
|
||||||
/// de una página ([`Page`](crate::response::Page)). El tema utiliza esta información para
|
/// la composición del cuerpo de una página ([`Page`](crate::response::Page)), es decir, qué
|
||||||
/// determinar qué regiones ([`Region`]) deben renderizarse y en qué orden.
|
/// regiones ([`RegionName`]) renderizar y en qué orden.
|
||||||
#[async_trait]
|
|
||||||
pub trait Template: Send + Sync {
|
|
||||||
/// Renderiza el contenido de la plantilla.
|
|
||||||
///
|
///
|
||||||
/// Por defecto, renderiza las regiones básicas de [`DefaultRegions`] en este orden:
|
/// Requiere [`AnyInfo`] por el mismo motivo que [`RegionName`], para que un [`TemplateRef`] pueda
|
||||||
/// [`DefaultRegions::Header`], [`DefaultRegions::Content`] y [`DefaultRegions::Footer`].
|
/// recuperarse mediante [`AnyCast::downcast_ref()`](crate::core::AnyCast::downcast_ref) hacia su
|
||||||
///
|
/// tipo concreto (por ejemplo, para que un tema distinga en
|
||||||
/// Se puede sobrescribir este método para:
|
/// [`Theme::handle_component()`](crate::core::theme::Theme::handle_component) qué variante concreta
|
||||||
///
|
/// está renderizando el componente [`Template`](crate::base::component::layout::Template)).
|
||||||
/// - Cambiar el conjunto de regiones que se renderizan según variantes de la plantilla.
|
pub trait TemplateName: Send + Sync + AnyInfo {
|
||||||
/// - Alterar el orden de dichas regiones.
|
/// Devuelve el nombre de la plantilla.
|
||||||
/// - Envolver las regiones en contenedores adicionales.
|
fn name(&self) -> &'static str;
|
||||||
/// - Implementar distribuciones específicas (por ejemplo, con barras laterales).
|
|
||||||
///
|
/// Devuelve un *texto localizado* como etiqueta descriptiva de la plantilla.
|
||||||
/// Este método se invoca normalmente desde [`Theme::render_page_body()`] para generar el
|
fn label(&self) -> L10n;
|
||||||
/// contenido del `<body>` de una página según la plantilla devuelta por el contexto de la
|
|
||||||
/// propia página ([`Contextual::template()`](crate::core::component::Contextual::template())).
|
|
||||||
async fn render(&self, cx: &mut Context) -> Markup {
|
|
||||||
html! {
|
|
||||||
(DefaultRegions::Header.render(cx).await)
|
|
||||||
(DefaultRegions::Content.render(cx).await)
|
|
||||||
(DefaultRegions::Footer.render(cx).await)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Referencia estática a una plantilla.
|
/// Referencia estática a una plantilla.
|
||||||
pub type TemplateRef = &'static dyn Template;
|
pub type TemplateRef = &'static dyn TemplateName;
|
||||||
|
|
||||||
// **< DefaultTemplates >***************************************************************************
|
// **< CoreTemplate >*******************************************************************************
|
||||||
|
|
||||||
/// Plantillas que PageTop proporciona por defecto.
|
/// Plantillas que PageTop proporciona por defecto.
|
||||||
#[derive(AutoDefault)]
|
#[derive(AutoDefault)]
|
||||||
pub enum DefaultTemplates {
|
pub enum CoreTemplate {
|
||||||
/// Plantilla predeterminada.
|
/// Plantilla predeterminada, de nombre `"standard"`.
|
||||||
///
|
///
|
||||||
/// Utiliza la implementación por defecto de [`Template::render()`] y se emplea cuando no se
|
/// Se emplea cuando no se selecciona ninguna otra plantilla explícitamente.
|
||||||
/// selecciona ninguna otra plantilla explícitamente.
|
|
||||||
#[default]
|
#[default]
|
||||||
Standard,
|
Standard,
|
||||||
|
|
||||||
/// Plantilla para la **interfaz de administración**.
|
/// Plantilla para la **interfaz de administración**, de nombre `"admin"`.
|
||||||
///
|
///
|
||||||
/// Se utiliza para páginas de administración o paneles de control. Por defecto utiliza la misma
|
/// Se utiliza para páginas de administración o paneles de control.
|
||||||
/// implementación de [`Template::render()`] que [`Self::Standard`].
|
|
||||||
Admin,
|
Admin,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
impl TemplateName for CoreTemplate {
|
||||||
impl Template for DefaultTemplates {}
|
#[inline]
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Standard => "standard",
|
||||||
|
Self::Admin => "admin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn label(&self) -> L10n {
|
||||||
|
match self {
|
||||||
|
Self::Standard => L10n::l("template-standard"),
|
||||||
|
Self::Admin => L10n::l("template-admin"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// **< render_component! >**************************************************************************
|
// **< render_component! >**************************************************************************
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
use crate::async_trait;
|
use crate::async_trait;
|
||||||
use crate::base::component::{Html, Intro, IntroOpening};
|
use crate::base::component::{Html, Intro, IntroOpening, layout};
|
||||||
use crate::core::component::{ChildOp, Component, ComponentError, Context, Contextual};
|
use crate::core::component::{ChildOp, Component, ComponentError, ComponentRender};
|
||||||
|
use crate::core::component::{Context, Contextual};
|
||||||
use crate::core::extension::Extension;
|
use crate::core::extension::Extension;
|
||||||
use crate::core::theme::{DefaultRegions, DefaultTemplates, TemplateRef};
|
use crate::core::theme::CoreRegion;
|
||||||
use crate::global;
|
use crate::global;
|
||||||
use crate::html::{Markup, html};
|
use crate::html::{Markup, html};
|
||||||
use crate::locale::L10n;
|
use crate::locale::L10n;
|
||||||
|
|
@ -13,10 +14,10 @@ use crate::web::http::StatusCode;
|
||||||
///
|
///
|
||||||
/// Un tema es una [`Extension`](crate::core::extension::Extension) que define el aspecto general de
|
/// Un tema es una [`Extension`](crate::core::extension::Extension) que define el aspecto general de
|
||||||
/// las páginas: cómo se renderiza el `<head>`, cómo se presenta el `<body>` usando plantillas
|
/// las páginas: cómo se renderiza el `<head>`, cómo se presenta el `<body>` usando plantillas
|
||||||
/// ([`Template`](crate::core::theme::Template)) que maquetan regiones
|
/// ([`TemplateName`](crate::core::theme::TemplateName)) que maquetan regiones
|
||||||
/// ([`Region`](crate::core::theme::Region)) y qué contenido mostrar en las páginas de error. El
|
/// ([`RegionName`](crate::core::theme::RegionName)) y qué contenido mostrar en las páginas de
|
||||||
/// contenido de cada región depende del [`Context`](crate::core::component::Context) y de su nombre
|
/// error. El contenido de cada región depende del [`Context`](crate::core::component::Context) y de
|
||||||
/// lógico.
|
/// su nombre lógico.
|
||||||
///
|
///
|
||||||
/// Todos los métodos de este *trait* tienen una implementación por defecto, por lo que pueden
|
/// Todos los métodos de este *trait* tienen una implementación por defecto, por lo que pueden
|
||||||
/// sobrescribirse selectivamente para crear nuevos temas con comportamientos distintos a los
|
/// sobrescribirse selectivamente para crear nuevos temas con comportamientos distintos a los
|
||||||
|
|
@ -59,34 +60,6 @@ pub trait Theme: Extension + Send + Sync {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Devuelve la plantilla ([`Template`](crate::core::theme::Template)) que el propio tema
|
|
||||||
/// propone como predeterminada.
|
|
||||||
///
|
|
||||||
/// Se utiliza al inicializar un [`Context`](crate::core::component::Context) o una página
|
|
||||||
/// ([`Page`](crate::response::Page)) por si no se elige ninguna otra plantilla con
|
|
||||||
/// [`Contextual::with_template()`](crate::core::component::Contextual::with_template).
|
|
||||||
///
|
|
||||||
/// La implementación por defecto devuelve la plantilla [`DefaultTemplates::Standard`] con una
|
|
||||||
/// estructura básica para la página. Los temas pueden sobrescribir este método para seleccionar
|
|
||||||
/// otra plantilla predeterminada o una plantilla propia.
|
|
||||||
#[inline]
|
|
||||||
fn default_template(&self) -> TemplateRef {
|
|
||||||
self.parent()
|
|
||||||
.map_or(&DefaultTemplates::Standard, |p| p.default_template())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Devuelve la plantilla ([`Template`](crate::core::theme::Template)) que el tema propone para
|
|
||||||
/// la interfaz de administración.
|
|
||||||
///
|
|
||||||
/// La implementación por defecto devuelve la plantilla [`DefaultTemplates::Admin`] con una
|
|
||||||
/// estructura básica para la interfaz de administración. Los temas pueden sobrescribir este
|
|
||||||
/// método para seleccionar otra plantilla predeterminada o una plantilla propia.
|
|
||||||
#[inline]
|
|
||||||
fn admin_template(&self) -> TemplateRef {
|
|
||||||
self.parent()
|
|
||||||
.map_or(&DefaultTemplates::Admin, |p| p.admin_template())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Acciones específicas del tema antes de renderizar el `<body>` de la página.
|
/// Acciones específicas del tema antes de renderizar el `<body>` de la página.
|
||||||
///
|
///
|
||||||
/// Es un buen lugar para inicializar o ajustar recursos en función del contexto de la página,
|
/// Es un buen lugar para inicializar o ajustar recursos en función del contexto de la página,
|
||||||
|
|
@ -107,14 +80,13 @@ pub trait Theme: Extension + Send + Sync {
|
||||||
/// Renderiza el contenido del `<body>` de la página.
|
/// Renderiza el contenido del `<body>` de la página.
|
||||||
///
|
///
|
||||||
/// La implementación predeterminada delega en la plantilla asociada a la página, obtenida desde
|
/// La implementación predeterminada delega en la plantilla asociada a la página, obtenida desde
|
||||||
/// su [`Context`](crate::core::component::Context), y llama a
|
/// su [`Context`](crate::core::component::Context), para componer el `<body>` a partir de las
|
||||||
/// [`Template::render()`](crate::core::theme::Template::render) para componer el `<body>` a
|
/// regiones.
|
||||||
/// partir de las regiones.
|
|
||||||
///
|
///
|
||||||
/// Con la configuración por defecto, la plantilla estándar utiliza las regiones
|
/// Con la configuración por defecto, la plantilla estándar utiliza las regiones
|
||||||
/// [`DefaultRegions::Header`](crate::core::theme::DefaultRegions::Header),
|
/// [`CoreRegion::Header`](crate::core::theme::CoreRegion::Header),
|
||||||
/// [`DefaultRegions::Content`](crate::core::theme::DefaultRegions::Content) y
|
/// [`CoreRegion::Content`](crate::core::theme::CoreRegion::Content) y
|
||||||
/// [`DefaultRegions::Footer`](crate::core::theme::DefaultRegions::Footer) en ese orden.
|
/// [`CoreRegion::Footer`](crate::core::theme::CoreRegion::Footer) en ese orden.
|
||||||
///
|
///
|
||||||
/// Los temas pueden sobrescribir este método para:
|
/// Los temas pueden sobrescribir este método para:
|
||||||
///
|
///
|
||||||
|
|
@ -127,7 +99,8 @@ pub trait Theme: Extension + Send + Sync {
|
||||||
if let Some(parent) = self.parent() {
|
if let Some(parent) = self.parent() {
|
||||||
parent.render_page_body(page).await
|
parent.render_page_body(page).await
|
||||||
} else {
|
} else {
|
||||||
page.template().render(page.context()).await
|
let template = page.template();
|
||||||
|
layout::Template::of(template).render(page.context()).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,16 +220,16 @@ pub trait Theme: Extension + Send + Sync {
|
||||||
|
|
||||||
/// Contenido predefinido para la página de error "*403 - Forbidden*" (acceso denegado).
|
/// Contenido predefinido para la página de error "*403 - Forbidden*" (acceso denegado).
|
||||||
///
|
///
|
||||||
/// Normalmente se renderiza con la plantilla predeterminada del tema (por defecto suele ser
|
/// Normalmente se renderiza con la plantilla ya activa en la página (por ejemplo
|
||||||
/// [`DefaultTemplates::Standard`]), para que el usuario no pierda el contexto de navegación del
|
/// [`CoreTemplate::Standard`](crate::core::theme::CoreTemplate::Standard)), para que el usuario
|
||||||
/// sitio. Los temas pueden sobrescribir este método para personalizar completamente el diseño y
|
/// no pierda el contexto de navegación del sitio. Los temas pueden sobrescribir este método
|
||||||
/// el contenido de la página de error.
|
/// para personalizar completamente el diseño y el contenido de la página de error.
|
||||||
fn error_403(&self, page: &mut Page) {
|
fn error_403(&self, page: &mut Page) {
|
||||||
if let Some(parent) = self.parent() {
|
if let Some(parent) = self.parent() {
|
||||||
return parent.error_403(page);
|
return parent.error_403(page);
|
||||||
}
|
}
|
||||||
page.alter_title(L10n::l("error403_title")).alter_child_in(
|
page.alter_title(L10n::l("error403_title")).alter_child_in(
|
||||||
&DefaultRegions::Content,
|
&CoreRegion::Content,
|
||||||
ChildOp::Prepend(
|
ChildOp::Prepend(
|
||||||
Html::with(move |cx| {
|
Html::with(move |cx| {
|
||||||
html! {
|
html! {
|
||||||
|
|
@ -273,15 +246,16 @@ pub trait Theme: Extension + Send + Sync {
|
||||||
|
|
||||||
/// Contenido predefinido para la página de error "*404 - Not Found*" (recurso no encontrado).
|
/// Contenido predefinido para la página de error "*404 - Not Found*" (recurso no encontrado).
|
||||||
///
|
///
|
||||||
/// Normalmente se renderiza con la plantilla predeterminada del tema (por defecto suele ser
|
/// Normalmente se renderiza con la plantilla ya activa en la página (por ejemplo
|
||||||
/// [`DefaultTemplates::Standard`]). Los temas pueden sobrescribir este método para personalizar
|
/// [`CoreTemplate::Standard`](crate::core::theme::CoreTemplate::Standard)). Los temas pueden
|
||||||
/// completamente el diseño y el contenido de la página de error.
|
/// sobrescribir este método para personalizar completamente el diseño y el contenido de la
|
||||||
|
/// página de error.
|
||||||
fn error_404(&self, page: &mut Page) {
|
fn error_404(&self, page: &mut Page) {
|
||||||
if let Some(parent) = self.parent() {
|
if let Some(parent) = self.parent() {
|
||||||
return parent.error_404(page);
|
return parent.error_404(page);
|
||||||
}
|
}
|
||||||
page.alter_title(L10n::l("error404_title")).alter_child_in(
|
page.alter_title(L10n::l("error404_title")).alter_child_in(
|
||||||
&DefaultRegions::Content,
|
&CoreRegion::Content,
|
||||||
ChildOp::Prepend(
|
ChildOp::Prepend(
|
||||||
Html::with(move |cx| {
|
Html::with(move |cx| {
|
||||||
html! {
|
html! {
|
||||||
|
|
@ -307,9 +281,10 @@ pub trait Theme: Extension + Send + Sync {
|
||||||
/// funcionan con normalidad.
|
/// funcionan con normalidad.
|
||||||
///
|
///
|
||||||
/// Por defecto, asigna el título al documento (`title`), se renderiza con la plantilla ya
|
/// Por defecto, asigna el título al documento (`title`), se renderiza con la plantilla ya
|
||||||
/// activa en la página (normalmente [`DefaultTemplates::Standard`]) y muestra un componente
|
/// activa en la página (normalmente
|
||||||
/// [`Intro`] con el código HTTP del error (`code`) y los mensajes proporcionados (`alert` y
|
/// [`CoreTemplate::Standard`](crate::core::theme::CoreTemplate::Standard)) y muestra un
|
||||||
/// `help`) como descripción del error.
|
/// componente [`Intro`] con el código HTTP del error (`code`) y los mensajes proporcionados
|
||||||
|
/// (`alert` y `help`) como descripción del error.
|
||||||
///
|
///
|
||||||
/// Este método no se utiliza en las implementaciones predefinidas de [`Self::error_403()`] ni
|
/// Este método no se utiliza en las implementaciones predefinidas de [`Self::error_403()`] ni
|
||||||
/// [`Self::error_404()`], que definen su propio contenido específico.
|
/// [`Self::error_404()`], que definen su propio contenido específico.
|
||||||
|
|
@ -326,7 +301,7 @@ pub trait Theme: Extension + Send + Sync {
|
||||||
return parent.error_fatal(page, code, title, alert, help);
|
return parent.error_fatal(page, code, title, alert, help);
|
||||||
}
|
}
|
||||||
page.alter_title(title).alter_child_in(
|
page.alter_title(title).alter_child_in(
|
||||||
&DefaultRegions::Content,
|
&CoreRegion::Content,
|
||||||
ChildOp::Prepend(
|
ChildOp::Prepend(
|
||||||
Intro::new()
|
Intro::new()
|
||||||
.with_title(L10n::l("error_code").with_arg("code", code.to_string()))
|
.with_title(L10n::l("error_code").with_arg("code", code.to_string()))
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use crate::core::component::{Child, ChildOp, Children, Component};
|
use crate::core::component::{Child, ChildOp, Children, Component};
|
||||||
use crate::core::theme::{DefaultRegions, RegionRef, ThemeRef};
|
use crate::core::theme::{CoreRegion, RegionRef, ThemeRef};
|
||||||
use crate::{AutoDefault, UniqueId, builder_fn};
|
use crate::{AutoDefault, UniqueId, builder_fn};
|
||||||
|
|
||||||
use parking_lot::RwLock;
|
use parking_lot::RwLock;
|
||||||
|
|
@ -43,20 +43,19 @@ static COMMON_REGIONS: LazyLock<RwLock<RegionComponents>> =
|
||||||
pub(crate) struct ChildrenInRegions(HashMap<String, Children>);
|
pub(crate) struct ChildrenInRegions(HashMap<String, Children>);
|
||||||
|
|
||||||
impl ChildrenInRegions {
|
impl ChildrenInRegions {
|
||||||
pub fn with(region_ref: RegionRef, child: Child) -> Self {
|
pub fn with(region: RegionRef, child: Child) -> Self {
|
||||||
Self::default().with_child_in(region_ref, child)
|
Self::default().with_child_in(region, child)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[builder_fn]
|
#[builder_fn]
|
||||||
pub fn with_child_in(mut self, region_ref: RegionRef, op: impl Into<ChildOp>) -> Self {
|
pub fn with_child_in(mut self, region: RegionRef, op: impl Into<ChildOp>) -> Self {
|
||||||
let child = op.into();
|
let child = op.into();
|
||||||
if let Some(region) = self.0.get_mut(region_ref.name()) {
|
let region_name = region.name();
|
||||||
|
if let Some(region) = self.0.get_mut(region_name) {
|
||||||
region.alter_child(child);
|
region.alter_child(child);
|
||||||
} else {
|
} else {
|
||||||
self.0.insert(
|
let children = Children::new().with_child(child);
|
||||||
region_ref.name().to_owned(),
|
self.0.insert(region_name.to_owned(), children);
|
||||||
Children::new().with_child(child),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +70,8 @@ impl ChildrenInRegions {
|
||||||
/// lugar de clonarse, ya que son de un único uso.
|
/// lugar de clonarse, ya que son de un único uso.
|
||||||
/// 3. Prototipos del tema activo, exclusivos del tema en curso. También se clonan para asegurar
|
/// 3. Prototipos del tema activo, exclusivos del tema en curso. También se clonan para asegurar
|
||||||
/// que llegan a `setup()` con el mismo estado inicial.
|
/// que llegan a `setup()` con el mismo estado inicial.
|
||||||
pub fn assemble_region(&mut self, theme_ref: ThemeRef, region_name: &str) -> Children {
|
pub fn assemble_region(&mut self, theme: ThemeRef, region: RegionRef) -> Children {
|
||||||
|
let region_name = region.name();
|
||||||
let common = COMMON_REGIONS.read();
|
let common = COMMON_REGIONS.read();
|
||||||
let themed = THEME_REGIONS.read();
|
let themed = THEME_REGIONS.read();
|
||||||
|
|
||||||
|
|
@ -90,7 +90,7 @@ impl ChildrenInRegions {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 3. Prototipos del tema activo.
|
// 3. Prototipos del tema activo.
|
||||||
if let Some(theme_map) = themed.get(&theme_ref.type_id()) {
|
if let Some(theme_map) = themed.get(&theme.type_id()) {
|
||||||
if let Some(protos) = theme_map.get(region_name) {
|
if let Some(protos) = theme_map.get(region_name) {
|
||||||
for proto in protos {
|
for proto in protos {
|
||||||
result.add(proto.as_child());
|
result.add(proto.as_child());
|
||||||
|
|
@ -118,31 +118,27 @@ impl ChildrenInRegions {
|
||||||
/// InRegion::Content.add(Html::with(|_| html! { "🎉 ¡Bienvenido!" }));
|
/// InRegion::Content.add(Html::with(|_| html! { "🎉 ¡Bienvenido!" }));
|
||||||
///
|
///
|
||||||
/// // Texto en la cabecera, visible en todos los temas.
|
/// // Texto en la cabecera, visible en todos los temas.
|
||||||
/// InRegion::Global(&DefaultRegions::Header).add(Html::with(|_| html! { "Publicidad" }));
|
/// InRegion::Global(&CoreRegion::Header).add(Html::with(|_| html! { "Publicidad" }));
|
||||||
/// ```
|
/// ```
|
||||||
pub enum InRegion {
|
pub enum InRegion {
|
||||||
/// Región principal de **contenido** por defecto.
|
/// Región principal de **contenido** por defecto.
|
||||||
///
|
///
|
||||||
/// Añade el componente a la región lógica de contenido principal de la aplicación. Por
|
/// Añade el componente a la región lógica de contenido principal de la aplicación. Internamente
|
||||||
/// convención, esta región corresponde a [`DefaultRegions::Content`], cuyo nombre es
|
/// equivale a `InRegion::Global(&CoreRegion::Content)`.
|
||||||
/// `"content"`. Cualquier tema que renderice esa misma región de contenido, ya sea usando
|
|
||||||
/// directamente [`DefaultRegions::Content`] o cualquier otra implementación de
|
|
||||||
/// [`Region`](crate::core::theme::Region) que devuelva ese mismo nombre, mostrará los
|
|
||||||
/// componentes registrados aquí, aunque lo harán según su propio método de renderizado
|
|
||||||
/// ([`Region::render()`](crate::core::theme::Region::render)).
|
|
||||||
Content,
|
Content,
|
||||||
/// Región global compartida por todos los temas.
|
/// Región global compartida por todos los temas.
|
||||||
///
|
///
|
||||||
/// Los componentes añadidos aquí se asocian al nombre de la región indicado por [`RegionRef`],
|
/// Los componentes añadidos aquí se asocian al nombre de la región indicado por [`RegionRef`],
|
||||||
/// es decir, al valor devuelto por [`Region::name()`](crate::core::theme::Region::name) para
|
/// es decir, al valor devuelto por
|
||||||
/// esa región. Se mostrarán en cualquier tema cuya plantilla renderice una región que devuelva
|
/// [`RegionName::name()`](crate::core::theme::RegionName::name) para esa región. Se mostrarán
|
||||||
/// ese mismo nombre.
|
/// en cualquier tema que renderice la región que devuelva ese nombre.
|
||||||
Global(RegionRef),
|
Global(RegionRef),
|
||||||
/// Región asociada a un tema concreto.
|
/// Región asociada a un tema concreto.
|
||||||
///
|
///
|
||||||
/// Los componentes sólo se renderizarán cuando el documento se procese con el tema indicado y
|
/// Los componentes sólo se renderizarán cuando el documento se procese exactamente con el tema
|
||||||
/// se utilice la región referenciada. Resulta útil para añadir contenido específico en un tema
|
/// indicado (no sirve un tema hijo que lo herede), y se utilice la región referenciada. A
|
||||||
/// sin afectar a otros.
|
/// diferencia del resto de comportamiento de `Theme`, este registro no sigue la cadena
|
||||||
|
/// `parent()`. Resulta útil para añadir contenido específico en un tema sin afectar a otros.
|
||||||
ForTheme(ThemeRef, RegionRef),
|
ForTheme(ThemeRef, RegionRef),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,26 +159,26 @@ impl InRegion {
|
||||||
/// }));
|
/// }));
|
||||||
///
|
///
|
||||||
/// // Texto en la cabecera.
|
/// // Texto en la cabecera.
|
||||||
/// InRegion::Global(&DefaultRegions::Header).add(Html::with(|_| {
|
/// InRegion::Global(&CoreRegion::Header).add(Html::with(|_| {
|
||||||
/// html! { "Publicidad" }
|
/// html! { "Publicidad" }
|
||||||
/// }));
|
/// }));
|
||||||
///
|
///
|
||||||
/// // Contenido sólo para la región del pie de página en un tema concreto.
|
/// // Contenido sólo para la región del pie de página en un tema concreto.
|
||||||
/// InRegion::ForTheme(&theme::Basic, &DefaultRegions::Footer).add(Html::with(|_| {
|
/// InRegion::ForTheme(&theme::Basic, &CoreRegion::Footer).add(Html::with(|_| {
|
||||||
/// html! { "Aviso legal" }
|
/// html! { "Aviso legal" }
|
||||||
/// }));
|
/// }));
|
||||||
/// ```
|
/// ```
|
||||||
pub fn add(&self, component: impl Component + Clone + 'static) -> &Self {
|
pub fn add(&self, component: impl Component + Clone + 'static) -> &Self {
|
||||||
let proto: Arc<dyn ComponentGlobal> = Arc::new(component);
|
let proto: Arc<dyn ComponentGlobal> = Arc::new(component);
|
||||||
match self {
|
match self {
|
||||||
InRegion::Content => Self::add_to_common(&DefaultRegions::Content, proto),
|
InRegion::Content => Self::add_to_common(&CoreRegion::Content, proto),
|
||||||
InRegion::Global(region_ref) => Self::add_to_common(*region_ref, proto),
|
InRegion::Global(region) => Self::add_to_common(*region, proto),
|
||||||
InRegion::ForTheme(theme_ref, region_ref) => {
|
InRegion::ForTheme(theme, region) => {
|
||||||
THEME_REGIONS
|
THEME_REGIONS
|
||||||
.write()
|
.write()
|
||||||
.entry(theme_ref.type_id())
|
.entry(theme.type_id())
|
||||||
.or_default()
|
.or_default()
|
||||||
.entry((*region_ref).name().to_owned())
|
.entry((*region).name().to_owned())
|
||||||
.or_default()
|
.or_default()
|
||||||
.push(proto);
|
.push(proto);
|
||||||
}
|
}
|
||||||
|
|
@ -191,10 +187,10 @@ impl InRegion {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn add_to_common(region_ref: RegionRef, proto: Arc<dyn ComponentGlobal>) {
|
fn add_to_common(region: RegionRef, proto: Arc<dyn ComponentGlobal>) {
|
||||||
COMMON_REGIONS
|
COMMON_REGIONS
|
||||||
.write()
|
.write()
|
||||||
.entry(region_ref.name().to_owned())
|
.entry(region.name().to_owned())
|
||||||
.or_default()
|
.or_default()
|
||||||
.push(proto);
|
.push(proto);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ pub use maud::{DOCTYPE, Escaper, Markup, PreEscaped, Render, display, html, html
|
||||||
mod route_path;
|
mod route_path;
|
||||||
pub use route_path::RoutePath;
|
pub use route_path::RoutePath;
|
||||||
|
|
||||||
|
mod sort_dir;
|
||||||
|
pub use sort_dir::SortDir;
|
||||||
|
|
||||||
// **< HTML DOCUMENT ASSETS >***********************************************************************
|
// **< HTML DOCUMENT ASSETS >***********************************************************************
|
||||||
|
|
||||||
mod assets;
|
mod assets;
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ enum Source {
|
||||||
Inline(CowStr, Box<dyn Fn(&mut Context) -> String + Send + Sync>),
|
Inline(CowStr, Box<dyn Fn(&mut Context) -> String + Send + Sync>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Define el medio objetivo para la hoja de estilos.
|
/// Define el medio objetivo para una hoja de estilos.
|
||||||
///
|
///
|
||||||
/// Permite especificar en qué contexto se aplica el CSS, adaptándose a diferentes dispositivos o
|
/// Permite especificar en qué contexto se aplica el CSS, adaptándose a diferentes dispositivos o
|
||||||
/// situaciones de impresión.
|
/// situaciones de impresión.
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,12 @@ impl RoutePath {
|
||||||
crate::util::url_looks_external(&self.path)
|
crate::util::url_looks_external(&self.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Indica si la ruta no tiene *path* ni parámetros, es decir, si su representación textual
|
||||||
|
/// sería una cadena vacía.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.path.is_empty() && self.query.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
// **< RoutePath HELPERS >**********************************************************************
|
// **< RoutePath HELPERS >**********************************************************************
|
||||||
|
|
||||||
// Codifica un valor para su uso seguro como parte de una *query string* según RFC 3986: los
|
// Codifica un valor para su uso seguro como parte de una *query string* según RFC 3986: los
|
||||||
|
|
|
||||||
116
src/html/sort_dir.rs
Normal file
116
src/html/sort_dir.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
use crate::AutoDefault;
|
||||||
|
|
||||||
|
/// Representa una dirección de ordenación (ascendente o descendente).
|
||||||
|
///
|
||||||
|
/// Es un tipo definido exclusivamente para trabajar con datos ordenables. No depende de ningún
|
||||||
|
/// componente. Sirve tanto para representar el estado de un listado ordenable en la capa de
|
||||||
|
/// servicio (interpretando el valor de la *query string* con [`from_query()`](Self::from_query) y
|
||||||
|
/// serializándolo con [`as_str()`](Self::as_str)), como para calcular la dirección que debe llevar
|
||||||
|
/// un enlace de ordenación de la interfaz si se vuelve a pulsar ([`toggled()`](Self::toggled),
|
||||||
|
/// [`next_for()`](Self::next_for)).
|
||||||
|
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub enum SortDir {
|
||||||
|
/// Orden ascendente. Es la dirección por defecto.
|
||||||
|
#[default]
|
||||||
|
Asc,
|
||||||
|
/// Orden descendente.
|
||||||
|
Desc,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SortDir {
|
||||||
|
/// Interpreta el valor del parámetro de ordenación procedente de una *query string*
|
||||||
|
/// (`"asc"`/`"desc"`).
|
||||||
|
///
|
||||||
|
/// Cualquier valor distinto de `"desc"` (incluido `None`, una cadena vacía o un valor no
|
||||||
|
/// reconocido) se interpreta como [`Asc`](Self::Asc).
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use pagetop::html::SortDir;
|
||||||
|
///
|
||||||
|
/// assert_eq!(SortDir::from_query(Some("desc")), SortDir::Desc);
|
||||||
|
/// assert_eq!(SortDir::from_query(Some("asc")), SortDir::Asc);
|
||||||
|
/// assert_eq!(SortDir::from_query(Some("")), SortDir::Asc);
|
||||||
|
/// assert_eq!(SortDir::from_query(None), SortDir::Asc);
|
||||||
|
/// ```
|
||||||
|
#[inline]
|
||||||
|
pub fn from_query(value: Option<&str>) -> Self {
|
||||||
|
match value {
|
||||||
|
Some("desc") => SortDir::Desc,
|
||||||
|
_ => SortDir::Asc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve el valor de esta dirección tal como se representa en una *query string*
|
||||||
|
/// (`"asc"`/`"desc"`).
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use pagetop::html::SortDir;
|
||||||
|
///
|
||||||
|
/// assert_eq!(SortDir::Asc.as_str(), "asc");
|
||||||
|
/// assert_eq!(SortDir::Desc.as_str(), "desc");
|
||||||
|
/// ```
|
||||||
|
#[inline]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
SortDir::Asc => "asc",
|
||||||
|
SortDir::Desc => "desc",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Devuelve la dirección contraria a esta.
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use pagetop::html::SortDir;
|
||||||
|
///
|
||||||
|
/// assert_eq!(SortDir::Asc.toggled(), SortDir::Desc);
|
||||||
|
/// assert_eq!(SortDir::Desc.toggled(), SortDir::Asc);
|
||||||
|
/// ```
|
||||||
|
#[inline]
|
||||||
|
pub const fn toggled(self) -> Self {
|
||||||
|
match self {
|
||||||
|
SortDir::Asc => SortDir::Desc,
|
||||||
|
SortDir::Desc => SortDir::Asc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcula la dirección que debe llevar un enlace de ordenación si se vuelve a pulsar, dado su
|
||||||
|
/// estado actual (`current`) que puede ser `Some(dir)` si el listado ya está ordenado por este
|
||||||
|
/// enlace, o `None` si el orden vigente lo determina otro campo.
|
||||||
|
///
|
||||||
|
/// Si el enlace ya ordena, [alterna la dirección](Self::toggled); si no, siempre empieza en
|
||||||
|
/// [`Asc`](Self::Asc), sea cual sea la dirección vigente del listado en conjunto.
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use pagetop::html::SortDir;
|
||||||
|
///
|
||||||
|
/// // Otro campo determina el orden vigente: el próximo clic aquí empieza en ascendente.
|
||||||
|
/// assert_eq!(SortDir::next_for(None), SortDir::Asc);
|
||||||
|
///
|
||||||
|
/// // Este mismo campo ya ordena en ascendente: el próximo clic debería invertirlo.
|
||||||
|
/// assert_eq!(SortDir::next_for(Some(SortDir::Asc)), SortDir::Desc);
|
||||||
|
/// assert_eq!(SortDir::next_for(Some(SortDir::Desc)), SortDir::Asc);
|
||||||
|
/// ```
|
||||||
|
#[inline]
|
||||||
|
pub const fn next_for(current: Option<SortDir>) -> SortDir {
|
||||||
|
match current {
|
||||||
|
Some(dir) => dir.toggled(),
|
||||||
|
None => SortDir::Asc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permite pasar un [`SortDir`] allí donde se espere `impl Into<String>`, por ejemplo, en
|
||||||
|
/// [`RoutePath::with_param()`](crate::html::RoutePath::with_param) o su equivalente
|
||||||
|
/// `alter_param()`, sin tener que escribir `as_str().to_owned()` a mano.
|
||||||
|
///
|
||||||
|
/// ```rust
|
||||||
|
/// use pagetop::html::SortDir;
|
||||||
|
///
|
||||||
|
/// assert_eq!(String::from(SortDir::Asc), "asc");
|
||||||
|
/// assert_eq!(String::from(SortDir::Desc), "desc");
|
||||||
|
/// ```
|
||||||
|
impl From<SortDir> for String {
|
||||||
|
fn from(dir: SortDir) -> Self {
|
||||||
|
dir.as_str().to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -170,7 +170,7 @@ impl Locale {
|
||||||
/// Devuelve el identificador de idioma configurado explícitamente, si es válido.
|
/// Devuelve el identificador de idioma configurado explícitamente, si es válido.
|
||||||
///
|
///
|
||||||
/// Si no se ha configurado un idioma por defecto o el valor no es válido, devuelve `None`.
|
/// Si no se ha configurado un idioma por defecto o el valor no es válido, devuelve `None`.
|
||||||
pub fn configured_langid() -> Option<&'static LanguageIdentifier> {
|
pub fn try_langid() -> Option<&'static LanguageIdentifier> {
|
||||||
*CONFIG_LANGID
|
*CONFIG_LANGID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ use crate::{AutoDefault, CowStr, include_locales};
|
||||||
|
|
||||||
use super::{LangId, Locale};
|
use super::{LangId, Locale};
|
||||||
|
|
||||||
|
include_locales!(LOCALES_PAGETOP);
|
||||||
|
|
||||||
use fluent_templates::Loader;
|
use fluent_templates::Loader;
|
||||||
use fluent_templates::StaticLoader as Locales;
|
use fluent_templates::StaticLoader as Locales;
|
||||||
|
|
||||||
|
|
@ -10,8 +12,6 @@ use std::collections::HashMap;
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
include_locales!(LOCALES_PAGETOP);
|
|
||||||
|
|
||||||
/// Operación de localización a realizar.
|
/// Operación de localización a realizar.
|
||||||
///
|
///
|
||||||
/// * `None` - No se aplica ninguna localización.
|
/// * `None` - No se aplica ninguna localización.
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,13 @@ impl RequestLocale {
|
||||||
/// - [`LangNegotiation::Full`](crate::global::LangNegotiation::Full) determina el idioma en
|
/// - [`LangNegotiation::Full`](crate::global::LangNegotiation::Full) determina el idioma en
|
||||||
/// este orden:
|
/// este orden:
|
||||||
/// 1. Parámetro de *query* `?lang=...`, si existe y corresponde a un idioma soportado.
|
/// 1. Parámetro de *query* `?lang=...`, si existe y corresponde a un idioma soportado.
|
||||||
/// 2. [`Locale::configured_langid()`], si la aplicación tiene un idioma por defecto válido.
|
/// 2. [`Locale::try_langid()`], si la aplicación tiene un idioma por defecto válido.
|
||||||
/// 3. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`].
|
/// 3. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`].
|
||||||
/// 4. Idioma de respaldo.
|
/// 4. Idioma de respaldo.
|
||||||
///
|
///
|
||||||
/// - [`LangNegotiation::NoQuery`](crate::global::LangNegotiation::NoQuery) descarta el uso del
|
/// - [`LangNegotiation::NoQuery`](crate::global::LangNegotiation::NoQuery) descarta el uso del
|
||||||
/// parámetro `?lang=...` y determina el idioma en este orden:
|
/// parámetro `?lang=...` y determina el idioma en este orden:
|
||||||
/// 1. [`Locale::configured_langid()`], si la aplicación tiene un idioma por defecto válido.
|
/// 1. [`Locale::try_langid()`], si la aplicación tiene un idioma por defecto válido.
|
||||||
/// 2. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`].
|
/// 2. Cabecera `Accept-Language`, si puede resolverse con [`Locale::resolve()`].
|
||||||
/// 3. Idioma de respaldo.
|
/// 3. Idioma de respaldo.
|
||||||
///
|
///
|
||||||
|
|
@ -56,7 +56,7 @@ impl RequestLocale {
|
||||||
Locale::default_langid()
|
Locale::default_langid()
|
||||||
}
|
}
|
||||||
global::LangNegotiation::Full | global::LangNegotiation::NoQuery => {
|
global::LangNegotiation::Full | global::LangNegotiation::NoQuery => {
|
||||||
if let Some(default) = Locale::configured_langid() {
|
if let Some(default) = Locale::try_langid() {
|
||||||
default
|
default
|
||||||
} else {
|
} else {
|
||||||
// Sin idioma por defecto, se evalúa la cabecera `Accept-Language`.
|
// Sin idioma por defecto, se evalúa la cabecera `Accept-Language`.
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,17 @@
|
||||||
//!
|
//!
|
||||||
//! Este módulo define [`Page`], que representa una página HTML lista para renderizar. Cada página
|
//! Este módulo define [`Page`], que representa una página HTML lista para renderizar. Cada página
|
||||||
//! se construye a partir de un [`Context`] propio, donde se registran el tema activo, la plantilla
|
//! se construye a partir de un [`Context`] propio, donde se registran el tema activo, la plantilla
|
||||||
//! ([`Template`](crate::core::theme::Template)) que define la disposición de las regiones
|
//! ([`TemplateName`](crate::core::theme::TemplateName)) que define la disposición de las regiones
|
||||||
//! ([`Region`]), los componentes asociados y los recursos adicionales (hojas de estilo, scripts,
|
//! ([`RegionName`]), los componentes asociados y los recursos adicionales (hojas de estilo,
|
||||||
//! *favicon*, etc.).
|
//! scripts, *favicon*, etc.).
|
||||||
//!
|
//!
|
||||||
//! El renderizado ([`Page::render()`]) delega en el tema ([`Theme`](crate::core::theme::Theme)) la
|
//! El renderizado ([`Page::render()`]) delega en el tema ([`Theme`](crate::core::theme::Theme)) la
|
||||||
//! composición del `<head>` y del `<body>`, y se ejecutan las acciones registradas por las
|
//! composición del `<head>` y del `<body>`, y se ejecutan las acciones registradas por las
|
||||||
//! extensiones antes y después de generar los contenidos.
|
//! extensiones antes y después de generar los contenidos.
|
||||||
//!
|
//!
|
||||||
//! También introduce regiones internas reservadas ([`ReservedRegion`]) que actúan como puntos de
|
//! También define las regiones internas reservadas ([`ReservedRegion`]) que actúan como puntos de
|
||||||
//! anclaje globales al inicio y al final del documento.
|
//! anclaje globales al inicio y al final del `<body>`, fuera de las regiones que maqueta la
|
||||||
|
//! plantilla activa.
|
||||||
|
|
||||||
mod error;
|
mod error;
|
||||||
pub use error::ErrorPage;
|
pub use error::ErrorPage;
|
||||||
|
|
@ -19,8 +20,10 @@ pub(crate) use error::{render_error_pages, response_for_panic, route_not_found};
|
||||||
|
|
||||||
use crate::auth::CurrentUser;
|
use crate::auth::CurrentUser;
|
||||||
use crate::base::action;
|
use crate::base::action;
|
||||||
use crate::core::component::{AssetsOp, ChildOp, Context, ContextError, Contextual};
|
use crate::base::component::layout;
|
||||||
use crate::core::theme::{DefaultRegions, Region, RegionRef, TemplateRef, ThemeRef};
|
use crate::core::component::{AssetsOp, ChildOp, ComponentRender};
|
||||||
|
use crate::core::component::{Context, ContextError, Contextual};
|
||||||
|
use crate::core::theme::{CoreRegion, CoreTemplate, RegionName, RegionRef, TemplateRef, ThemeRef};
|
||||||
use crate::html::{Assets, Favicon, JavaScript, StyleSheet};
|
use crate::html::{Assets, Favicon, JavaScript, StyleSheet};
|
||||||
use crate::html::{Attr, Props, PropsOp};
|
use crate::html::{Attr, Props, PropsOp};
|
||||||
use crate::html::{DOCTYPE, Markup, html};
|
use crate::html::{DOCTYPE, Markup, html};
|
||||||
|
|
@ -32,37 +35,35 @@ use crate::{AutoDefault, builder_fn};
|
||||||
|
|
||||||
/// Regiones internas reservadas como puntos de anclaje globales.
|
/// Regiones internas reservadas como puntos de anclaje globales.
|
||||||
///
|
///
|
||||||
/// Representan contenedores especiales situados al inicio y al final de un documento. Están
|
/// Representan contenedores especiales situados al inicio y al final del `<body>`, fuera de las
|
||||||
/// pensadas para proporcionar regiones donde inyectar contenido global o técnico. No suelen usarse
|
/// regiones que maqueta la plantilla activa. Las renderiza directamente [`Page::render()`],
|
||||||
/// como regiones visibles en los temas.
|
/// envolviendo el resultado de
|
||||||
|
/// [`Theme::render_page_body()`](crate::core::theme::Theme::render_page_body). **No suelen usarse
|
||||||
|
/// como regiones "visibles" en los temas**, sino para inyectar contenido global o técnico.
|
||||||
|
#[derive(AutoDefault)]
|
||||||
pub enum ReservedRegion {
|
pub enum ReservedRegion {
|
||||||
/// Región interna situada al **inicio del documento**.
|
/// Región interna situada al **inicio del `<body>`**, de nombre `"page-top"`.
|
||||||
///
|
///
|
||||||
/// Su función es proporcionar un contenedor donde las extensiones puedan inyectar contenido
|
/// Proporciona un contenedor donde las extensiones puedan inyectar elementos auxiliares antes
|
||||||
/// global antes del resto de regiones principales (cabecera, contenido, etc.).
|
/// del resto de regiones (cabecera, contenido, etc.), como marcadores técnicos, inicializadores
|
||||||
///
|
/// o contenido de depuración.
|
||||||
/// No suele utilizarse en los temas como una región “visible” dentro del maquetado habitual,
|
|
||||||
/// sino como punto de anclaje para elementos auxiliares, marcadores técnicos, inicializadores o
|
|
||||||
/// contenido de depuración que deban situarse en la parte superior del documento.
|
|
||||||
///
|
///
|
||||||
/// Se considera una región **reservada** para este tipo de usos globales.
|
/// Se considera una región **reservada** para este tipo de usos globales.
|
||||||
|
#[default]
|
||||||
PageTop,
|
PageTop,
|
||||||
|
|
||||||
/// Región interna situada al **final del documento**.
|
/// Región interna situada al **final del `<body>`**, de nombre `"page-bottom"`.
|
||||||
///
|
///
|
||||||
/// Pensada para proporcionar un contenedor donde las extensiones puedan inyectar contenido
|
/// Proporciona un contenedor donde las extensiones puedan inyectar contenido global después del
|
||||||
/// global después del resto de regiones principales (cabecera, contenido, etc.).
|
/// resto de regiones (cabecera, contenido, etc.), como elementos auxiliares asociados a
|
||||||
///
|
/// comportamientos dinámicos.
|
||||||
/// No suele utilizarse en los temas como una región “visible” dentro del maquetado habitual,
|
|
||||||
/// sino como punto de anclaje para elementos auxiliares asociados a comportamientos dinámicos
|
|
||||||
/// que deban situarse en la parte inferior del documento.
|
|
||||||
///
|
///
|
||||||
/// Igual que [`Self::PageTop`], se considera una región **reservada** para este tipo de usos
|
/// Igual que [`Self::PageTop`], se considera una región **reservada** para este tipo de usos
|
||||||
/// globales.
|
/// globales.
|
||||||
PageBottom,
|
PageBottom,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Region for ReservedRegion {
|
impl RegionName for ReservedRegion {
|
||||||
#[inline]
|
#[inline]
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
|
@ -101,22 +102,23 @@ impl Page {
|
||||||
/// [`CurrentUser`] inyectado por middleware en sus extensiones (ver
|
/// [`CurrentUser`] inyectado por middleware en sus extensiones (ver
|
||||||
/// [`Context::new`](crate::core::component::Context::new)). Cualquier handler tiene acceso al
|
/// [`Context::new`](crate::core::component::Context::new)). Cualquier handler tiene acceso al
|
||||||
/// usuario actual desde el momento en que se crea la página, sin llamadas adicionales.
|
/// usuario actual desde el momento en que se crea la página, sin llamadas adicionales.
|
||||||
#[rustfmt::skip]
|
|
||||||
pub fn new(request: HttpRequest) -> Self {
|
pub fn new(request: HttpRequest) -> Self {
|
||||||
Page {
|
Page {
|
||||||
title : Attr::<L10n>::default(),
|
|
||||||
description : Attr::<L10n>::default(),
|
|
||||||
metadata : Vec::default(),
|
|
||||||
properties : Vec::default(),
|
|
||||||
context: Context::new(Some(request)),
|
context: Context::new(Some(request)),
|
||||||
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Crea una nueva instancia de página con la plantilla de administración del tema activo.
|
/// Crea una nueva instancia de página con la plantilla [`CoreTemplate::Admin`].
|
||||||
|
///
|
||||||
|
/// Cada tema puede maquetarla de forma distinta capturando
|
||||||
|
/// [`Template`](crate::base::component::layout::Template) en `handle_component()`, pero la
|
||||||
|
/// plantilla en sí es la misma constante para cualquier tema.
|
||||||
pub fn admin(request: HttpRequest) -> Self {
|
pub fn admin(request: HttpRequest) -> Self {
|
||||||
let mut page = Page::new(request);
|
Page {
|
||||||
page.context().use_admin_template();
|
context: Context::new(Some(request)).with_template(&CoreTemplate::Admin),
|
||||||
page
|
..Default::default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **< Page BUILDER >***************************************************************************
|
// **< Page BUILDER >***************************************************************************
|
||||||
|
|
@ -217,9 +219,9 @@ impl Page {
|
||||||
|
|
||||||
// Renderiza el <body>.
|
// Renderiza el <body>.
|
||||||
let body = html! {
|
let body = html! {
|
||||||
(ReservedRegion::PageTop.render(&mut self.context).await)
|
(layout::Region::of(&ReservedRegion::PageTop).render(&mut self.context).await)
|
||||||
(self.context.theme().render_page_body(self).await)
|
(self.context.theme().render_page_body(self).await)
|
||||||
(ReservedRegion::PageBottom.render(&mut self.context).await)
|
(layout::Region::of(&ReservedRegion::PageBottom).render(&mut self.context).await)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Acciones específicas del tema después de renderizar el <body>.
|
// Acciones específicas del tema después de renderizar el <body>.
|
||||||
|
|
@ -310,14 +312,13 @@ impl Contextual for Page {
|
||||||
|
|
||||||
#[builder_fn]
|
#[builder_fn]
|
||||||
fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
|
fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
|
||||||
self.context
|
self.context.alter_child_in(&CoreRegion::Content, op.into());
|
||||||
.alter_child_in(&DefaultRegions::Content, op.into());
|
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
#[builder_fn]
|
#[builder_fn]
|
||||||
fn with_child_in(mut self, region_ref: RegionRef, op: impl Into<ChildOp>) -> Self {
|
fn with_child_in(mut self, region: RegionRef, op: impl Into<ChildOp>) -> Self {
|
||||||
self.context.alter_child_in(region_ref, op.into());
|
self.context.alter_child_in(region, op.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
118
tests/component_template.rs
Normal file
118
tests/component_template.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
|
/// Initializes PageTop (locale, extensions...) once for the whole suite.
|
||||||
|
///
|
||||||
|
/// Rendering a `Region`/`Template` looks up localized labels (`aria-label`, etc.), so tests that
|
||||||
|
/// render them need the localization subsystem loaded.
|
||||||
|
async fn setup() {
|
||||||
|
Application::new().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< A theme that intercepts the `Template` component >*******************************************
|
||||||
|
|
||||||
|
/// Replaces the default `Template` composition (`Header` + `Content` + `Footer`) with a fixed
|
||||||
|
/// marker string, for both `CoreTemplate::Standard` and `CoreTemplate::Admin`. Mirrors how
|
||||||
|
/// a real theme (e.g. `pagetop-bootsier`) tells its own layout apart from PageTop's default: by
|
||||||
|
/// intercepting the `Template` component in `handle_component()`, not by swapping which
|
||||||
|
/// `TemplateRef` gets resolved.
|
||||||
|
struct MarkerTheme;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Extension for MarkerTheme {
|
||||||
|
fn theme(&self) -> Option<ThemeRef> {
|
||||||
|
Some(&Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Theme for MarkerTheme {
|
||||||
|
async fn handle_component(
|
||||||
|
&self,
|
||||||
|
component: &mut dyn Component,
|
||||||
|
_cx: &mut Context,
|
||||||
|
) -> Option<Result<Markup, ComponentError>> {
|
||||||
|
let template = (&*component).downcast_ref::<layout::Template>()?;
|
||||||
|
template.template().downcast_ref::<CoreTemplate>()?;
|
||||||
|
Some(Ok(html! { "marker-template-output" }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Default/Admin template identity is independent of the active theme >*************************
|
||||||
|
//
|
||||||
|
// `Theme::default_template()`/`admin_template()` were removed: `Context::template()` always
|
||||||
|
// resolves `Default`/`Admin` to the core `CoreTemplate::Standard`/`Admin` identity, regardless
|
||||||
|
// of which theme is active. Themes customize the actual rendering by intercepting the `Template`
|
||||||
|
// component in `handle_component()` instead (see the tests further below).
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn default_template_identity_is_independent_of_theme() {
|
||||||
|
let cx = Context::new(None);
|
||||||
|
assert_eq!(cx.template().name(), "standard");
|
||||||
|
|
||||||
|
let cx = Context::new(None).with_theme(&MarkerTheme);
|
||||||
|
assert_eq!(cx.template().name(), "standard");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn admin_template_identity_is_independent_of_theme() {
|
||||||
|
let mut page = Page::admin(web::test::TestRequest::get().to_http_request());
|
||||||
|
assert_eq!(page.context().template().name(), "admin");
|
||||||
|
|
||||||
|
let mut page =
|
||||||
|
Page::admin(web::test::TestRequest::get().to_http_request()).with_theme(&MarkerTheme);
|
||||||
|
assert_eq!(page.context().template().name(), "admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn explicit_template_is_not_overridden_by_a_later_with_theme() {
|
||||||
|
// A template explicitly set with `with_template()` prevails even if `with_theme()` is called
|
||||||
|
// afterwards.
|
||||||
|
let cx = Context::new(None)
|
||||||
|
.with_template(&CoreTemplate::Admin)
|
||||||
|
.with_theme(&pagetop::base::theme::Basic);
|
||||||
|
|
||||||
|
assert_eq!(cx.template().name(), "admin");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< A theme customizes rendering via `handle_component()` >**************************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn without_a_matching_theme_the_default_composition_is_used() {
|
||||||
|
setup().await;
|
||||||
|
|
||||||
|
// With no theme intercepting it, and no content registered in any region, the default
|
||||||
|
// composition (Header + Content + Footer) renders empty.
|
||||||
|
let mut template = layout::Template::default();
|
||||||
|
let html = template.render(&mut Context::default()).await.into_string();
|
||||||
|
|
||||||
|
assert!(html.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn theme_replaces_template_rendering_via_handle_component() {
|
||||||
|
setup().await;
|
||||||
|
|
||||||
|
let mut template = layout::Template::default();
|
||||||
|
let mut cx = Context::default().with_theme(&MarkerTheme);
|
||||||
|
let html = template.render(&mut cx).await.into_string();
|
||||||
|
|
||||||
|
assert_eq!(html, "marker-template-output");
|
||||||
|
}
|
||||||
|
|
||||||
|
// **< Page::render() reaches the active theme's `handle_component()` >*****************************
|
||||||
|
|
||||||
|
#[pagetop::test]
|
||||||
|
async fn page_admin_render_reflects_the_active_theme_template() {
|
||||||
|
setup().await;
|
||||||
|
|
||||||
|
let request = web::test::TestRequest::get().to_http_request();
|
||||||
|
let mut page = Page::admin(request).with_theme(&MarkerTheme);
|
||||||
|
|
||||||
|
let html = page
|
||||||
|
.render()
|
||||||
|
.await
|
||||||
|
.expect("page should render")
|
||||||
|
.into_string();
|
||||||
|
|
||||||
|
assert!(html.contains("marker-template-output"));
|
||||||
|
}
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
use pagetop::prelude::*;
|
|
||||||
|
|
||||||
// **< Theme with its own template >****************************************************************
|
|
||||||
|
|
||||||
struct MarkerTemplate;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Template for MarkerTemplate {
|
|
||||||
async fn render(&self, _cx: &mut Context) -> Markup {
|
|
||||||
html! { "marker-template-output" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MarkerTheme;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Extension for MarkerTheme {
|
|
||||||
fn theme(&self) -> Option<ThemeRef> {
|
|
||||||
Some(&Self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Theme for MarkerTheme {
|
|
||||||
fn default_template(&self) -> TemplateRef {
|
|
||||||
&MarkerTemplate
|
|
||||||
}
|
|
||||||
|
|
||||||
fn admin_template(&self) -> TemplateRef {
|
|
||||||
&MarkerTemplate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn render_active_template(cx: &mut Context) -> String {
|
|
||||||
let template = cx.template();
|
|
||||||
template.render(cx).await.into_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< Context::template() follows the active theme >***********************************************
|
|
||||||
|
|
||||||
#[pagetop::test]
|
|
||||||
async fn with_theme_updates_the_effective_template() {
|
|
||||||
// Without changing theme, the active template is not `MarkerTheme`'s.
|
|
||||||
let mut cx = Context::new(None);
|
|
||||||
assert_ne!(
|
|
||||||
render_active_template(&mut cx).await,
|
|
||||||
"marker-template-output"
|
|
||||||
);
|
|
||||||
|
|
||||||
// After changing theme with `with_theme()`, the active template becomes that theme's, with no
|
|
||||||
// need to call `with_template()` explicitly.
|
|
||||||
let mut cx = Context::new(None).with_theme(&MarkerTheme);
|
|
||||||
assert_eq!(
|
|
||||||
render_active_template(&mut cx).await,
|
|
||||||
"marker-template-output"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[pagetop::test]
|
|
||||||
async fn explicit_template_is_not_overridden_by_a_later_with_theme() {
|
|
||||||
// A template explicitly set with `with_template()` prevails even if `with_theme()` is called
|
|
||||||
// afterwards, regardless of order.
|
|
||||||
let mut cx = Context::new(None)
|
|
||||||
.with_template(&MarkerTemplate)
|
|
||||||
.with_theme(&pagetop::base::theme::Basic);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
render_active_template(&mut cx).await,
|
|
||||||
"marker-template-output"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// **< Page::admin() follows the active theme >*****************************************************
|
|
||||||
|
|
||||||
#[pagetop::test]
|
|
||||||
async fn page_admin_template_follows_a_later_with_theme() {
|
|
||||||
let request = web::test::TestRequest::get().to_http_request();
|
|
||||||
|
|
||||||
let mut page = Page::admin(request).with_theme(&MarkerTheme);
|
|
||||||
let markup = page.context().template().render(page.context()).await;
|
|
||||||
|
|
||||||
assert_eq!(markup.into_string(), "marker-template-output");
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue