✨ (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:
parent
55159f6d8f
commit
e7f2563967
14 changed files with 1176 additions and 46 deletions
|
|
@ -16,6 +16,7 @@ authors.workspace = true
|
|||
|
||||
[dependencies]
|
||||
pagetop.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
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
|
||||
|
||||
Este *crate* integra la biblioteca [HTMX 2.0.10](https://htmx.org), distribuida bajo licencia
|
||||
|
|
|
|||
|
|
@ -24,11 +24,9 @@
|
|||
//!
|
||||
//! ```rust,no_run
|
||||
//! 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, endpoint)
|
||||
//! let props = Props::new(hx::GET, "/api/items")
|
||||
//! .with_prop(PropsOp::set(hx::TARGET, "#list"))
|
||||
//! .with_prop(PropsOp::set(hx::SWAP, hx::swap::OUTER_HTML));
|
||||
//!
|
||||
|
|
@ -45,7 +43,7 @@
|
|||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pagetop::prelude::*;
|
||||
//! use pagetop_htmx::hx;
|
||||
//! use pagetop_htmx::prelude::*;
|
||||
//!
|
||||
//! #[derive(AutoDefault, Getters)]
|
||||
//! pub struct MyButton {
|
||||
|
|
@ -75,7 +73,7 @@
|
|||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pagetop::prelude::*;
|
||||
//! use pagetop_htmx::hx;
|
||||
//! use pagetop_htmx::prelude::*;
|
||||
//!
|
||||
//! // Evento nativo del DOM: hx-on:click="..."
|
||||
//! // Evento propio de HTMX: hx-on::after-swap="..."
|
||||
|
|
@ -91,7 +89,7 @@
|
|||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # use pagetop_htmx::hx;
|
||||
/// # use pagetop_htmx::prelude::*;
|
||||
/// let props = Props::new(hx::GET, "/api/search")
|
||||
/// .with_prop(PropsOp::set(hx::TARGET, "#results"));
|
||||
/// ```
|
||||
|
|
@ -113,7 +111,7 @@ pub const PATCH: &str = "hx-patch";
|
|||
///
|
||||
/// ```rust,no_run
|
||||
/// # 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.
|
||||
/// let props = Props::new(hx::DELETE, "/api/item/42")
|
||||
/// .with_prop(PropsOp::set(hx::TARGET, "closest li"))
|
||||
|
|
@ -130,7 +128,7 @@ pub const DELETE: &str = "hx-delete";
|
|||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # use pagetop_htmx::hx;
|
||||
/// # use pagetop_htmx::prelude::*;
|
||||
/// let props = Props::new(hx::GET, "/api/detalles")
|
||||
/// .with_prop(PropsOp::set(hx::TARGET, "closest article"));
|
||||
/// ```
|
||||
|
|
@ -144,7 +142,7 @@ pub const TARGET: &str = "hx-target";
|
|||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # use pagetop_htmx::hx;
|
||||
/// # use pagetop_htmx::prelude::*;
|
||||
/// // Reemplaza el elemento completo con una transición de 300 ms.
|
||||
/// let props = Props::new(hx::SWAP, "outerHTML swap:300ms");
|
||||
/// // O usando la constante tipada más los modificadores:
|
||||
|
|
@ -176,7 +174,7 @@ pub const SELECT_OOB: &str = "hx-select-oob";
|
|||
///
|
||||
/// ```rust,no_run
|
||||
/// # 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:
|
||||
/// let props = Props::new(hx::GET, "/api/search")
|
||||
/// .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.
|
||||
/// - `"json-enc"` - codifica la petición como JSON en lugar de form-urlencoded.
|
||||
/// - `"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";
|
||||
|
||||
/// 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
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # use pagetop_htmx::hx;
|
||||
/// # use pagetop_htmx::prelude::*;
|
||||
/// let props = Props::new(hx::on("click"), "this.classList.toggle('active')")
|
||||
/// .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
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # use pagetop_htmx::hx;
|
||||
/// # use pagetop_htmx::prelude::*;
|
||||
/// let props = Props::new(hx::on_htmx("before-request"), "console.log('enviando...')")
|
||||
/// .with_prop(PropsOp::set(hx::on_htmx("after-swap"), "initTooltips()"));
|
||||
/// ```
|
||||
|
|
@ -382,7 +385,7 @@ pub fn on_htmx(event: &str) -> String {
|
|||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::prelude::*;
|
||||
/// use pagetop_htmx::hx;
|
||||
/// use pagetop_htmx::prelude::*;
|
||||
///
|
||||
/// async fn handler(request: HttpRequest) {
|
||||
/// 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).
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop_htmx::hx;
|
||||
/// use pagetop::web::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
/// use pagetop::prelude::*;
|
||||
/// use pagetop_htmx::prelude::*;
|
||||
///
|
||||
/// let mut headers = HeaderMap::new();
|
||||
/// let mut headers = web::http::HeaderMap::new();
|
||||
/// headers.insert(
|
||||
/// hx::response::TRIGGER.parse::<HeaderName>().unwrap(),
|
||||
/// HeaderValue::from_static("itemAdded"),
|
||||
/// hx::response::TRIGGER.parse::<web::http::HeaderName>().unwrap(),
|
||||
/// web::http::HeaderValue::from_static("itemAdded"),
|
||||
/// );
|
||||
/// ```
|
||||
pub mod response {
|
||||
/// 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";
|
||||
/// Empuja la URL indicada al historial del navegador. Ver
|
||||
/// [`HtmxResponse::push_url()`](crate::HtmxResponse::push_url).
|
||||
|
|
@ -475,7 +479,7 @@ pub mod response {
|
|||
///
|
||||
/// ```rust,no_run
|
||||
/// # 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:
|
||||
/// 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
|
||||
/// # 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.
|
||||
/// 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
|
||||
}
|
||||
```
|
||||
|
||||
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::*;
|
||||
|
||||
include_locales!(LOCALES_HTMX);
|
||||
|
||||
pub mod hx;
|
||||
pub mod hx_table;
|
||||
|
||||
mod request;
|
||||
pub use request::HtmxRequestExt;
|
||||
|
|
@ -76,7 +100,14 @@ pub use request::HtmxRequestExt;
|
|||
mod response;
|
||||
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.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use pagetop::prelude::*;
|
|||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::prelude::*;
|
||||
/// use pagetop_htmx::{HtmxRequestExt, HtmxResponse};
|
||||
/// use pagetop_htmx::prelude::*;
|
||||
///
|
||||
/// async fn list_items(request: HttpRequest) -> Response {
|
||||
/// if request.is_htmx() {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use pagetop::prelude::*;
|
|||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::prelude::*;
|
||||
/// use pagetop_htmx::{HtmxResponse, hx};
|
||||
/// use pagetop_htmx::prelude::*;
|
||||
///
|
||||
/// async fn add_item(request: HttpRequest) -> impl IntoResponse {
|
||||
/// let new_item = html! { li #item-42 { "New item" } };
|
||||
|
|
@ -37,7 +37,7 @@ use pagetop::prelude::*;
|
|||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::prelude::*;
|
||||
/// use pagetop_htmx::HtmxResponse;
|
||||
/// use pagetop_htmx::prelude::*;
|
||||
///
|
||||
/// async fn delete_item() -> impl IntoResponse {
|
||||
/// HtmxResponse::empty().redirect("/items")
|
||||
|
|
@ -59,7 +59,7 @@ use pagetop::prelude::*;
|
|||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::prelude::*;
|
||||
/// use pagetop_htmx::HtmxResponse;
|
||||
/// use pagetop_htmx::prelude::*;
|
||||
///
|
||||
/// // Dos eventos sin datos:
|
||||
/// HtmxResponse::empty().trigger("itemAdded, listUpdated");
|
||||
|
|
@ -67,6 +67,7 @@ use pagetop::prelude::*;
|
|||
/// // Evento con datos en JSON:
|
||||
/// HtmxResponse::empty().trigger(r#"{"itemAdded": {"id": 42}}"#);
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub struct HtmxResponse {
|
||||
markup: Markup,
|
||||
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.
|
||||
///
|
||||
/// 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`,
|
||||
/// `target`, `swap`, `select` y `values` para personalizar la navegación:
|
||||
/// objetivo definido por el destino. Para personalizar `target`, `swap`, `select` o `values`,
|
||||
/// 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
|
||||
/// use pagetop::prelude::*;
|
||||
/// use pagetop_htmx::HtmxResponse;
|
||||
/// use pagetop_htmx::prelude::*;
|
||||
///
|
||||
/// // Navegación simple:
|
||||
/// HtmxResponse::empty().location("/items");
|
||||
///
|
||||
/// // Navegación con destino personalizado:
|
||||
/// HtmxResponse::empty()
|
||||
/// .location(r##"{"path": "/items", "target": "#content"}"##);
|
||||
/// # fn build_response(cx: &Context) -> HtmxResponse {
|
||||
/// HtmxResponse::empty().location(cx.route("/items"))
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn location(self, url: impl Into<String>) -> Self {
|
||||
self.set_header(b"hx-location", url)
|
||||
pub fn location(self, url: impl Into<RoutePath>) -> Self {
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
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.
|
||||
///
|
||||
/// Usar `"false"` para desactivar el reemplazo.
|
||||
pub fn replace_url(self, url: impl Into<String>) -> Self {
|
||||
self.set_header(b"hx-replace-url", url)
|
||||
/// Usar `"false"` para desactivar el reemplazo. Usa
|
||||
/// [`Context::route()`](pagetop::core::component::Context::route) en lugar de un literal para
|
||||
/// 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.
|
||||
///
|
||||
/// A diferencia de [`location()`](Self::location), esta redirección recarga la página por
|
||||
/// 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.
|
||||
|
|
@ -170,7 +249,7 @@ impl HtmxResponse {
|
|||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::prelude::*;
|
||||
/// use pagetop_htmx::HtmxResponse;
|
||||
/// use pagetop_htmx::prelude::*;
|
||||
///
|
||||
/// // Evento simple:
|
||||
/// HtmxResponse::empty().trigger("itemAdded");
|
||||
|
|
@ -181,6 +260,21 @@ impl HtmxResponse {
|
|||
/// // Evento con datos en JSON:
|
||||
/// 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 {
|
||||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue