1008 lines
30 KiB
Rust
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"));
|
|
}
|
|
}
|