diff --git a/examples/html_examples/src/main.rs b/examples/html_examples/src/main.rs index 9171563..0b2e2f2 100644 --- a/examples/html_examples/src/main.rs +++ b/examples/html_examples/src/main.rs @@ -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, + "{}% complete", + self.percent, self.percent + ) + .expect("write to String cannot fail"); + Ok(()) + } +} + #[derive(Hemplate, Clone)] struct EditableRow { 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 { ContactCard { id: Id(record.id), @@ -451,7 +481,7 @@ mod gallery_handlers { let _ = input.request; let mut progress = state.progress.lock().unwrap(); *progress = (*progress + 25).min(100); - gallery::progress_meter.set(format!("{}% complete", *progress)) + gallery::progress_meter.replace(&ProgressMeter { percent: *progress }) } #[hemx::handler] @@ -521,20 +551,20 @@ mod gallery_handlers { Form(input): Form, ) -> impl IntoEffect { let email = input.email.trim().to_owned(); - if !email.contains('@') { + if !is_demo_email(&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![ 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"), + 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")), - gallery::validate_email_form.clear(), ] } @@ -770,7 +800,7 @@ mod tests { } assert!(html.contains("data-hemx-form=\"validate_email\"")); 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=\"infinite_row\"")); assert!(html.contains("data-hemx-slot=\"value_option\"")); @@ -831,8 +861,19 @@ mod tests { .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, @@ -845,6 +886,7 @@ mod tests { ); 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( @@ -864,8 +906,7 @@ mod tests { .unwrap() .batch, ); - assert!(progress.updates_text(gallery::progress_meter)); - assert!(progress.payload_contains("25% complete")); + assert!(progress.payload_contains("25% complete")); let values = inspect_batch( InteractionRequest::from(form(gallery::choose_category, &[("category", "numbers")])) diff --git a/examples/html_examples/templates/gallery.heml b/examples/html_examples/templates/gallery.heml index 91fa57c..5794a37 100644 --- a/examples/html_examples/templates/gallery.heml +++ b/examples/html_examples/templates/gallery.heml @@ -78,12 +78,13 @@ progress-bar - + Tick progress - {+ self.progress_label +} - {+ self.progress_label +} + + {+ self.progress_label +} + diff --git a/hemx-xtask/src/main.rs b/hemx-xtask/src/main.rs index e2fce3b..b49e666 100644 --- a/hemx-xtask/src/main.rs +++ b/hemx-xtask/src/main.rs @@ -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 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 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 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 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 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;})()"#;
{+ self.progress_label +}
+ {+ self.progress_label +} +