refactor(kanban): render presence with hemplate

Move the kanban SSE presence fragment from Rust raw HTML formatting into a hemplate partial/view and cover the rendered structure with scraper.

req: html_safety/002

req: view/001

req: test/005
This commit is contained in:
slhx agent
2026-05-25 21:34:53 +02:00
parent 59b0c8ccc4
commit d76682e98f
4 changed files with 46 additions and 6 deletions
Generated
+2
View File
@@ -1409,6 +1409,8 @@ version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"futures-util", "futures-util",
"hemplate",
"scraper",
"slhx", "slhx",
"slhx-axum", "slhx-axum",
"slhx-build", "slhx-build",
+2
View File
@@ -10,11 +10,13 @@ path = "src/lib.rs"
[dependencies] [dependencies]
axum = "0.7" axum = "0.7"
futures-util = "0.3" futures-util = "0.3"
hemplate = { path = "../../../hemplate/hemplate" }
slhx = { path = "../../slhx" } slhx = { path = "../../slhx" }
slhx-axum = { path = "../../slhx-axum" } slhx-axum = { path = "../../slhx-axum" }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] } tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies] [dev-dependencies]
scraper = "0.23"
slhx-test = { path = "../../slhx-test" } slhx-test = { path = "../../slhx-test" }
[build-dependencies] [build-dependencies]
+41 -6
View File
@@ -3,6 +3,7 @@ use axum::response::IntoResponse;
use axum::routing::get; use axum::routing::get;
use axum::Router; use axum::Router;
use futures_util::{stream, StreamExt}; use futures_util::{stream, StreamExt};
use hemplate::Hemplate;
use slhx::{IntoEffect, SafeHtml}; use slhx::{IntoEffect, SafeHtml};
use slhx_axum::{runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm, PageRequest}; use slhx_axum::{runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm, PageRequest};
use slhx_kanban_example::ui; use slhx_kanban_example::ui;
@@ -32,6 +33,12 @@ struct Card {
column: usize, column: usize,
} }
#[derive(Hemplate)]
#[hemplate = "partials"]
struct Presence {
count: u64,
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let state = Arc::new(AppState { let state = Arc::new(AppState {
@@ -80,13 +87,13 @@ async fn interact(
// req: push/001 req: push/003 req: examples/001 // req: push/001 req: push/003 req: examples/001
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse { async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
if params.contains_key("once") { if params.contains_key("once") {
let effect = ui::board::slots::presence.html(SafeHtml::trusted(render_presence(1))); let effect = ui::board::slots::presence.html(render_presence(1));
return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed()); return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed());
} }
let batches = stream::unfold(1_u64, |count| async move { let batches = stream::unfold(1_u64, |count| async move {
tokio::time::sleep(Duration::from_secs(4)).await; tokio::time::sleep(Duration::from_secs(4)).await;
let effect = ui::board::slots::presence.html(SafeHtml::trusted(render_presence(count))); let effect = ui::board::slots::presence.html(render_presence(count));
Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1)) Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1))
}) })
.boxed(); .boxed();
@@ -247,10 +254,13 @@ fn render_card(card: &Card) -> String {
) )
} }
fn render_presence(count: u64) -> String { fn render_presence(count: u64) -> SafeHtml {
format!( // req: html_safety/002 req: view/001
r#"<span class="presence">Ada online</span> <span class="presence">Grace online</span> <small>tick #{count}</small>"# render_html(&Presence { count })
) }
fn render_html(template: &impl Hemplate) -> SafeHtml {
SafeHtml::trusted(template.render().expect("kanban hemplate partial renders"))
} }
fn escape_html(value: &str) -> String { fn escape_html(value: &str) -> String {
@@ -261,3 +271,28 @@ fn escape_html(value: &str) -> String {
.replace('"', "&quot;") .replace('"', "&quot;")
.replace('\'', "&#39;") .replace('\'', "&#39;")
} }
#[cfg(test)]
mod tests {
use super::*;
use scraper::{Html, Selector};
fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses")
}
// req: html_safety/002 req: view/001 req: test/005
#[test]
fn presence_payload_is_rendered_by_a_hemplate_view() {
let html = render_presence(7);
let document = Html::parse_fragment(html.as_str());
assert_eq!(document.select(&selector("span.presence")).count(), 2);
assert_eq!(
document
.select(&selector("small"))
.next()
.map(|small| small.text().collect::<String>()),
Some("tick #7".to_owned())
);
}
}
@@ -0,0 +1 @@
<span class="presence">Ada online</span> <span class="presence">Grace online</span> <small>tick #{+ self.count +}</small>