use axum::body::Body; use axum::extract::State; use axum::http::Uri; use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum::Router; use hemplate::Hemplate; use hemx::{Html, IntoEffect}; use hemx_axum::{ interactions, runtime_js, runtime_js_path, EffectResponse, Form, InteractionHandlers, InteractionRequest, PageRequest, }; use hemx_html_examples::ui; use hemx_html_examples::ui::{contact_card, editable_row, gallery}; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; #[derive(Default)] struct GalleryState { contacts: Mutex>, rows: Mutex>, loaded_count: Mutex, infinite_count: Mutex, lazy_loads: Mutex, progress: Mutex, email: Mutex, email_status: Mutex, category: Mutex, value: Mutex, reset_status: Mutex, } #[derive(Clone)] struct ContactRecord { id: u64, name: String, email: String, editing: bool, } #[derive(Clone)] struct RowRecord { id: u64, title: String, editing: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct Id(u64); impl std::str::FromStr for Id { type Err = &'static str; fn from_str(value: &str) -> Result { value.parse::().map(Id).map_err(|_| "invalid id") } } impl std::fmt::Display for Id { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } #[hemx::form("edit_contact")] struct EditContact { id: Id, } #[hemx::form("edit_row")] struct EditRow { id: Id, } #[hemx::form("delete_row")] struct DeleteRow { id: Id, } #[hemx::form("load_more")] struct LoadMore { request: String, } #[hemx::form("infinite_scroll")] struct InfiniteScroll { request: String, } #[hemx::form("save_contact")] struct SaveContact { id: Id, name: String, email: String, } #[hemx::form("save_row")] struct SaveRow { id: Id, title: String, } #[hemx::form("lazy_load")] struct LazyLoad { request: String, } #[hemx::form("tick_progress")] struct TickProgress { request: String, } #[hemx::form("validate_email")] struct ValidateEmail { email: String, } #[hemx::form("choose_category")] struct ChooseCategory { category: String, } #[hemx::form("reset_message")] struct ResetMessage { message: String, } #[derive(Hemplate)] struct AppShell { runtime_src: &'static str, body: Html, } #[derive(Hemplate)] struct Gallery { contacts: Vec, rows: Vec, lazy_panel: String, loaded_rows: Vec, load_status: String, infinite_rows: Vec, infinite_status: String, progress: u8, progress_label: String, email: String, email_status: String, value_options: Vec, reset_status: String, query: String, search_status: String, search_results: Vec, } #[derive(Hemplate, Clone)] struct ContactCard { id: Id, name: String, email: String, editing: bool, } impl hemx::KeyedPartial for ContactCard { fn hemx_key(&self) -> String { self.id.to_string() } } struct ProgressMeter { percent: u8, } impl Hemplate for ProgressMeter { fn render_into(&self, out: &mut String) -> Result<(), hemplate::error::HemplateError> { use std::fmt::Write as _; write!( out, " {}% complete", self.percent, self.percent, self.percent ) .expect("write to String cannot fail"); Ok(()) } } #[derive(Hemplate, Clone)] struct EditableRow { id: Id, title: String, editing: bool, } impl hemx::KeyedPartial for EditableRow { fn hemx_key(&self) -> String { self.id.to_string() } } #[derive(Hemplate, Clone)] struct LoadedRow { id: Id, title: String, } impl hemx::KeyedPartial for LoadedRow { fn hemx_key(&self) -> String { self.id.to_string() } } #[derive(Hemplate, Clone)] struct ValueOption { id: Id, value: String, label: String, selected: bool, } impl hemx::KeyedPartial for ValueOption { fn hemx_key(&self) -> String { self.id.to_string() } } #[derive(Hemplate, Clone)] struct SearchResult { id: Id, label: String, } impl hemx::KeyedPartial for SearchResult { fn hemx_key(&self) -> String { self.id.to_string() } } #[tokio::main] async fn main() { let state = Arc::new(GalleryState::seeded()); let app = Router::new() .route("/", get(home).post(interact)) .route(runtime_js_path(), get(runtime)) .route("/app.css", get(css)) .with_state(state); let port = std::env::var("HEMX_HTML_EXAMPLES_PORT") .unwrap_or_else(|_| "3029".to_string()) .parse::() .expect("HEMX_HTML_EXAMPLES_PORT must be a valid u16"); let addr = SocketAddr::from(([127, 0, 0, 1], port)); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); println!("hemx HTML examples: http://{addr}"); axum::serve(listener, app).await.unwrap(); } impl GalleryState { fn seeded() -> Self { Self { contacts: Mutex::new(vec![ContactRecord { id: 1, name: "Ada Lovelace".into(), email: "ada@example.com".into(), editing: false, }]), rows: Mutex::new(vec![ RowRecord { id: 1, title: "Write boring HTML".into(), editing: false, }, RowRecord { id: 2, title: "Keep state on the server".into(), editing: false, }, ]), loaded_count: Mutex::new(2), infinite_count: Mutex::new(3), lazy_loads: Mutex::new(0), progress: Mutex::new(0), email: Mutex::new(String::new()), email_status: Mutex::new("Waiting for an email".into()), category: Mutex::new("letters".into()), value: Mutex::new("alpha".into()), reset_status: Mutex::new("No message sent yet".into()), } } } async fn home( State(state): State>, uri: Uri, request: PageRequest, ) -> impl IntoResponse { let query = query_param(uri.query(), "q"); request .page_html(ui::page(&gallery_view(&state, &query)), shell) .title("hemx HTML examples") .fingerprint(ui::BUILD_FINGERPRINT) } async fn interact( State(state): State>, request: InteractionRequest, ) -> Result { request.dispatch_async(handlers(state)).await } async fn runtime() -> impl IntoResponse { runtime_js() } async fn css() -> Response { Response::builder() .header("content-type", "text/css; charset=utf-8") .body(Body::from(include_str!("../templates/app.css"))) .expect("css response") } #[hemx::app(gallery_handlers, contact_card_handlers, editable_row_handlers)] fn handlers(state: Arc) -> InteractionHandlers { interactions(ui::BUILD_FINGERPRINT) } fn shell(body: Html) -> Html { ui::page(&AppShell { runtime_src: runtime_js_path(), body, }) } fn gallery_view(state: &GalleryState, query: &str) -> Gallery { let contacts = state .contacts .lock() .unwrap() .iter() .cloned() .map(contact_card_view) .collect(); let rows = state .rows .lock() .unwrap() .iter() .cloned() .map(editable_row_view) .collect(); let loaded_count = *state.loaded_count.lock().unwrap(); let infinite_count = *state.infinite_count.lock().unwrap(); let lazy_loads = *state.lazy_loads.lock().unwrap(); let progress = *state.progress.lock().unwrap(); let email = state.email.lock().unwrap().clone(); let email_status = state.email_status.lock().unwrap().clone(); let category = state.category.lock().unwrap().clone(); let value = state.value.lock().unwrap().clone(); let reset_status = state.reset_status.lock().unwrap().clone(); let query = query.trim().to_owned(); let search_results = search_results_for(&query); Gallery { contacts, rows, lazy_panel: lazy_panel_text(lazy_loads), loaded_rows: loaded_rows(loaded_count), load_status: format!("Showing {loaded_count} rows"), infinite_rows: loaded_rows(infinite_count), infinite_status: format!("Showing {infinite_count} rows"), progress, progress_label: format!("{progress}% complete"), email, email_status, value_options: value_options_for(&category, &value), reset_status, query: query.clone(), search_status: if query.is_empty() { "Showing all results".into() } else { format!("Results for {query}") }, search_results, } } fn lazy_panel_text(loads: usize) -> String { if loads == 0 { "Waiting to be revealed".into() } else { format!("Lazy content loaded by server update #{loads}") } } fn is_demo_email(email: &str) -> bool { let Some((local, domain)) = email.split_once('@') else { return false; }; !local.is_empty() && domain .split('.') .filter(|part| !part.is_empty()) .take(2) .count() >= 2 } fn contact_card_view(record: ContactRecord) -> ContactCard { ContactCard { id: Id(record.id), name: record.name, email: record.email, editing: record.editing, } } fn editable_row_view(record: RowRecord) -> EditableRow { EditableRow { id: Id(record.id), title: record.title, editing: record.editing, } } fn loaded_rows(count: usize) -> Vec { (1..=count) .map(|id| LoadedRow { id: Id(id as u64), title: format!("Loaded row {id}"), }) .collect() } fn value_options_for(category: &str, selected: &str) -> Vec { let values = match category { "numbers" => [(1, "one", "One"), (2, "two", "Two")], _ => [(1, "alpha", "Alpha"), (2, "beta", "Beta")], }; values .into_iter() .map(|(id, value, label)| ValueOption { id: Id(id), value: value.into(), label: label.into(), selected: value == selected, }) .collect() } fn search_results_for(query: &str) -> Vec { ["Alpha", "Beta", "Gamma", "Delta"] .into_iter() .enumerate() .filter(|(_, label)| { query.is_empty() || label.to_lowercase().contains(&query.to_lowercase()) }) .map(|(index, label)| SearchResult { id: Id(index as u64 + 1), label: label.into(), }) .collect() } fn query_param(query: Option<&str>, name: &str) -> String { query .unwrap_or("") .split('&') .filter_map(|pair| pair.split_once('=')) .find_map(|(key, value)| (key == name).then(|| form_decode(value))) .unwrap_or_default() } fn form_decode(value: &str) -> String { let mut bytes = Vec::with_capacity(value.len()); let mut input = value.as_bytes().iter().copied(); while let Some(byte) = input.next() { match byte { b'+' => bytes.push(b' '), b'%' => { let high = input.next().and_then(hex_value); let low = input.next().and_then(hex_value); if let (Some(high), Some(low)) = (high, low) { bytes.push((high << 4) | low); } } byte => bytes.push(byte), } } String::from_utf8_lossy(&bytes).into_owned() } fn hex_value(byte: u8) -> Option { match byte { b'0'..=b'9' => Some(byte - b'0'), b'a'..=b'f' => Some(byte - b'a' + 10), b'A'..=b'F' => Some(byte - b'A' + 10), _ => None, } } #[hemx::component("gallery")] mod gallery_handlers { use super::*; #[hemx::handler] async fn lazy_load( State(state): State>, Form(input): Form, ) -> impl IntoEffect { let _ = input.request; let mut lazy_loads = state.lazy_loads.lock().unwrap(); *lazy_loads += 1; gallery::lazy_panel.set(lazy_panel_text(*lazy_loads)) } #[hemx::handler] async fn tick_progress( State(state): State>, Form(input): Form, ) -> impl IntoEffect { let _ = input.request; let mut progress = state.progress.lock().unwrap(); *progress = (*progress + 25).min(100); gallery::progress_meter.replace(&ProgressMeter { percent: *progress }) } #[hemx::handler] async fn choose_category( State(state): State>, Form(input): Form, ) -> impl IntoEffect { let category = if input.category == "numbers" { "numbers" } else { "letters" } .to_owned(); let value = if category == "numbers" { "one" } else { "alpha" } .to_owned(); *state.category.lock().unwrap() = category.clone(); *state.value.lock().unwrap() = value.clone(); value_options_for(&category, &value) .into_iter() .map(|option| gallery::value_option.replace(option)) .collect::>() } #[hemx::handler] async fn reset_message( State(state): State>, Form(input): Form, ) -> impl IntoEffect { let status = if input.message.trim().is_empty() { "Nothing to send".to_owned() } else { format!("Sent: {}", input.message.trim()) }; *state.reset_status.lock().unwrap() = status.clone(); vec![ gallery::reset_status.set(status), gallery::reset_message_form.clear(), ] } #[hemx::handler] async fn infinite_scroll( State(state): State>, Form(input): Form, ) -> impl IntoEffect { let _ = input.request; let mut count = state.infinite_count.lock().unwrap(); let first_new = *count + 1; *count += 3; let rows = loaded_rows(*count) .into_iter() .filter(|row| row.id.0 >= first_new as u64) .map(|row| gallery::infinite_row.append(row)) .collect::>(); let mut effects = rows; effects.push(gallery::infinite_status.set(format!("Showing {} rows", *count))); effects } #[hemx::handler] async fn validate_email( State(state): State>, Form(input): Form, ) -> impl IntoEffect { let email = input.email.trim().to_owned(); if !is_demo_email(&email) { *state.email.lock().unwrap() = email; *state.email_status.lock().unwrap() = "Email needs a name and dotted domain".into(); return vec![ gallery::validate_email_form.focus("email"), gallery::validate_email_form.error("email", "Use a real email address"), gallery::email_status.set("Email needs a name and dotted domain"), ]; } *state.email.lock().unwrap() = email.clone(); *state.email_status.lock().unwrap() = format!("{email} is valid"); vec![ gallery::validate_email_form.error("email", ""), gallery::email_status.set(format!("{email} is valid")), ] } #[hemx::handler] async fn load_more( State(state): State>, Form(input): Form, ) -> impl IntoEffect { let _ = input.request; let mut loaded = state.loaded_count.lock().unwrap(); let first_new = *loaded + 1; *loaded += 2; let rows = loaded_rows(*loaded) .into_iter() .filter(|row| row.id.0 >= first_new as u64) .map(|row| gallery::loaded_row.append(row)) .collect::>(); let mut effects = rows; effects.push(gallery::load_status.set(format!("Showing {} rows", *loaded))); effects } } #[hemx::component("contact_card")] mod contact_card_handlers { use super::*; #[hemx::handler] async fn edit_contact( State(state): State>, Form(input): Form, ) -> impl IntoEffect { let mut contacts = state.contacts.lock().unwrap(); if let Some(contact) = contacts.iter_mut().find(|contact| contact.id == input.id.0) { contact.editing = true; return Some(gallery::contact_card.replace(&contact_card_view(contact.clone()))); } None } #[hemx::handler] async fn save_contact( State(state): State>, Form(input): Form, ) -> impl IntoEffect { if input.name.trim().is_empty() || !input.email.contains('@') { return Some(contact_card::save_contact_form.focus("name")); } let mut contacts = state.contacts.lock().unwrap(); if let Some(contact) = contacts.iter_mut().find(|contact| contact.id == input.id.0) { contact.name = input.name; contact.email = input.email; contact.editing = false; return Some(gallery::contact_card.replace(&contact_card_view(contact.clone()))); } None } } #[hemx::component("editable_row")] mod editable_row_handlers { use super::*; #[hemx::handler] async fn edit_row( State(state): State>, Form(input): Form, ) -> impl IntoEffect { let mut rows = state.rows.lock().unwrap(); if let Some(row) = rows.iter_mut().find(|row| row.id == input.id.0) { row.editing = true; return Some(gallery::editable_row.replace(editable_row_view(row.clone()))); } None } #[hemx::handler] async fn save_row( State(state): State>, Form(input): Form, ) -> impl IntoEffect { if input.title.trim().is_empty() { return Some(editable_row::save_row_form.focus("title")); } let mut rows = state.rows.lock().unwrap(); if let Some(row) = rows.iter_mut().find(|row| row.id == input.id.0) { row.title = input.title; row.editing = false; return Some(gallery::editable_row.replace(editable_row_view(row.clone()))); } None } #[hemx::handler] async fn delete_row( State(state): State>, Form(input): Form, ) -> impl IntoEffect { let mut rows = state.rows.lock().unwrap(); let before = rows.len(); rows.retain(|row| row.id != input.id.0); (rows.len() != before).then(|| gallery::editable_row.remove(input.id)) } } #[cfg(test)] mod tests { use super::*; use hemx_test::inspect_batch; fn form(handle: hemx::Handle, fields: &[(&str, &str)]) -> hemx_axum::InteractionForm { hemx_axum::InteractionForm::for_handle( handle, fields .iter() .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())), ) } #[test] fn inline_validation_form_uses_input_event_for_revalidation() { let state = Arc::new(GalleryState::seeded()); let html = gallery_view(&state, "").render().expect("render gallery"); assert!( html.contains("data-hemx-on=\"input\""), "inline validation form must listen on input events" ); } #[test] fn active_search_is_reconstructed_from_url_query() { // req: page_swap/009 req: page_swap/010 let state = Arc::new(GalleryState::seeded()); let html = gallery_view(&state, &query_param(Some("q=ga"), "q")) .render() .expect("render gallery"); assert!(html.contains("name=\"q\" value=\"ga\"")); assert!(html.contains("Results for ga")); assert!(html.contains("Gamma")); assert_eq!( search_results_for("ga") .into_iter() .map(|result| result.label) .collect::>(), ["Gamma"] ); } #[test] fn active_search_form_marks_live_replace_and_submit_push() { // req: page_swap/009 let state = Arc::new(GalleryState::seeded()); let html = gallery_view(&state, "").render().expect("render gallery"); assert!(html.contains("method=\"get\"")); assert!(html.contains("data-hemx-history=\"replace\"")); assert!(html.contains("data-hemx-on=\"input\"")); assert!(html.contains("data-hemx-history=\"push\"")); assert!(!html.contains("data-hemx-handle=\"search\"")); assert!(!html.contains("data-hemx-form=\"search\"")); assert!(!html.contains("name=\"__h\"")); } #[test] fn revealed_forms_preserve_data_hemx_revealed_attribute() { // req: convention/005 let state = Arc::new(GalleryState::seeded()); let html = gallery_view(&state, "").render().expect("render gallery"); assert!( html.contains("data-hemx-revealed=\"true\""), "lazy-load and infinite-scroll forms must preserve data-hemx-revealed attribute" ); } #[test] fn readme_maps_every_htmx_example_slug() { let readme = include_str!("../README.md"); for slug in [ "click-to-edit", "bulk-update", "click-to-load", "delete-row", "edit-row", "lazy-load", "inline-validation", "infinite-scroll", "active-search", "progress-bar", "value-select", "animations", "file-upload", "file-upload-input", "reset-user-input", "dialogs", "modal-uikit", "modal-bootstrap", "modal-custom", "tabs-hateoas", "tabs-javascript", "keyboard-shortcuts", "sortable", "update-other-content", "confirm", "async-auth", "web-components", "move-before", ] { assert!( readme.contains(&format!("`{slug}`")), "missing htmx example slug {slug}" ); } } #[tokio::test] async fn gallery_covers_core_html_patterns_with_generated_resources() { let state = Arc::new(GalleryState::seeded()); let html = gallery_view(&state, "").render().expect("render gallery"); assert!(html.contains("data-hemx-root=\"gallery\"")); for slug in [ "click-to-edit", "edit-row", "delete-row", "lazy-load", "inline-validation", "infinite-scroll", "click-to-load", "progress-bar", "value-select", "reset-user-input", "active-search", ] { assert!( html.contains(&format!("data-htmx-example=\"{slug}\"")), "missing exact htmx slug {slug}" ); } assert!(html.contains("data-hemx-form=\"validate_email\"")); assert!(html.contains("data-hemx-revealed=\"true\"")); assert!(html.contains("data-hemx-handle=\"tick_progress\"")); assert!(!html.contains("data-hemx-interval=\"1000\"")); assert!(html.contains("0% complete")); assert!(html.contains("data-hemx-slot=\"loaded_row\"")); assert!(html.contains("data-hemx-slot=\"infinite_row\"")); assert!(html.contains("data-hemx-slot=\"value_option\"")); assert!(html.contains("data-hemx-slot=\"search_result\"")); let edit = inspect_batch( InteractionRequest::from(form(contact_card::edit_contact, &[("id", "1")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); // Component-level rendering now lowers contact_card-local handles, so the // effect contains the lowered handle id, not the source name. edit.assert_updates_html_containing( gallery::contact_card, &format!("data-hid=\"{}\"", contact_card::save_contact), ); let save = inspect_batch( InteractionRequest::from(form( contact_card::save_contact, &[("id", "1"), ("name", "Ada"), ("email", "ada@hemx.test")], )) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); save.assert_updates_html_containing(gallery::contact_card, "ada@hemx.test"); let lazy = inspect_batch( InteractionRequest::from(form(gallery::lazy_load, &[("request", "lazy")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); lazy.assert_updates_text_containing( gallery::lazy_panel, "Lazy content loaded by server update #1", ); let more = inspect_batch( InteractionRequest::from(form(gallery::load_more, &[("request", "more")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert!(more.payload_contains("Loaded row 3")); assert!(more.payload_contains("Loaded row 4")); assert!(more.updates_text(gallery::load_status)); let invalid = inspect_batch( InteractionRequest::from(form(gallery::validate_email, &[("email", "bad")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert!(invalid.payload_contains("Use a real email address")); assert!(invalid.payload_contains("Email needs a name and dotted domain")); assert!(invalid.updates_text(gallery::email_status)); let partial = inspect_batch( InteractionRequest::from(form(gallery::validate_email, &[("email", "xyz@")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert!(partial.payload_contains("Email needs a name and dotted domain")); assert!(!partial.payload_contains("xyz@ is valid")); let valid = inspect_batch( InteractionRequest::from(form( gallery::validate_email, &[("email", "xyz@example.com")], )) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert!(valid.payload_contains("xyz@example.com is valid")); assert!(!valid.payload_contains("Use a real email address")); assert!(!valid.payload_contains("hemx:form-reset")); assert!(valid.updates_text(gallery::email_status)); let infinite = inspect_batch( InteractionRequest::from(form(gallery::infinite_scroll, &[("request", "more")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert!(infinite.payload_contains("Loaded row 4")); assert!(infinite.payload_contains("Loaded row 6")); let progress = inspect_batch( InteractionRequest::from(form(gallery::tick_progress, &[("request", "tick")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert!(progress.payload_contains("25% complete")); let values = inspect_batch( InteractionRequest::from(form(gallery::choose_category, &[("category", "numbers")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert!(values.replaces_keyed_html_containing(gallery::value_option, "1", "One")); let reset = inspect_batch( InteractionRequest::from(form(gallery::reset_message, &[("message", "hello")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert!(reset.updates_text(gallery::reset_status)); assert!(reset.resets_form(gallery::reset_message_form)); let load = inspect_batch( InteractionRequest::from(form(gallery::load_more, &[("request", "more")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert!(load.payload_contains("Loaded row 6")); let row_save = inspect_batch( InteractionRequest::from(form( editable_row::save_row, &[("id", "1"), ("title", "Write dynamic HTML")], )) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert!(row_save.replaces_keyed_html_containing( gallery::editable_row, "1", "Write dynamic HTML" )); let delete = inspect_batch( InteractionRequest::from(form(editable_row::delete_row, &[("id", "1")])) .dispatch_async(handlers(state)) .await .unwrap() .batch, ); assert!(delete.removes_key(gallery::editable_row, "1")); } }