fix(html-examples): tighten email and progress feedback
Require a complete dotted email before inline validation reports success, preserve the typed email after valid revalidation, and update progress via the actual progress element so visible progress is not stuck at 100%. req: htmx_equivalents/005 req: examples/001 req: test/006
This commit is contained in:
@@ -170,6 +170,23 @@ impl hemx::KeyedPartial for ContactCard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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\">{}% complete</progress>",
|
||||||
|
self.percent, self.percent
|
||||||
|
)
|
||||||
|
.expect("write to String cannot fail");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Hemplate, Clone)]
|
#[derive(Hemplate, Clone)]
|
||||||
struct EditableRow {
|
struct EditableRow {
|
||||||
id: Id,
|
id: Id,
|
||||||
@@ -372,6 +389,19 @@ fn lazy_panel_text(loads: usize) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
fn contact_card_view(record: ContactRecord) -> ContactCard {
|
||||||
ContactCard {
|
ContactCard {
|
||||||
id: Id(record.id),
|
id: Id(record.id),
|
||||||
@@ -451,7 +481,7 @@ mod gallery_handlers {
|
|||||||
let _ = input.request;
|
let _ = input.request;
|
||||||
let mut progress = state.progress.lock().unwrap();
|
let mut progress = state.progress.lock().unwrap();
|
||||||
*progress = (*progress + 25).min(100);
|
*progress = (*progress + 25).min(100);
|
||||||
gallery::progress_meter.set(format!("{}% complete", *progress))
|
gallery::progress_meter.replace(&ProgressMeter { percent: *progress })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hemx::handler]
|
#[hemx::handler]
|
||||||
@@ -521,20 +551,20 @@ mod gallery_handlers {
|
|||||||
Form(input): Form<ValidateEmail>,
|
Form(input): Form<ValidateEmail>,
|
||||||
) -> impl IntoEffect {
|
) -> impl IntoEffect {
|
||||||
let email = input.email.trim().to_owned();
|
let email = input.email.trim().to_owned();
|
||||||
if !email.contains('@') {
|
if !is_demo_email(&email) {
|
||||||
*state.email.lock().unwrap() = email;
|
*state.email.lock().unwrap() = email;
|
||||||
*state.email_status.lock().unwrap() = "Email needs an @ sign".into();
|
*state.email_status.lock().unwrap() = "Email needs a name and dotted domain".into();
|
||||||
return vec![
|
return vec![
|
||||||
gallery::validate_email_form.focus("email"),
|
gallery::validate_email_form.focus("email"),
|
||||||
gallery::validate_email_form.error("email", "Use a real email address"),
|
gallery::validate_email_form.error("email", "Use a real email address"),
|
||||||
gallery::email_status.set("Email needs an @ sign"),
|
gallery::email_status.set("Email needs a name and dotted domain"),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
*state.email.lock().unwrap() = email.clone();
|
*state.email.lock().unwrap() = email.clone();
|
||||||
*state.email_status.lock().unwrap() = format!("{email} is valid");
|
*state.email_status.lock().unwrap() = format!("{email} is valid");
|
||||||
vec![
|
vec![
|
||||||
|
gallery::validate_email_form.error("email", ""),
|
||||||
gallery::email_status.set(format!("{email} is valid")),
|
gallery::email_status.set(format!("{email} is valid")),
|
||||||
gallery::validate_email_form.clear(),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,7 +800,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert!(html.contains("data-hemx-form=\"validate_email\""));
|
assert!(html.contains("data-hemx-form=\"validate_email\""));
|
||||||
assert!(html.contains("data-hemx-revealed=\"true\""));
|
assert!(html.contains("data-hemx-revealed=\"true\""));
|
||||||
assert!(html.contains("data-hemx-interval=\"500ms\""));
|
assert!(html.contains("data-hemx-handle=\"tick_progress\""));
|
||||||
assert!(html.contains("data-hemx-slot=\"loaded_row\""));
|
assert!(html.contains("data-hemx-slot=\"loaded_row\""));
|
||||||
assert!(html.contains("data-hemx-slot=\"infinite_row\""));
|
assert!(html.contains("data-hemx-slot=\"infinite_row\""));
|
||||||
assert!(html.contains("data-hemx-slot=\"value_option\""));
|
assert!(html.contains("data-hemx-slot=\"value_option\""));
|
||||||
@@ -831,8 +861,19 @@ mod tests {
|
|||||||
.batch,
|
.batch,
|
||||||
);
|
);
|
||||||
assert!(invalid.payload_contains("Use a real email address"));
|
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));
|
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(
|
let valid = inspect_batch(
|
||||||
InteractionRequest::from(form(
|
InteractionRequest::from(form(
|
||||||
gallery::validate_email,
|
gallery::validate_email,
|
||||||
@@ -845,6 +886,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(valid.payload_contains("xyz@example.com is valid"));
|
assert!(valid.payload_contains("xyz@example.com is valid"));
|
||||||
assert!(!valid.payload_contains("Use a real email address"));
|
assert!(!valid.payload_contains("Use a real email address"));
|
||||||
|
assert!(!valid.payload_contains("hemx:form-reset"));
|
||||||
assert!(valid.updates_text(gallery::email_status));
|
assert!(valid.updates_text(gallery::email_status));
|
||||||
|
|
||||||
let infinite = inspect_batch(
|
let infinite = inspect_batch(
|
||||||
@@ -864,8 +906,7 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.batch,
|
.batch,
|
||||||
);
|
);
|
||||||
assert!(progress.updates_text(gallery::progress_meter));
|
assert!(progress.payload_contains("<progress value=\"25\" max=\"100\">25% complete</progress>"));
|
||||||
assert!(progress.payload_contains("25% complete"));
|
|
||||||
|
|
||||||
let values = inspect_batch(
|
let values = inspect_batch(
|
||||||
InteractionRequest::from(form(gallery::choose_category, &[("category", "numbers")]))
|
InteractionRequest::from(form(gallery::choose_category, &[("category", "numbers")]))
|
||||||
|
|||||||
@@ -78,12 +78,13 @@
|
|||||||
|
|
||||||
<section id="progress-bar" data-htmx-example="progress-bar" aria-labelledby="progress-heading">
|
<section id="progress-bar" data-htmx-example="progress-bar" aria-labelledby="progress-heading">
|
||||||
<h2 id="progress-heading">progress-bar</h2>
|
<h2 id="progress-heading">progress-bar</h2>
|
||||||
<form data-hemx-handle="tick_progress" data-hemx-form="tick_progress" data-hemx-interval="500ms">
|
<form data-hemx-handle="tick_progress" data-hemx-form="tick_progress">
|
||||||
<input type="hidden" name="request" value="tick">
|
<input type="hidden" name="request" value="tick">
|
||||||
<button type="submit">Tick progress</button>
|
<button type="submit">Tick progress</button>
|
||||||
</form>
|
</form>
|
||||||
|
<p data-hemx-slot="progress_meter">
|
||||||
<progress max="100" +value="self.progress">{+ self.progress_label +}</progress>
|
<progress max="100" +value="self.progress">{+ self.progress_label +}</progress>
|
||||||
<p data-hemx-slot="progress_meter">{+ self.progress_label +}</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="value-select" data-htmx-example="value-select" aria-labelledby="value-heading">
|
<section id="value-select" data-htmx-example="value-select" aria-labelledby="value-heading">
|
||||||
|
|||||||
@@ -223,13 +223,13 @@ fn cdp_assert(name: &str, script: &str) -> Result<(), ExitCode> {
|
|||||||
|
|
||||||
const CLICK_TO_EDIT_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const edit=Array.from(document.querySelectorAll("form"))[0]; edit.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,600)); const save=Array.from(document.querySelectorAll("form")).find(f=>f.elements.name&&f.elements.name.value==="Ada Lovelace"); save.elements.name.value="Ada Byron"; save.elements.email.value="ada.byron@example.com"; save.querySelector("button[type=submit],input[type=submit]").click(); await new Promise(r=>setTimeout(r,800)); if(!(text().includes("Ada Byron")&&!text().includes("Ada Lovelace ada@example.com"))) throw new Error("click-to-edit did not save"); return true;})()"#;
|
const CLICK_TO_EDIT_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const edit=Array.from(document.querySelectorAll("form"))[0]; edit.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,600)); const save=Array.from(document.querySelectorAll("form")).find(f=>f.elements.name&&f.elements.name.value==="Ada Lovelace"); save.elements.name.value="Ada Byron"; save.elements.email.value="ada.byron@example.com"; save.querySelector("button[type=submit],input[type=submit]").click(); await new Promise(r=>setTimeout(r,800)); if(!(text().includes("Ada Byron")&&!text().includes("Ada Lovelace ada@example.com"))) throw new Error("click-to-edit did not save"); return true;})()"#;
|
||||||
const EDIT_ROW_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const edit=Array.from(document.querySelectorAll("form"))[1]; edit.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,600)); const save=Array.from(document.querySelectorAll("form")).find(f=>f.elements.title); save.elements.title.value="Write dynamic HTML"; save.querySelector("button[type=submit],input[type=submit]").click(); await new Promise(r=>setTimeout(r,800)); if(!text().includes("Write dynamic HTML")) throw new Error("edit-row did not save"); return true;})()"#;
|
const EDIT_ROW_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const edit=Array.from(document.querySelectorAll("form"))[1]; edit.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,600)); const save=Array.from(document.querySelectorAll("form")).find(f=>f.elements.title); save.elements.title.value="Write dynamic HTML"; save.querySelector("button[type=submit],input[type=submit]").click(); await new Promise(r=>setTimeout(r,800)); if(!text().includes("Write dynamic HTML")) throw new Error("edit-row did not save"); return true;})()"#;
|
||||||
const INLINE_VALIDATION_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.getAttribute("data-hemx-on")==="input"); const input=f.elements.email; input.value="wrong"; input.dispatchEvent(new InputEvent("input",{bubbles:true,inputType:"insertText",data:"g"})); await new Promise(r=>setTimeout(r,700)); const afterBad=document.body.textContent; input.value="xyz@example.com"; input.dispatchEvent(new InputEvent("input",{bubbles:true,inputType:"insertText",data:"m"})); await new Promise(r=>setTimeout(r,700)); const afterGood=document.body.textContent; if(!(afterBad.includes("Email needs an @ sign")&&!afterGood.includes("Email needs an @ sign")&&afterGood.includes("xyz@example.com is valid"))) throw new Error("inline validation did not recover"); return true;})()"#;
|
const INLINE_VALIDATION_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.getAttribute("data-hemx-on")==="input"); const input=f.elements.email; input.value="wrong"; input.dispatchEvent(new InputEvent("input",{bubbles:true,inputType:"insertText",data:"g"})); await new Promise(r=>setTimeout(r,700)); const afterBad=document.body.textContent; input.value="qwdqdqwd@"; input.dispatchEvent(new InputEvent("input",{bubbles:true,inputType:"insertText",data:"@"})); await new Promise(r=>setTimeout(r,700)); if(document.body.textContent.includes("qwdqdqwd@ is valid")) throw new Error("partial email was accepted"); if(input.value!=="qwdqdqwd@") throw new Error("inline validation reset partial email"); input.value="xyz@example.com"; input.dispatchEvent(new InputEvent("input",{bubbles:true,inputType:"insertText",data:"m"})); await new Promise(r=>setTimeout(r,700)); const afterGood=document.body.textContent; if(!(afterBad.includes("Email needs a name and dotted domain")&&!afterGood.includes("Email needs a name and dotted domain")&&afterGood.includes("xyz@example.com is valid")&&input.value==="xyz@example.com")) throw new Error("inline validation did not recover"); return true;})()"#;
|
||||||
const ACTIVE_SEARCH_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.elements.query); f.elements.query.value="beta"; f.dispatchEvent(new Event("submit",{bubbles:true,cancelable:true})); await new Promise(r=>setTimeout(r,900)); const results=Array.from(document.querySelectorAll("[data-sid=\"1037530521\"]")).map(e=>e.textContent.trim()); if(!(results.length===2&&results.every(t=>t==="Beta"))) throw new Error("active search did not filter rows"); return true;})()"#;
|
const ACTIVE_SEARCH_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.elements.query); f.elements.query.value="beta"; f.dispatchEvent(new Event("submit",{bubbles:true,cancelable:true})); await new Promise(r=>setTimeout(r,900)); const results=Array.from(document.querySelectorAll("[data-sid=\"1037530521\"]")).map(e=>e.textContent.trim()); if(!(results.length===2&&results.every(t=>t==="Beta"))) throw new Error("active search did not filter rows"); return true;})()"#;
|
||||||
const DELETE_ROW_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const del=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.trim()==="Delete"&&Array.from(f.elements).some(e=>e.name==="id"&&e.value==="1")); del.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(text().includes("Review content")) throw new Error("delete row did not remove row"); return true;})()"#;
|
const DELETE_ROW_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const del=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.trim()==="Delete"&&Array.from(f.elements).some(e=>e.name==="id"&&e.value==="1")); del.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(text().includes("Review content")) throw new Error("delete row did not remove row"); return true;})()"#;
|
||||||
const LAZY_LOAD_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Load lazy content")); const panel=()=>f.nextElementSibling; const before=panel().textContent.trim(); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); const after=panel().textContent.trim(); if(!(after.includes("Lazy content loaded by server update #")&&after!==before)) throw new Error("lazy load did not visibly update"); return true;})()"#;
|
const LAZY_LOAD_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Load lazy content")); const panel=()=>f.nextElementSibling; const before=panel().textContent.trim(); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); const after=panel().textContent.trim(); if(!(after.includes("Lazy content loaded by server update #")&&after!==before)) throw new Error("lazy load did not visibly update"); return true;})()"#;
|
||||||
const CLICK_TO_LOAD_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Load more")); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(!(text().includes("Loaded row 3")&&text().includes("Loaded row 4"))) throw new Error("click-to-load did not append rows"); return true;})()"#;
|
const CLICK_TO_LOAD_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Load more")); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(!(text().includes("Loaded row 3")&&text().includes("Loaded row 4"))) throw new Error("click-to-load did not append rows"); return true;})()"#;
|
||||||
const INFINITE_SCROLL_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Reveal more rows")); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(!(text().includes("Loaded row 4")&&text().includes("Loaded row 6"))) throw new Error("infinite scroll did not append rows"); return true;})()"#;
|
const INFINITE_SCROLL_SMOKE: &str = r#"(async()=>{const text=()=>document.body.textContent.replace(/\s+/g," "); const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Reveal more rows")); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(!(text().includes("Loaded row 4")&&text().includes("Loaded row 6"))) throw new Error("infinite scroll did not append rows"); return true;})()"#;
|
||||||
const PROGRESS_SMOKE: &str = r#"(async()=>{await new Promise(r=>setTimeout(r,700)); const progress=document.querySelector("progress"); if(!(progress&&progress.textContent.includes("% complete")&&progress.textContent!=="0% complete")) throw new Error("progress did not tick"); return true;})()"#;
|
const PROGRESS_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.textContent.includes("Tick progress")); const text=()=>document.querySelector("progress").textContent.trim(); const before=text(); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); const after=text(); if(!(after!==before&&after!=="100% complete"&&after.endsWith("% complete"))) throw new Error("progress click did not visibly tick"); return true;})()"#;
|
||||||
const VALUE_SELECT_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.elements.category); f.elements.category.value="numbers"; f.elements.category.dispatchEvent(new Event("change",{bubbles:true,cancelable:true})); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); const options=Array.from(document.querySelectorAll("select[name=value] option")).map(option=>option.textContent.trim()); if(!(options.includes("One")&&options.includes("Two")&&!options.includes("Alpha"))) throw new Error("value select did not replace options"); return true;})()"#;
|
const VALUE_SELECT_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.elements.category); f.elements.category.value="numbers"; f.elements.category.dispatchEvent(new Event("change",{bubbles:true,cancelable:true})); f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); const options=Array.from(document.querySelectorAll("select[name=value] option")).map(option=>option.textContent.trim()); if(!(options.includes("One")&&options.includes("Two")&&!options.includes("Alpha"))) throw new Error("value select did not replace options"); return true;})()"#;
|
||||||
const RESET_INPUT_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.elements.message); f.elements.message.value="hello reset"; f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(!(document.body.textContent.includes("Sent: hello reset")&&f.elements.message.value==="")) throw new Error("reset user input did not clear field"); return true;})()"#;
|
const RESET_INPUT_SMOKE: &str = r#"(async()=>{const f=Array.from(document.querySelectorAll("form")).find(f=>f.elements.message); f.elements.message.value="hello reset"; f.querySelector("button,input[type=submit]").click(); await new Promise(r=>setTimeout(r,700)); if(!(document.body.textContent.includes("Sent: hello reset")&&f.elements.message.value==="")) throw new Error("reset user input did not clear field"); return true;})()"#;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user