Files
hemx/examples/html_examples/src/main.rs
T
2026-07-13 10:07:40 +02:00

1008 lines
30 KiB
Rust

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<Vec<ContactRecord>>,
rows: Mutex<Vec<RowRecord>>,
loaded_count: Mutex<usize>,
infinite_count: Mutex<usize>,
lazy_loads: Mutex<usize>,
progress: Mutex<u8>,
email: Mutex<String>,
email_status: Mutex<String>,
category: Mutex<String>,
value: Mutex<String>,
reset_status: Mutex<String>,
}
#[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<Self, Self::Err> {
value.parse::<u64>().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<ContactCard>,
rows: Vec<EditableRow>,
lazy_panel: String,
loaded_rows: Vec<LoadedRow>,
load_status: String,
infinite_rows: Vec<LoadedRow>,
infinite_status: String,
progress: u8,
progress_label: String,
email: String,
email_status: String,
value_options: Vec<ValueOption>,
reset_status: String,
query: String,
search_status: String,
search_results: Vec<SearchResult>,
}
#[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,
"<progress value=\"{}\" max=\"100\" aria-label=\"{}% complete\"></progress> <span>{}% complete</span>",
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::<u16>()
.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<Arc<GalleryState>>,
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<Arc<GalleryState>>,
request: InteractionRequest,
) -> Result<EffectResponse, impl IntoResponse> {
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<GalleryState>) -> 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<LoadedRow> {
(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<ValueOption> {
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<SearchResult> {
["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<u8> {
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<Arc<GalleryState>>,
Form(input): Form<LazyLoad>,
) -> 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<Arc<GalleryState>>,
Form(input): Form<TickProgress>,
) -> 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<Arc<GalleryState>>,
Form(input): Form<ChooseCategory>,
) -> 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::<Vec<_>>()
}
#[hemx::handler]
async fn reset_message(
State(state): State<Arc<GalleryState>>,
Form(input): Form<ResetMessage>,
) -> 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<Arc<GalleryState>>,
Form(input): Form<InfiniteScroll>,
) -> 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::<Vec<_>>();
let mut effects = rows;
effects.push(gallery::infinite_status.set(format!("Showing {} rows", *count)));
effects
}
#[hemx::handler]
async fn validate_email(
State(state): State<Arc<GalleryState>>,
Form(input): Form<ValidateEmail>,
) -> 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<Arc<GalleryState>>,
Form(input): Form<LoadMore>,
) -> 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::<Vec<_>>();
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<Arc<GalleryState>>,
Form(input): Form<EditContact>,
) -> 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<Arc<GalleryState>>,
Form(input): Form<SaveContact>,
) -> 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<Arc<GalleryState>>,
Form(input): Form<EditRow>,
) -> 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<Arc<GalleryState>>,
Form(input): Form<SaveRow>,
) -> 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<Arc<GalleryState>>,
Form(input): Form<DeleteRow>,
) -> 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<I>(handle: hemx::Handle<I>, 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::<Vec<_>>(),
["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("<span>25% complete</span>"));
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"));
}
}