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:
@@ -54,6 +54,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
|
||||
- hemx core stays small: effects, typed ids, registries, and wire schema only.
|
||||
- Routing, auth, sessions, transport, transitions, sync, and storage belong in integration/user crates.
|
||||
- Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes.
|
||||
- `examples/html_examples` is the copy-paste HTML pattern gallery for htmx-style examples; keep exact htmx URL slugs visible while translating behavior to boring `.heml`, generated resources, and server-owned Rust state. req: htmx_equivalents/001 req: htmx_equivalents/002 req: examples/001
|
||||
- Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands. req: ceremony/005 req: ceremony/006
|
||||
- The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition.
|
||||
- The stable public `.heml` authoring surface lives in `docs/hemplate-syntax.md`; Hemlate examples must use that real hemplate syntax, not Vue/Handlebars sketches.
|
||||
|
||||
Generated
+14
@@ -538,6 +538,20 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hemx-html-examples"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"hemplate",
|
||||
"hemx",
|
||||
"hemx-axum",
|
||||
"hemx-build",
|
||||
"hemx-test",
|
||||
"scraper",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hemx-js"
|
||||
version = "0.1.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/kanban", "examples/techdemo", "examples/saas", "examples/workout"]
|
||||
members = ["hemx", "hemx-core", "hemx-host", "hemx-derive", "hemx-js", "hemx-axum", "hemx-build", "hemx-test", "hemx-lsp", "hemx-xtask", "examples/v0", "examples/html_examples", "examples/kanban", "examples/techdemo", "examples/saas", "examples/workout"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -156,6 +156,11 @@ See `docs/versioning.md`.
|
||||
PATH`; it includes a page, form, keyed row partial, notice slot, handlers,
|
||||
tests, and generated append/replace/remove/dynamic-batch updates. Start here.
|
||||
req: ceremony/005
|
||||
- `examples/html_examples`: copy-paste HTML pattern gallery for htmx-style
|
||||
CRUD/form/search/load UX patterns. It proves the hemx idiom for click-to-edit,
|
||||
edit row, delete row, inline validation, click-to-load, and active search with
|
||||
`.heml`, generated resources, server-owned Rust state, and tiny runtime
|
||||
behavior. req: htmx_equivalents/001 req: htmx_equivalents/002 req: examples/001
|
||||
- `examples/saas`: compile-tested v1 tutorial app covering auth/session,
|
||||
CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain
|
||||
CSS, and one explicit island without provider-heavy platform scope. Read the
|
||||
|
||||
+2
-2
@@ -185,7 +185,7 @@ client app state framework.
|
||||
001 hemx replaces common HTMX use-cases through typed equivalents, not HTMX syntax.
|
||||
|
||||
### req: htmx/002
|
||||
002 Easy equivalents must exist for: generated target replacement, append/prepend/remove, boosted links/forms, page swap, form submit, loading indicators, confirmation, debounce/throttle, polling, history navigation, multi-target updates, response events, SSE/push, validation errors, modals, toasts, table rows, SVG fragments, and form error regions.
|
||||
002 Easy equivalents must exist for: generated target replacement, append/prepend/remove, boosted links/forms, page swap, form submit, loading indicators, confirmation, debounce/throttle, polling, history navigation, multi-target updates, response events, SSE/push, validation errors, modals, toasts, table rows, SVG fragments, and form error regions. The copy-paste HTML pattern gallery must cover the core CRUD/form/search/load patterns through boring `.heml`, generated targets/forms/handles, and server-owned Rust state before adding plugin-shaped or browser-policy-heavy patterns. [north_star]
|
||||
|
||||
### req: htmx/003
|
||||
003 hemx core deliberately does not clone selector-based HTMX features: `hx-target` selectors, `hx-select`, `hx-include` selectors, `closest/find/this` target strings, or trigger mini-languages. Equivalent patterns use generated targets, typed params, forms, explicit handlers, and page/push adapters around partial swaps.
|
||||
@@ -813,7 +813,7 @@ what a valid business email is.
|
||||
## examples
|
||||
|
||||
### req: examples/001
|
||||
001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; the workout example is the phone-first local-first product exemplar for commands/events/projections plus a complete session flow with rest/next state, progress, undo recovery, finish/export, recoverable invalid input and host/replay failures, and host boundary results returned through app code, and must be runnable with the documented cargo command; local-first kanban is a north-star milestone example. The full techdemo may include an opaque leaf-widget island that communicates through `Effect::event`, without moving island mechanics into hemx core.
|
||||
001 The repository must contain canonical examples that act as API tests. v0 examples are counter, todo CRUD, form wizard, docs-site page swap, auth action, SSE notifications, and keyed todo list; `examples/html_examples` is the copy-paste HTML pattern gallery proving htmx-style CRUD/form/search/load patterns map to boring `.heml`, generated resources, and server-owned Rust state; the workout example is the phone-first local-first product exemplar for commands/events/projections plus a complete session flow with rest/next state, progress, undo recovery, finish/export, recoverable invalid input and host/replay failures, and host boundary results returned through app code, and must be runnable with the documented cargo command; local-first kanban is a north-star milestone example. The full techdemo may include an opaque leaf-widget island that communicates through `Effect::event`, without moving island mechanics into hemx core.
|
||||
|
||||
### req: examples/002
|
||||
002 Each example must have a maximum ceremony budget. The counter example must fit in under 50 lines of user-authored Rust plus one template. Todo CRUD must fit in under 150 lines excluding model definitions.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "hemx-html-examples"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.7"
|
||||
hemplate = { path = "../../../hemplate/hemplate" }
|
||||
hemx = { path = "../../hemx" }
|
||||
hemx-axum = { path = "../../hemx-axum" }
|
||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] }
|
||||
|
||||
[dev-dependencies]
|
||||
scraper = "0.23"
|
||||
hemx-test = { path = "../../hemx-test" }
|
||||
|
||||
[build-dependencies]
|
||||
hemx-build = { path = "../../hemx-build" }
|
||||
@@ -0,0 +1,37 @@
|
||||
# hemx HTML examples
|
||||
|
||||
A copy-pasteable pattern gallery for the boring HTML UX patterns popularized by
|
||||
htmx. The point is not to clone htmx attributes; it is to show the hemx idiom:
|
||||
plain `.heml`, generated resources, server-owned Rust state, keyed partials, and
|
||||
tiny runtime behavior. req: htmx_equivalents/001 req: htmx_equivalents/002 req: examples/001
|
||||
|
||||
Run it:
|
||||
|
||||
```sh
|
||||
cargo run -p hemx-html-examples
|
||||
```
|
||||
|
||||
Open <http://127.0.0.1:3029>.
|
||||
|
||||
## Pattern matrix
|
||||
|
||||
Names match the htmx example URL slug exactly, e.g. `modal-custom` from
|
||||
`https://htmx.org/examples/modal-custom/`. Rust resource names use normal
|
||||
identifier spelling only where the language requires it.
|
||||
|
||||
| htmx example slug | hemx idiom | Proof anchor |
|
||||
| --- | --- | --- |
|
||||
| `click-to-edit` | A read view and edit form are the same generated `contact_card` partial; the server toggles `editing` and returns `gallery::contact_card.replace(...)`. | `templates/partials/contact_card.heml`, `contact_card_handlers::edit_contact`, `save_contact` |
|
||||
| `edit-row` | A table row is a keyed `.heml` partial with generated edit/save forms; no selector target strings. | `templates/partials/editable_row.heml`, `editable_row_handlers::edit_row`, `save_row` |
|
||||
| `delete-row` | Server state removes the row and returns `gallery::editable_row.remove(id)`. | `editable_row_handlers::delete_row` |
|
||||
| `inline-validation` | A generated form reports field failure with `validate_email_form.error(...)`, focuses the field, and updates status text. | `templates/gallery.heml`, `gallery_handlers::validate_email` |
|
||||
| `click-to-load` | The server owns the loaded count and returns generated keyed `loaded_row` replacements plus status text. | `gallery_handlers::load_more`, `LoadedRow` |
|
||||
| `active-search` | The search form posts a query; the server derives result rows and replaces generated `search_result` keyed partials. | `gallery_handlers::search`, `SearchResult` |
|
||||
|
||||
## Boundary
|
||||
|
||||
This gallery deliberately skips the trophy-list patterns that would need a real
|
||||
product reason or a browser/plugin policy first: drag-and-drop, modal libraries,
|
||||
file upload progress, infinite scroll, web components, and authentication token
|
||||
refresh. Add those only as vertical slices when hemx has a boring product-owned
|
||||
answer, not as demo-only JavaScript.
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
hemx_build::app().run().unwrap();
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hemx HTML examples</title>
|
||||
<script defer="defer" +src="self.runtime_src"></script>
|
||||
</head>
|
||||
<body>
|
||||
{+= self.body =+}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
<main data-hemx-root="gallery">
|
||||
<header>
|
||||
<p>Copy-pasteable hemx HTML patterns</p>
|
||||
<h1>HTML UX pattern gallery</h1>
|
||||
<p>Server-owned Rust state, boring .heml, generated resources, and tiny runtime behavior.</p>
|
||||
</header>
|
||||
|
||||
<section id="click-to-edit" data-htmx-example="click-to-edit" aria-labelledby="click-to-edit-heading">
|
||||
<h2 id="click-to-edit-heading">click-to-edit</h2>
|
||||
<div data-hemx-slot="contact_card">
|
||||
<template h-for="contact in &self.contacts" h-key="contact.id">
|
||||
{+ contact +}
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="edit-row" data-htmx-example="edit-row" aria-labelledby="edit-row-heading">
|
||||
<h2 id="edit-row-heading">edit-row</h2>
|
||||
<p id="delete-row" data-htmx-example="delete-row">delete-row uses the same keyed row partial and a generated remove effect.</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Task</th><th>Actions</th></tr>
|
||||
</thead>
|
||||
<tbody data-hemx-slot="editable_row">
|
||||
<template h-for="row in &self.rows" h-key="row.id">
|
||||
{+ row +}
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section id="inline-validation" data-htmx-example="inline-validation" aria-labelledby="validation-heading">
|
||||
<h2 id="validation-heading">inline-validation</h2>
|
||||
<form data-hemx-handle="validate_email" data-hemx-form="validate_email">
|
||||
<label>Email <input name="email" +value="self.email" required="required"></label>
|
||||
<button type="submit">Validate</button>
|
||||
<p data-hemx-error-for="email"></p>
|
||||
<p data-hemx-slot="email_status">{+ self.email_status +}</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="click-to-load" data-htmx-example="click-to-load" aria-labelledby="load-heading">
|
||||
<h2 id="load-heading">click-to-load</h2>
|
||||
<ul data-hemx-slot="loaded_row">
|
||||
<template h-for="row in &self.loaded_rows" h-key="row.id">
|
||||
{+ row +}
|
||||
</template>
|
||||
</ul>
|
||||
<form data-hemx-handle="load_more" data-hemx-form="load_more">
|
||||
<input type="hidden" name="request" value="more">
|
||||
<button type="submit">Load more</button>
|
||||
</form>
|
||||
<p data-hemx-slot="load_status">{+ self.load_status +}</p>
|
||||
</section>
|
||||
|
||||
<section id="active-search" data-htmx-example="active-search" aria-labelledby="search-heading">
|
||||
<h2 id="search-heading">active-search</h2>
|
||||
<form data-hemx-handle="search" data-hemx-form="search">
|
||||
<label>Search <input name="query" +value="self.query"></label>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<p data-hemx-slot="search_status">{+ self.search_status +}</p>
|
||||
<ul data-hemx-slot="search_result">
|
||||
<template h-for="result in &self.search_results" h-key="result.id">
|
||||
{+ result +}
|
||||
</template>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
@@ -0,0 +1,15 @@
|
||||
<article +data-key="self.id">
|
||||
<div h-if="!self.editing">
|
||||
<h3>{+ self.name +}</h3>
|
||||
<p>{+ self.email +}</p>
|
||||
<form data-hemx-handle="edit_contact" data-hemx-form="edit_contact">
|
||||
<button type="submit" name="id" +value="self.id">Edit</button>
|
||||
</form>
|
||||
</div>
|
||||
<form h-if="self.editing" data-hemx-handle="save_contact" data-hemx-form="save_contact">
|
||||
<input type="hidden" name="id" +value="self.id">
|
||||
<label>Name <input name="name" +value="self.name" required="required"></label>
|
||||
<label>Email <input name="email" +value="self.email" required="required"></label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</article>
|
||||
@@ -0,0 +1,14 @@
|
||||
<tr +data-key="self.id">
|
||||
<td h-if="!self.editing">{+ self.title +}</td>
|
||||
<td h-if="!self.editing">
|
||||
<form data-hemx-handle="edit_row" data-hemx-form="edit_row"><button type="submit" name="id" +value="self.id">Edit</button></form>
|
||||
<form data-hemx-handle="delete_row" data-hemx-form="delete_row"><button type="submit" name="id" +value="self.id">Delete</button></form>
|
||||
</td>
|
||||
<td h-if="self.editing" colspan="2">
|
||||
<form data-hemx-handle="save_row" data-hemx-form="save_row">
|
||||
<input type="hidden" name="id" +value="self.id">
|
||||
<label>Task <input name="title" +value="self.title" required="required"></label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -0,0 +1 @@
|
||||
<li +data-key="self.id">{+ self.title +}</li>
|
||||
@@ -0,0 +1 @@
|
||||
<li +data-key="self.id">{+ self.label +}</li>
|
||||
Reference in New Issue
Block a user