use axum::extract::{Query, State}; use axum::response::IntoResponse; use axum::routing::get; use axum::Router; use futures_util::{stream, StreamExt}; use hemplate::Hemplate; use hemx::{Html, IntoEffect}; use hemx_axum::{ interactions, runtime_js, runtime_js_path, sse, EffectResponse, Form, HandlerErrorContext, HandlerFailure, InteractionHandlers, InteractionRequest, IntoHandlerFailure, PageRequest, }; use hemx_v0_examples::ui; use hemx_v0_examples::ui::{auth, counter, notifications, page_swap, todo_row, todos, wizard}; use std::collections::BTreeMap; use std::convert::Infallible; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; use std::time::Duration; #[derive(Default)] struct ExampleState { counter: Mutex, todos: Mutex>, wizard_step: Mutex, } #[derive(Clone)] struct TodoRecord { // Domain/SQL-shaped rows stay as boring Rust data; hemplate view structs are derived at render/update boundaries. // req: examples/001 req: view/001 id: u64, title: String, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct TodoId(u64); impl std::fmt::Display for TodoId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } impl std::str::FromStr for TodoId { type Err = std::num::ParseIntError; fn from_str(value: &str) -> Result { value.parse().map(Self) } } #[derive(Clone, Debug, Eq, PartialEq)] struct TodoTitle(String); impl TodoTitle { fn as_str(&self) -> &str { &self.0 } fn into_string(self) -> String { self.0 } } impl std::fmt::Display for TodoTitle { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } impl std::str::FromStr for TodoTitle { type Err = Infallible; fn from_str(value: &str) -> Result { Ok(Self(value.trim().to_owned())) } } #[hemx::form("new_todo")] struct NewTodo { title: TodoTitle, } #[hemx::form("rename_todo")] struct RenameTodo { id: TodoId, title: TodoTitle, } #[hemx::form("delete_todo")] struct DeleteTodo { id: TodoId, } #[hemx::form("wizard_input")] struct WizardInput { step: String, } #[hemx::form("credentials")] struct Credentials { email: String, password: String, } #[derive(Debug)] struct AppError(String); impl std::fmt::Display for AppError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } impl std::error::Error for AppError {} impl IntoHandlerFailure for AppError { fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure { HandlerFailure::effects( notifications::notifications.set(format!("Could not save todo: {}", self.0)), context, ) } } #[derive(Hemplate)] struct Todos { summary: String, notice: String, rows: Vec, } #[derive(Hemplate)] #[hemplate = "partials"] struct TodoRow { id: TodoId, title: String, } impl hemx::KeyedPartial for TodoRow { fn hemx_key(&self) -> String { self.id.to_string() } } #[derive(Hemplate)] struct Counter; #[derive(Hemplate)] struct Wizard; #[derive(Hemplate)] struct Auth; #[derive(Hemplate)] struct Notifications; #[derive(Hemplate)] struct PageSwap { content: Html, title: &'static str, } #[derive(Hemplate)] struct AppShell { runtime_src: &'static str, body: Html, } #[derive(Hemplate)] #[hemplate = "partials"] struct DocsContent { message: &'static str, } #[tokio::main] async fn main() { let state = Arc::new(ExampleState::default()); let app = Router::new() .route("/", get(home).post(interact)) .route("/docs", get(docs).post(interact)) .route("/events", get(events)) .route(runtime_js_path(), get(runtime)) .with_state(state); let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); println!("hemx v0 examples: http://{addr}"); axum::serve(listener, app).await.unwrap(); } // req: examples/001 async fn home(request: PageRequest) -> impl IntoResponse { request .page_html(all_examples(), shell) .title("hemx v0 examples") .fingerprint(ui::BUILD_FINGERPRINT) } // req: page_swap/002, req: examples/001 async fn docs(request: PageRequest) -> impl IntoResponse { let partial = render_page_swap("Docs", "This page was swapped without a full reload."); request .page_html(partial, shell) .title("Docs") .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() } // req: push/001, req: push/003, req: examples/001 async fn events(Query(params): Query>) -> impl IntoResponse { if params.contains_key("once") { let effect = notifications::notifications.set("Server event #1"); return sse(stream::iter([Ok::<_, Infallible>( effect.into_batch(ui::BUILD_FINGERPRINT), )]) .boxed()); } let batches = stream::unfold(1_u64, |count| async move { tokio::time::sleep(Duration::from_secs(3)).await; let effect = notifications::notifications.set(format!("Server event #{count}")); Some(( Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1, )) }) .boxed(); sse(batches) } #[hemx::app( counter_handlers, todo_handlers, todo_row_handlers, wizard_handlers, auth_handlers )] fn handlers(state: Arc) -> InteractionHandlers { interactions(ui::BUILD_FINGERPRINT) } fn all_examples() -> Html { // req: html_safety/001 req: html_safety/002 req: component/003 Html::join([ counter::page(&Counter), ui::page(&todos_view(&[])), wizard::page(&Wizard), auth::page(&Auth), render_page_swap("Welcome", "Welcome"), notifications::page(&Notifications), ]) } fn shell(body: Html) -> Html { // req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003 ui::page(&AppShell { runtime_src: runtime_js_path(), body, }) } #[hemx::component("counter")] mod counter_handlers { use super::*; #[hemx::handler] async fn increment(State(state): State>) -> impl IntoEffect { // req: examples/001 let mut counter = state.counter.lock().unwrap(); *counter += 1; counter::counter_value.set(*counter) } } #[hemx::component("todos")] mod todo_handlers { use super::*; #[hemx::handler] async fn add_todo( State(state): State>, Form(form): Form, ) -> Result { // req: examples/001 req: canonical_authoring/003 if form.title.as_str().is_empty() { return Ok(( todos::new_todo.error("title", "Title required"), todos::new_todo.focus("title"), todos::summary.set(todo_summary(&state.todos.lock().unwrap())), todos::notice.set("Add a title to create a todo"), )); } if form.title.as_str() == "fail-db" { return Err(AppError("todo store unavailable".to_owned())); } let mut todos = state.todos.lock().unwrap(); let id = todos.last().map_or(1, |todo| todo.id + 1); let title = form.title.into_string(); todos.push(TodoRecord { id, title: title.clone(), }); let summary = todo_summary(&todos); Ok(( todos::todo_row.append(TodoRow { id: TodoId(id), title: title.clone(), }), todos::summary.set(summary), todos::notice.set(format!("Added {title}")), todos::new_todo.clear(), )) } } #[hemx::component("todo_row")] mod todo_row_handlers { use super::*; #[hemx::handler] async fn rename_todo( State(state): State>, Form(form): Form, ) -> impl IntoEffect { rename_todo_effect(state, form) } #[hemx::handler] async fn delete_todo( State(state): State>, Form(form): Form, ) -> impl IntoEffect { delete_todo_effect(state, form) } } #[hemx::component("wizard")] mod wizard_handlers { use super::*; #[hemx::handler] async fn next_step( State(state): State>, Form(form): Form, ) -> impl IntoEffect { // req: examples/001 let _submitted_step = form.step; let mut step = state.wizard_step.lock().unwrap(); *step += 1; wizard::wizard_step.set(format!("Step {}", *step + 1)) } } #[hemx::component("auth")] mod auth_handlers { use super::*; #[hemx::handler] async fn login( State(_state): State>, Form(credentials): Form, ) -> impl IntoEffect { // req: examples/001 let ok = credentials.email == "demo@example.com" && !credentials.password.is_empty(); auth::login_status.set(if ok { "Signed in as demo@example.com" } else { "Try demo@example.com with any password" }) } } fn rename_todo_effect(state: Arc, form: RenameTodo) -> impl IntoEffect { // req: examples/001 req: canonical_authoring/003 if form.title.as_str().is_empty() { return Some(( todo_row::rename_todo_form.focus("title"), todos::notice.set("Add a title before renaming"), )); } let mut todos = state.todos.lock().unwrap(); todos .iter_mut() .find(|todo| todo.id == form.id.0) .map(|todo| { todo.title = form.title.into_string(); ( todos::todo_row.replace(TodoRow { id: form.id, title: todo.title.clone(), }), todos::notice.set("Todo renamed"), ) }) } fn delete_todo_effect(state: Arc, form: DeleteTodo) -> impl IntoEffect { // req: examples/001 req: canonical_authoring/003 let mut todos = state.todos.lock().unwrap(); let before = todos.len(); todos.retain(|todo| todo.id != form.id.0); let deleted = todos.len() != before; let summary = todo_summary(&todos); ( deleted.then(|| todos::todo_row.remove(form.id)), deleted.then(|| todos::summary.set(summary)), deleted.then(|| todos::notice.set("Todo deleted")), ) } fn todos_view(todos: &[TodoRecord]) -> Todos { // req: html_safety/002 req: view/001 req: canonical_authoring/003 Todos { summary: todo_summary(todos), notice: String::new(), rows: todo_rows(todos), } } #[cfg(test)] fn refresh_todo_rows_effect(todos: &[TodoRecord]) -> impl IntoEffect { // req: canonical_authoring/003 let row_updates = todo_rows(todos) .into_iter() .map(|row| todos::todo_row.replace(row)) .collect::>(); ( row_updates, todos::summary.set(todo_summary(todos)), todos::notice.set("Todos refreshed"), ) } fn todo_rows(todos: &[TodoRecord]) -> Vec { todos .iter() .map(|todo| TodoRow { id: TodoId(todo.id), title: todo.title.clone(), }) .collect() } fn todo_summary(todos: &[TodoRecord]) -> String { match todos.len() { 0 => "No todos".to_owned(), 1 => "1 todo".to_owned(), count => format!("{count} todos"), } } fn render_page_swap(title: &'static str, message: &'static str) -> Html { // req: html_safety/002 req: view/001 req: component/003 page_swap::page(&PageSwap { content: render_docs_content(message), title, }) } fn render_docs_content(message: &'static str) -> Html { // req: html_safety/002 req: view/001 req: component/003 ui::page(&DocsContent { message }) } #[cfg(test)] mod tests { use super::*; use hemx_test::{ article_selector, document_title_selector, escaped_markup_selector, heading_selector, inspect_batch, keyed_items_selector, keyed_selector, list_item_selector, page_nav_link_selector, prose_selector, root_element_selector, runtime_script_selector, }; use scraper::{Html, Selector}; fn selector(value: &str) -> Selector { Selector::parse(value).expect("test selector parses") } 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())), ) } // req: html_safety/002 req: view/001 req: test/005 #[test] fn shell_is_rendered_by_a_hemplate_view() { let html = shell(render_page_swap("Welcome", "Welcome")); let document = Html::parse_document(html.as_str()); assert_eq!( document .select(&selector(document_title_selector())) .next() .map(|title| title.text().collect::()), Some("hemx v0 examples".to_owned()) ); assert_eq!( document .select(&selector(runtime_script_selector())) .count(), 1 ); assert_eq!( document .select(&selector(&root_element_selector("main", "docs"))) .count(), 1 ); assert!( document .root_element() .text() .all(|text| !text.contains("{+=")), "hemplate insertion markers must not leak into rendered text" ); } // req: html_safety/002 req: view/001 req: test/005 #[test] fn docs_content_payload_is_rendered_by_a_hemplate_view() { let html = render_docs_content("This content came from a generated update response."); let document = Html::parse_fragment(html.as_str()); assert_eq!( document .select(&selector(&heading_selector("", 1))) .next() .map(|heading| heading.text().collect::()), Some("Docs".to_owned()) ); assert_eq!( document .select(&selector(&prose_selector(""))) .next() .map(|paragraph| paragraph.text().collect::()), Some("This content came from a generated update response.".to_owned()) ); } // req: html_safety/002 req: view/001 req: test/005 #[test] fn docs_page_partial_is_rendered_by_a_hemplate_view() { let html = render_page_swap("Docs", "This page was swapped without a full reload."); let document = Html::parse_fragment(html.as_str()); assert_eq!( document .select(&selector(&root_element_selector("main", "docs"))) .count(), 1 ); assert_eq!(document.select(&selector(article_selector())).count(), 1); assert_eq!( document .select(&selector(&page_nav_link_selector("/docs"))) .count(), 1 ); assert_eq!( document .select(&selector(&heading_selector("article", 1))) .next() .map(|heading| heading.text().collect::()), Some("Docs".to_owned()) ); } // req: html_safety/002 req: view/001 req: test/005 #[test] fn todos_payload_is_rendered_by_a_hemplate_view() { let todos = vec![TodoRecord { id: 7, title: "Ship v0".to_owned(), }]; let html = ui::page(&todos_view(&todos)); let document = Html::parse_fragment(html.as_str()); let rows = document .select(&selector(&keyed_items_selector("li"))) .collect::>(); assert_eq!(rows.len(), 1); let row = document .select(&selector(&keyed_selector("li", 7))) .next() .expect("generated keyed row"); assert!(row.text().collect::().contains("Ship v0")); assert!(document .select(&selector(&escaped_markup_selector("b"))) .next() .is_none()); assert_eq!( document .select(&selector("[data-hemx-error-for='title']")) .count(), 2, "add and rename forms expose generated field-error targets" ); assert_eq!( document.select(&selector("[data-hemx-error]")).count(), 1, "the app exposes one root-scoped transport error outlet" ); } // req: canonical_authoring/003 req: test/005 #[test] fn reusable_todo_partial_supports_dynamic_replace_batches() { let todos = vec![ TodoRecord { id: 7, title: "Ship v0".to_owned(), }, TodoRecord { id: 8, title: "Write docs".to_owned(), }, ]; let initial = ui::page(&todos_view(&todos)); let document = Html::parse_fragment(initial.as_str()); assert_eq!( document .select(&selector(&keyed_items_selector("li"))) .count(), 2 ); let refresh = inspect_batch(refresh_todo_rows_effect(&todos).into_batch(ui::BUILD_FINGERPRINT)); assert_eq!(refresh.op_count(), 4); assert!(refresh.replaces_keyed_html_containing(todos::todo_row, "7", "Ship v0")); assert!(refresh.replaces_keyed_html_containing(todos::todo_row, "8", "Write docs")); assert!(refresh.updates_text(todos::summary)); assert!(refresh.updates_text(todos::notice)); } // req: html_safety/002 req: view/001 req: test/005 #[test] fn empty_todos_payload_is_rendered_by_a_hemplate_view() { let html = ui::page(&todos_view(&[])); let document = Html::parse_fragment(html.as_str()); let rows = document .select(&selector(&list_item_selector(""))) .collect::>(); assert_eq!(rows.len(), 0); } // req: examples/001 req: page_swap/002 req: page_swap/003 req: component/005 req: public_api/003 req: test/005 #[tokio::test] async fn registry_dispatches_generated_keyed_crud_effects() { let state = Arc::new(ExampleState::default()); let counter = inspect_batch( InteractionRequest::from(form(counter::increment, &[])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert_eq!(*state.counter.lock().unwrap(), 1); assert!(counter.updates_text(counter::counter_value)); assert!(counter.payload_contains("1")); let validation = inspect_batch( InteractionRequest::from(form(todos::add_todo, &[("title", " ")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert!(state.todos.lock().unwrap().is_empty()); assert!(validation.payload_contains("Title required")); let store_failure = inspect_batch( InteractionRequest::from(form(todos::add_todo, &[("title", "fail-db")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert!(state.todos.lock().unwrap().is_empty()); assert!(store_failure.updates_text(notifications::notifications)); assert!(store_failure.payload_contains("todo store unavailable")); let add = inspect_batch( InteractionRequest::from(form(todos::add_todo, &[("title", "Ship v0")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert_eq!(state.todos.lock().unwrap()[0].title, "Ship v0"); assert_eq!(add.op_count(), 4); assert!(add.inserts_html_containing(todos::todo_row, "1", "Ship v0")); assert!(add.updates_text(todos::summary)); assert!(add.updates_text(todos::notice)); assert!(add.payload_contains("Added Ship v0")); assert!(add.resets_form(todos::new_todo)); let rename_validation = inspect_batch( InteractionRequest::from(form( todo_row::rename_todo, &[("id", "1"), ("title", " ")], )) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert_eq!(state.todos.lock().unwrap()[0].title, "Ship v0"); assert!(rename_validation.payload_contains("Add a title before renaming")); assert!(rename_validation.updates_text(todos::notice)); let rename = inspect_batch( InteractionRequest::from(form( todo_row::rename_todo, &[("id", "1"), ("title", "Ship 1.0")], )) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert_eq!(state.todos.lock().unwrap()[0].title, "Ship 1.0"); assert_eq!(rename.op_count(), 2); assert!(rename.replaces_keyed_html_containing(todos::todo_row, "1", "Ship 1.0")); assert!(rename.updates_text(todos::notice)); let delete = inspect_batch( InteractionRequest::from(form(todo_row::delete_todo, &[("id", "1")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert!(state.todos.lock().unwrap().is_empty()); assert_eq!(delete.op_count(), 3); assert!(delete.removes_key(todos::todo_row, "1")); assert!(delete.updates_text(todos::summary)); assert!(delete.updates_text(todos::notice)); let missing_delete = inspect_batch( InteractionRequest::from(form(todo_row::delete_todo, &[("id", "99")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert!(missing_delete.is_empty()); let wizard = inspect_batch( InteractionRequest::from(form(wizard::next_step, &[("step", "1")])) .dispatch_async(handlers(state.clone())) .await .unwrap() .batch, ); assert!(wizard.updates_text(wizard::wizard_step)); assert!(wizard.payload_contains("Step 2")); let auth = inspect_batch( InteractionRequest::from(form( auth::login, &[("email", "demo@example.com"), ("password", "secret")], )) .dispatch_async(handlers(state)) .await .unwrap() .batch, ); assert!(auth.updates_text(auth::login_status)); assert!(auth.payload_contains("Signed in as demo@example.com")); } }