(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

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