Use HX-Target, not HX-Boost for Layout render

HX-Boost is not always sent in all AJAX requests that htmx sends, only
those initiated by an element with hx-boost enabled. It was not showing
up on requests following an HX-Redirect response.

After reading the docs more, I realized HX-Target was what I wanted. If
I can see that the request is targeting `#main-content` then I know to
only return HTML inside that element. Simple.
This commit is contained in:
Tyler Hallada 2023-09-27 01:28:53 -04:00
parent bea3529e22
commit a72bfa15bd
10 changed files with 110 additions and 60 deletions

View File

@ -8,7 +8,7 @@ use sqlx::PgPool;
use crate::config::Config; use crate::config::Config;
use crate::error::Result; use crate::error::Result;
use crate::htmx::HXBoosted; use crate::htmx::HXTarget;
use crate::models::entry::Entry; use crate::models::entry::Entry;
use crate::partials::layout::Layout; use crate::partials::layout::Layout;
use crate::uuid::Base62Uuid; use crate::uuid::Base62Uuid;
@ -17,7 +17,7 @@ pub async fn get(
Path(id): Path<Base62Uuid>, Path(id): Path<Base62Uuid>,
State(pool): State<PgPool>, State(pool): State<PgPool>,
State(config): State<Config>, State(config): State<Config>,
hx_boosted: Option<TypedHeader<HXBoosted>>, hx_target: Option<TypedHeader<HXTarget>>,
layout: Layout, layout: Layout,
) -> Result<Response> { ) -> Result<Response> {
let entry = Entry::get(&pool, id.as_uuid()).await?; let entry = Entry::get(&pool, id.as_uuid()).await?;
@ -30,7 +30,7 @@ pub async fn get(
let content = fs::read_to_string(content_path).unwrap_or_else(|_| "No content".to_string()); let content = fs::read_to_string(content_path).unwrap_or_else(|_| "No content".to_string());
Ok(layout Ok(layout
.with_subtitle(&title) .with_subtitle(&title)
.boosted(hx_boosted) .targeted(hx_target)
.render(html! { .render(html! {
article { article {
header { header {

View File

@ -16,7 +16,7 @@ use tokio_stream::StreamExt;
use crate::actors::crawl_scheduler::{CrawlSchedulerHandle, CrawlSchedulerHandleMessage}; use crate::actors::crawl_scheduler::{CrawlSchedulerHandle, CrawlSchedulerHandleMessage};
use crate::actors::feed_crawler::FeedCrawlerHandleMessage; use crate::actors::feed_crawler::FeedCrawlerHandleMessage;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::htmx::HXBoosted; use crate::htmx::HXTarget;
use crate::models::entry::{Entry, GetEntriesOptions}; use crate::models::entry::{Entry, GetEntriesOptions};
use crate::models::feed::{CreateFeed, Feed}; use crate::models::feed::{CreateFeed, Feed};
use crate::partials::add_feed_form::add_feed_form; use crate::partials::add_feed_form::add_feed_form;
@ -28,7 +28,7 @@ use crate::uuid::Base62Uuid;
pub async fn get( pub async fn get(
Path(id): Path<Base62Uuid>, Path(id): Path<Base62Uuid>,
State(pool): State<PgPool>, State(pool): State<PgPool>,
hx_boosted: Option<TypedHeader<HXBoosted>>, hx_target: Option<TypedHeader<HXTarget>>,
layout: Layout, layout: Layout,
) -> Result<Response> { ) -> Result<Response> {
let feed = Feed::get(&pool, id.as_uuid()).await?; let feed = Feed::get(&pool, id.as_uuid()).await?;
@ -39,7 +39,7 @@ pub async fn get(
let title = feed.title.unwrap_or_else(|| "Untitled Feed".to_string()); let title = feed.title.unwrap_or_else(|| "Untitled Feed".to_string());
let entries = Entry::get_all(&pool, &options).await?; let entries = Entry::get_all(&pool, &options).await?;
let delete_url = format!("/feed/{}/delete", id); let delete_url = format!("/feed/{}/delete", id);
Ok(layout.with_subtitle(&title).boosted(hx_boosted).render(html! { Ok(layout.with_subtitle(&title).targeted(hx_target).render(html! {
header class="feed-header" { header class="feed-header" {
h2 { (title) } h2 { (title) }
button class="edit-feed" { "✏️ Edit feed" } button class="edit-feed" { "✏️ Edit feed" }

View File

@ -5,7 +5,7 @@ use maud::html;
use sqlx::PgPool; use sqlx::PgPool;
use crate::error::Result; use crate::error::Result;
use crate::htmx::HXBoosted; use crate::htmx::HXTarget;
use crate::models::feed::{Feed, GetFeedsOptions}; use crate::models::feed::{Feed, GetFeedsOptions};
use crate::partials::add_feed_form::add_feed_form; use crate::partials::add_feed_form::add_feed_form;
use crate::partials::feed_list::feed_list; use crate::partials::feed_list::feed_list;
@ -14,14 +14,14 @@ use crate::partials::opml_import_form::opml_import_form;
pub async fn get( pub async fn get(
State(pool): State<PgPool>, State(pool): State<PgPool>,
hx_boosted: Option<TypedHeader<HXBoosted>>, hx_target: Option<TypedHeader<HXTarget>>,
layout: Layout, layout: Layout,
) -> Result<Response> { ) -> Result<Response> {
let options = GetFeedsOptions::default(); let options = GetFeedsOptions::default();
let feeds = Feed::get_all(&pool, &options).await?; let feeds = Feed::get_all(&pool, &options).await?;
Ok(layout Ok(layout
.with_subtitle("feeds") .with_subtitle("feeds")
.boosted(hx_boosted) .targeted(hx_target)
.render(html! { .render(html! {
header { h2 { "Feeds" } } header { h2 { "Feeds" } }
div class="feeds" { div class="feeds" {

View File

@ -5,18 +5,18 @@ use maud::html;
use sqlx::PgPool; use sqlx::PgPool;
use crate::error::Result; use crate::error::Result;
use crate::htmx::HXBoosted; use crate::htmx::HXTarget;
use crate::models::entry::Entry; use crate::models::entry::Entry;
use crate::partials::{entry_list::entry_list, layout::Layout}; use crate::partials::{entry_list::entry_list, layout::Layout};
pub async fn get( pub async fn get(
State(pool): State<PgPool>, State(pool): State<PgPool>,
hx_boosted: Option<TypedHeader<HXBoosted>>, hx_target: Option<TypedHeader<HXTarget>>,
layout: Layout, layout: Layout,
) -> Result<Response> { ) -> Result<Response> {
let options = Default::default(); let options = Default::default();
let entries = Entry::get_all(&pool, &options).await?; let entries = Entry::get_all(&pool, &options).await?;
Ok(layout.boosted(hx_boosted).render(html! { Ok(layout.targeted(hx_target).render(html! {
ul class="entries" { ul class="entries" {
(entry_list(entries, &options)) (entry_list(entries, &options))
} }

View File

@ -18,15 +18,15 @@ use tokio_stream::Stream;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use crate::error::Result; use crate::error::Result;
use crate::htmx::HXBoosted; use crate::htmx::HXTarget;
use crate::log::MEM_LOG; use crate::log::MEM_LOG;
use crate::partials::layout::Layout; use crate::partials::layout::Layout;
pub async fn get(hx_boosted: Option<TypedHeader<HXBoosted>>, layout: Layout) -> Result<Response> { pub async fn get(hx_target: Option<TypedHeader<HXTarget>>, layout: Layout) -> Result<Response> {
let mem_buf = MEM_LOG.lock().unwrap(); let mem_buf = MEM_LOG.lock().unwrap();
Ok(layout Ok(layout
.with_subtitle("log") .with_subtitle("log")
.boosted(hx_boosted) .targeted(hx_target)
.render(html! { .render(html! {
pre id="log" hx-sse="connect:/log/stream swap:message" hx-swap="beforeend" hx-target="#log" { pre id="log" hx-sse="connect:/log/stream swap:message" hx-swap="beforeend" hx-target="#log" {
(PreEscaped(convert_escaped(from_utf8(mem_buf.as_slices().0).unwrap()).unwrap())) (PreEscaped(convert_escaped(from_utf8(mem_buf.as_slices().0).unwrap()).unwrap()))

View File

@ -8,7 +8,7 @@ use sqlx::PgPool;
use crate::auth::verify_password; use crate::auth::verify_password;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::htmx::{HXBoosted, HXRedirect}; use crate::htmx::{HXRedirect, HXTarget};
use crate::partials::login_form::{login_form, LoginFormProps}; use crate::partials::login_form::{login_form, LoginFormProps};
use crate::{ use crate::{
models::user::{AuthContext, User}, models::user::{AuthContext, User},
@ -22,36 +22,49 @@ pub struct Login {
password: String, password: String,
} }
pub async fn get(hx_boosted: Option<TypedHeader<HXBoosted>>, layout: Layout) -> Result<Response> { pub fn login_page(
Ok(layout hx_target: Option<TypedHeader<HXTarget>>,
layout: Layout,
form_props: LoginFormProps,
) -> Response {
layout
.with_subtitle("login") .with_subtitle("login")
.boosted(hx_boosted) .targeted(hx_target)
.render(html! { .render(html! {
div class="center-horizontal" { div class="center-horizontal" {
header class="center-text" { header class="center-text" {
h2 { "Login" } h2 { "Login" }
} }
(login_form(LoginFormProps::default())) (login_form(form_props))
} }
})) })
.into_response()
}
pub async fn get(hx_target: Option<TypedHeader<HXTarget>>, layout: Layout) -> Result<Response> {
Ok(login_page(hx_target, layout, LoginFormProps::default()))
} }
pub async fn post( pub async fn post(
State(pool): State<PgPool>, State(pool): State<PgPool>,
mut auth: AuthContext, mut auth: AuthContext,
hx_target: Option<TypedHeader<HXTarget>>,
layout: Layout,
Form(login): Form<Login>, Form(login): Form<Login>,
) -> Result<Response> { ) -> Result<Response> {
let user: User = match User::get_by_email(&pool, login.email.clone()).await { let user: User = match User::get_by_email(&pool, login.email.clone()).await {
Ok(user) => user, Ok(user) => user,
Err(err) => { Err(err) => {
if let Error::NotFoundString(_, _) = err { if let Error::NotFoundString(_, _) = err {
// Error::BadRequest("invalid email or password") return Ok(login_page(
return Ok(login_form(LoginFormProps { hx_target,
layout,
LoginFormProps {
email: Some(login.email), email: Some(login.email),
general_error: Some("invalid email or password".to_string()), general_error: Some("invalid email or password".to_string()),
..Default::default() ..Default::default()
}) },
.into_response()); ));
} else { } else {
return Err(err); return Err(err);
} }
@ -61,13 +74,15 @@ pub async fn post(
.await .await
.is_err() .is_err()
{ {
// return Err(Error::BadRequest("invalid email or password")); return Ok(login_page(
return Ok(login_form(LoginFormProps { hx_target,
layout,
LoginFormProps {
email: Some(login.email), email: Some(login.email),
general_error: Some("invalid email or password".to_string()), general_error: Some("invalid email or password".to_string()),
..Default::default() ..Default::default()
}) },
.into_response()); ));
} }
auth.login(&user) auth.login(&user)
.await .await

View File

@ -7,7 +7,7 @@ use serde_with::{serde_as, NoneAsEmptyString};
use sqlx::PgPool; use sqlx::PgPool;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::htmx::{HXBoosted, HXRedirect}; use crate::htmx::{HXTarget, HXRedirect};
use crate::models::user::{AuthContext, CreateUser, User}; use crate::models::user::{AuthContext, CreateUser, User};
use crate::partials::layout::Layout; use crate::partials::layout::Layout;
use crate::partials::register_form::{register_form, RegisterFormProps}; use crate::partials::register_form::{register_form, RegisterFormProps};
@ -22,10 +22,10 @@ pub struct Register {
pub name: Option<String>, pub name: Option<String>,
} }
pub async fn get(hx_boosted: Option<TypedHeader<HXBoosted>>, layout: Layout) -> Result<Response> { pub async fn get(hx_target: Option<TypedHeader<HXTarget>>, layout: Layout) -> Result<Response> {
Ok(layout Ok(layout
.with_subtitle("register") .with_subtitle("register")
.boosted(hx_boosted) .targeted(hx_target)
.render(html! { .render(html! {
div class="center-horizontal" { div class="center-horizontal" {
header class="center-text" { header class="center-text" {

View File

@ -6,7 +6,9 @@ use http::StatusCode;
#[allow(clippy::declare_interior_mutable_const)] #[allow(clippy::declare_interior_mutable_const)]
pub const HX_LOCATION: HeaderName = HeaderName::from_static("hx-location"); pub const HX_LOCATION: HeaderName = HeaderName::from_static("hx-location");
#[allow(clippy::declare_interior_mutable_const)] #[allow(clippy::declare_interior_mutable_const)]
pub const HX_BOOSTED: HeaderName = HeaderName::from_static("hx-boosted"); pub const HX_REQUEST: HeaderName = HeaderName::from_static("hx-request");
#[allow(clippy::declare_interior_mutable_const)]
pub const HX_TARGET: HeaderName = HeaderName::from_static("hx-target");
/// Sets the HX-Location header so that HTMX redirects to the given URI. Unlike /// Sets the HX-Location header so that HTMX redirects to the given URI. Unlike
/// axum::response::Redirect this does not return a 300-level status code (instead, a 200 status /// axum::response::Redirect this does not return a 300-level status code (instead, a 200 status
@ -35,18 +37,18 @@ impl IntoResponse for HXRedirect {
} }
#[derive(Debug)] #[derive(Debug)]
pub struct HXBoosted(bool); pub struct HXRequest(bool);
impl HXBoosted { impl HXRequest {
pub fn is_boosted(&self) -> bool { pub fn is_true(&self) -> bool {
self.0 self.0
} }
} }
impl Header for HXBoosted { impl Header for HXRequest {
fn name() -> &'static HeaderName { fn name() -> &'static HeaderName {
static HX_BOOSTED_STATIC: HeaderName = HX_BOOSTED; static HX_REQUEST_STATIC: HeaderName = HX_REQUEST;
&HX_BOOSTED_STATIC &HX_REQUEST_STATIC
} }
fn decode<'i, I>(values: &mut I) -> Result<Self, headers::Error> fn decode<'i, I>(values: &mut I) -> Result<Self, headers::Error>
@ -58,9 +60,9 @@ impl Header for HXBoosted {
.ok_or_else(headers::Error::invalid)?; .ok_or_else(headers::Error::invalid)?;
if value == "true" { if value == "true" {
Ok(HXBoosted(true)) Ok(HXRequest(true))
} else if value == "false" { } else if value == "false" {
Ok(HXBoosted(false)) Ok(HXRequest(false))
} else { } else {
Err(headers::Error::invalid()) Err(headers::Error::invalid())
} }
@ -81,3 +83,34 @@ impl Header for HXBoosted {
values.extend(std::iter::once(value)); values.extend(std::iter::once(value));
} }
} }
#[derive(Debug)]
pub struct HXTarget {
pub target: HeaderValue,
}
impl Header for HXTarget {
fn name() -> &'static HeaderName {
static HX_TARGET_STATIC: HeaderName = HX_TARGET;
&HX_TARGET_STATIC
}
fn decode<'i, I>(values: &mut I) -> Result<Self, headers::Error>
where
I: Iterator<Item = &'i HeaderValue>,
{
let value = values
.next()
.ok_or_else(headers::Error::invalid)?;
Ok(HXTarget{ target: value.clone() })
}
fn encode<E>(&self, values: &mut E)
where
E: Extend<HeaderValue>,
{
values.extend(std::iter::once(self.target.clone()));
}
}

View File

@ -11,13 +11,16 @@ use axum::{
TypedHeader, TypedHeader,
}; };
use axum_login::{extractors::AuthContext, SqlxStore}; use axum_login::{extractors::AuthContext, SqlxStore};
use headers::HeaderValue;
use maud::{html, Markup, DOCTYPE}; use maud::{html, Markup, DOCTYPE};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::models::user::User; use crate::models::user::User;
use crate::{config::Config, partials::footer::footer}; use crate::config::Config;
use crate::{htmx::HXBoosted, partials::header::header}; use crate::htmx::HXTarget;
use crate::partials::header::header;
use crate::partials::footer::footer;
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
use crate::{CSS_MANIFEST, JS_MANIFEST}; use crate::{CSS_MANIFEST, JS_MANIFEST};
@ -26,7 +29,7 @@ pub struct Layout {
pub title: String, pub title: String,
pub subtitle: Option<String>, pub subtitle: Option<String>,
pub user: Option<User>, pub user: Option<User>,
pub boosted: bool, pub main_content_targeted: bool,
} }
#[async_trait] #[async_trait]
@ -118,17 +121,16 @@ impl Layout {
self self
} }
/// If the given HX-Boosted header is present and "true", makes this Layout boosted. /// If the given HX-Target is present and equal to "main-content", then this function will make
/// /// this Layout skip rendering the layout and only render the template with a hx-swap-oob
/// A boosted layout will skip rendering the layout and only render the template with a /// <title> element to update the document title.
/// hx-swap-oob <title> element to update the document title.
/// ///
/// Links and forms that are boosted with the hx-boost attribute are only updating a portion of /// Links and forms that are boosted with the hx-boost attribute are only updating a portion of
/// the page inside the layout, so there is no need to render and send the layout again. /// the page inside the layout, so there is no need to render and send the layout again.
pub fn boosted(mut self, hx_boosted: Option<TypedHeader<HXBoosted>>) -> Self { pub fn targeted(mut self, hx_target: Option<TypedHeader<HXTarget>>) -> Self {
if let Some(hx_boosted) = hx_boosted { if let Some(hx_target) = hx_target {
if hx_boosted.is_boosted() { if hx_target.target == HeaderValue::from_static("main-content") {
self.boosted = true; self.main_content_targeted = true;
} }
} }
self self
@ -143,7 +145,7 @@ impl Layout {
} }
pub fn render(self, template: Markup) -> Response { pub fn render(self, template: Markup) -> Response {
if self.boosted { if self.main_content_targeted {
html! { html! {
title hx-swap-oob="true" { (self.full_title()) } title hx-swap-oob="true" { (self.full_title()) }
(template) (template)

View File

@ -16,7 +16,7 @@ pub fn login_form(props: LoginFormProps) -> Markup {
general_error, general_error,
} = props; } = props;
html! { html! {
form hx-post="/login" hx-swap="outerHTML" class="auth-form-grid" { form action="/login" method="post" class="auth-form-grid" {
label for="email" { "Email" } label for="email" { "Email" }
input type="email" name="email" id="email" placeholder="Email" value=(email.unwrap_or_default()) required; input type="email" name="email" id="email" placeholder="Email" value=(email.unwrap_or_default()) required;
@if let Some(email_error) = email_error { @if let Some(email_error) = email_error {