✨ (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
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