feat(examples): add hemx html pattern gallery
Add examples/html_examples as a copy-pasteable gallery for the first htmx-style HTML UX patterns using exact htmx URL slugs in the page and README. The slice covers click-to-edit, edit-row, delete-row, inline-validation, click-to-load, and active-search with .heml templates, generated resources, server-owned Rust state, and runtime-free selector targeting. req: htmx_equivalents/001 req: htmx_equivalents/002 req: examples/001
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
#[hemx::surface]
|
||||
pub mod ui {}
|
||||
@@ -0,0 +1,565 @@
|
||||
use axum::extract::State;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use hemplate::Hemplate;
|
||||
use hemx::advanced::Effect;
|
||||
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>,
|
||||
email: Mutex<String>,
|
||||
email_status: Mutex<String>,
|
||||
query: 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("save_contact")]
|
||||
struct SaveContact {
|
||||
id: Id,
|
||||
name: String,
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[hemx::form("save_row")]
|
||||
struct SaveRow {
|
||||
id: Id,
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[hemx::form("validate_email")]
|
||||
struct ValidateEmail {
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[hemx::form("search")]
|
||||
struct SearchInput {
|
||||
query: String,
|
||||
}
|
||||
|
||||
#[derive(Hemplate)]
|
||||
struct AppShell {
|
||||
runtime_src: &'static str,
|
||||
body: Html,
|
||||
}
|
||||
|
||||
#[derive(Hemplate)]
|
||||
struct Gallery {
|
||||
contacts: Vec<ContactCard>,
|
||||
rows: Vec<EditableRow>,
|
||||
email: String,
|
||||
email_status: String,
|
||||
loaded_rows: Vec<LoadedRow>,
|
||||
load_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()
|
||||
}
|
||||
}
|
||||
|
||||
#[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 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))
|
||||
.with_state(state);
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3029));
|
||||
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),
|
||||
email: Mutex::new(String::new()),
|
||||
email_status: Mutex::new("Waiting for an email".into()),
|
||||
query: Mutex::new(String::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn home(State(state): State<Arc<GalleryState>>, request: PageRequest) -> impl IntoResponse {
|
||||
request
|
||||
.page_html(ui::page(&gallery_view(&state)), 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()
|
||||
}
|
||||
|
||||
#[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) -> 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 email = state.email.lock().unwrap().clone();
|
||||
let email_status = state.email_status.lock().unwrap().clone();
|
||||
let query = state.query.lock().unwrap().clone();
|
||||
let search_results = search_results_for(&query);
|
||||
Gallery {
|
||||
contacts,
|
||||
rows,
|
||||
email,
|
||||
email_status,
|
||||
loaded_rows: loaded_rows(loaded_count),
|
||||
load_status: format!("Showing {loaded_count} rows"),
|
||||
query: query.clone(),
|
||||
search_status: if query.is_empty() {
|
||||
"Showing all results".into()
|
||||
} else {
|
||||
format!("Results for {query}")
|
||||
},
|
||||
search_results,
|
||||
}
|
||||
}
|
||||
|
||||
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 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()
|
||||
}
|
||||
|
||||
#[hemx::component("gallery")]
|
||||
mod gallery_handlers {
|
||||
use super::*;
|
||||
|
||||
#[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 !email.contains('@') {
|
||||
*state.email.lock().unwrap() = email;
|
||||
*state.email_status.lock().unwrap() = "Email needs an @ sign".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 an @ sign"),
|
||||
];
|
||||
}
|
||||
*state.email.lock().unwrap() = email.clone();
|
||||
*state.email_status.lock().unwrap() = format!("{email} is valid");
|
||||
vec![
|
||||
gallery::email_status.set(format!("{email} is valid")),
|
||||
gallery::validate_email_form.clear(),
|
||||
]
|
||||
}
|
||||
|
||||
#[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();
|
||||
*loaded += 2;
|
||||
let rows = loaded_rows(*loaded)
|
||||
.into_iter()
|
||||
.map(|row| gallery::loaded_row.replace(row))
|
||||
.collect::<Vec<Effect>>();
|
||||
let mut effects = rows;
|
||||
effects.push(gallery::load_status.set(format!("Showing {} rows", *loaded)));
|
||||
effects
|
||||
}
|
||||
|
||||
#[hemx::handler]
|
||||
async fn search(
|
||||
State(state): State<Arc<GalleryState>>,
|
||||
Form(input): Form<SearchInput>,
|
||||
) -> impl IntoEffect {
|
||||
let query = input.query.trim().to_owned();
|
||||
*state.query.lock().unwrap() = query.clone();
|
||||
let results = search_results_for(&query)
|
||||
.into_iter()
|
||||
.map(|result| gallery::search_result.replace(result))
|
||||
.collect::<Vec<Effect>>();
|
||||
let mut effects = results;
|
||||
effects.push(gallery::search_status.set(if query.is_empty() {
|
||||
"Showing all results".into()
|
||||
} else {
|
||||
format!("Results for {query}")
|
||||
}));
|
||||
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())),
|
||||
)
|
||||
}
|
||||
|
||||
#[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",
|
||||
"inline-validation",
|
||||
"click-to-load",
|
||||
"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-slot=\"loaded_row\""));
|
||||
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,
|
||||
);
|
||||
assert!(edit.updates_html_containing(gallery::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,
|
||||
);
|
||||
assert!(save.updates_html_containing(gallery::contact_card, "ada@hemx.test"));
|
||||
|
||||
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.updates_text(gallery::email_status));
|
||||
|
||||
let load = inspect_batch(
|
||||
InteractionRequest::from(form(gallery::load_more, &[("request", "more")]))
|
||||
.dispatch_async(handlers(state.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert!(load.replaces_keyed_html_containing(gallery::loaded_row, "4", "Loaded row 4"));
|
||||
|
||||
let search = inspect_batch(
|
||||
InteractionRequest::from(form(gallery::search, &[("query", "ga")]))
|
||||
.dispatch_async(handlers(state.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert!(search.replaces_keyed_html_containing(gallery::search_result, "3", "Gamma"));
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user