(htmx): Añade soporte HTMX a tablas ordenables

- Nuevo `SortDir` en `pagetop::html` para representar direcciones de
  orden (asc/desc) y calcular la siguiente al pulsar una cabecera.
- `hx_table::sort_link()` construye el enlace de ordenación con los
  cuatro atributos `hx-*` fijos, reutilizable en cualquier tabla.
- `HtmxResponse` usa `RoutePath` en `location`/`push_url`/`replace_url`/
  `redirect` para preservar "lang"; `location_json()` se separa de
  `location()` para el caso de configuración JSON personalizada.
- Añade el módulo `prelude` y una batería de tests para hx, hx_table,
  request, response y extension.
This commit is contained in:
Manuel Cillero 2026-07-23 07:21:27 +02:00
parent 55159f6d8f
commit e7f2563967
14 changed files with 1176 additions and 46 deletions

View file

@ -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

View file

@ -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

View file

@ -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");
/// ///

View 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"))
}

View file

@ -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.
/// ///

View file

@ -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() {

View file

@ -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)
} }

View 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>"));
}

View 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");
}

View 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 `&amp;` because this ends up inside an HTML attribute value.
assert!(html.contains(r#"href="/admin/users?lang=es-ES&amp;sort=username&amp;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="));
}

View 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());
}

View 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);
}

View file

@ -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;

116
src/html/sort_dir.rs Normal file
View 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()
}
}