Files
hemx/examples/kanban/src/main.rs
T
slhx agent 743f79c6d6 fix(examples): lower slot render updates
Use generated lower-aware render paths before slot HTML updates so example fragments carry runtime ids for follow-up interactions such as browser drag/drop.

req: component/003

req: runtime/001

req: examples/003
2026-06-02 01:41:42 +02:00

352 lines
11 KiB
Rust

use axum::extract::{Query, State};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use futures_util::{stream, StreamExt};
use hemplate::Hemplate;
use slhx::{IntoEffect, SafeHtml};
use slhx_axum::{
interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse,
InteractionRequest, PageRequest,
};
use slhx_kanban_example::ui::{self, board as board_ui};
use slhx_kanban_example::ui::board::{forms, handles, slots};
use slhx_kanban_example::ui::board_card::handles as card_handles;
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use std::time::Duration;
const COLUMNS: [(&str, &str); 3] = [("backlog", "Backlog"), ("doing", "Doing"), ("done", "Done")];
#[derive(Default)]
struct AppState {
board: Mutex<BoardState>,
}
#[derive(Default, Clone)]
struct BoardState {
next_id: u64,
cards: Vec<Card>,
}
#[derive(Clone)]
struct Card {
id: u64,
title: String,
column: usize,
}
#[derive(Hemplate)]
struct AppShell {
body: SafeHtml,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct BoardColumns {
columns: Vec<BoardColumn>,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct BoardColumn {
title: &'static str,
cards: Vec<BoardCard>,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct BoardCard {
id: u64,
title: String,
left_disabled: bool,
right_disabled: bool,
}
#[derive(Hemplate)]
struct Board {
options: SafeHtml,
board: SafeHtml,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct ColumnOptions {
options: Vec<ColumnOption>,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct ColumnOption {
id: &'static str,
title: &'static str,
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct Presence {
count: u64,
}
#[tokio::main]
async fn main() {
let state = Arc::new(AppState {
board: Mutex::new(BoardState {
next_id: 4,
cards: vec![
Card { id: 1, title: "Write requirements".into(), column: 0 },
Card { id: 2, title: "Build browser example".into(), column: 1 },
Card { id: 3, title: "Verify with HTTP".into(), column: 2 },
],
}),
});
let app = Router::new()
.route("/", get(home).post(interact))
.route("/events", get(events))
.route("/slhx.js", get(runtime))
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], 3001));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
println!("slhx Kanban example: http://{addr}");
axum::serve(listener, app).await.unwrap();
}
// req: examples/001 req: component/003
async fn home(State(state): State<Arc<AppState>>, request: PageRequest) -> impl IntoResponse {
let board = state.board.lock().unwrap().clone();
request
.page_html(page_html(&board), shell)
.title("slhx Kanban")
.fingerprint(ui::BUILD_FINGERPRINT)
}
async fn runtime() -> impl IntoResponse {
runtime_js()
}
async fn interact(
State(state): State<Arc<AppState>>,
request: InteractionRequest,
) -> Result<EffectResponse, DispatchRejection> {
request.dispatch(registry(state))
}
// req: push/001 req: push/003 req: examples/001
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
if params.contains_key("once") {
let effect = slots::presence.html(ui::render(&Presence { count: 1 }));
return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed());
}
let batches = stream::unfold(1_u64, |count| async move {
tokio::time::sleep(Duration::from_secs(4)).await;
let effect = slots::presence.html(ui::render(&Presence { count }));
Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1))
})
.boxed();
sse(batches)
}
fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
interactions(ui::BUILD_FINGERPRINT)
.on(handles::create_card, {
let state = state.clone();
move |form| {
// req: examples/001 req: form/002
let title = form.value("title").unwrap_or("").trim();
let column = parse_column(form.value("column"));
let mut board = state.board.lock().unwrap();
if !title.is_empty() {
let id = board.next_id;
board.next_id += 1;
board.cards.push(Card { id, title: title.into(), column });
}
board_effects(&board, "Card added")
}
})
.on(card_handles::move_left, {
let state = state.clone();
move |form| {
// req: examples/001 req: list/003
let mut board = state.board.lock().unwrap();
let moved = update_card(&mut board, form.parse("card_id"), |card| {
card.column = card.column.saturating_sub(1);
});
board_effects(&board, if moved { "Card moved left" } else { "Card not found" })
}
})
.on(card_handles::move_right, {
let state = state.clone();
move |form| {
// req: examples/001 req: list/003
let mut board = state.board.lock().unwrap();
let moved = update_card(&mut board, form.parse("card_id"), |card| {
card.column = (card.column + 1).min(COLUMNS.len() - 1);
});
board_effects(&board, if moved { "Card moved right" } else { "Card not found" })
}
})
.on(card_handles::delete_card, {
let state = state.clone();
move |form| {
// req: examples/001 req: list/003
let mut board = state.board.lock().unwrap();
let before = board.cards.len();
if let Some(id) = form.parse::<u64>("card_id") {
board.cards.retain(|card| card.id != id);
}
board_effects(&board, if board.cards.len() < before { "Card deleted" } else { "Card not found" })
}
})
}
fn board_effects(board: &BoardState, notice: &'static str) -> impl IntoEffect {
(
slots::board.html(ui::render(&board_view(board))),
slots::notice.text(notice),
forms::create_card.clear("title"),
)
}
fn update_card(
board: &mut BoardState,
card_id: Option<u64>,
update: impl FnOnce(&mut Card),
) -> bool {
let Some(id) = card_id else {
return false;
};
let Some(card) = board.cards.iter_mut().find(|card| card.id == id) else {
return false;
};
update(card);
true
}
fn parse_column(value: Option<&str>) -> usize {
let id = value.unwrap_or(COLUMNS[0].0);
COLUMNS
.iter()
.position(|(column_id, _)| *column_id == id)
.unwrap_or(0)
}
fn page_html(board: &BoardState) -> SafeHtml {
// req: html_safety/002 req: view/001
board_ui::render(&Board {
options: render_options(),
board: ui::render(&board_view(board)),
})
}
fn shell(body: SafeHtml) -> SafeHtml {
// req: html_safety/001 req: html_safety/002 req: axum_integration/001
slhx::render(&AppShell { body })
}
fn render_options() -> SafeHtml {
// req: html_safety/002 req: view/001
ui::render(&ColumnOptions {
options: COLUMNS
.iter()
.map(|(id, title)| ColumnOption { id, title })
.collect(),
})
}
fn board_view(board: &BoardState) -> BoardColumns {
// req: html_safety/002 req: view/001
BoardColumns {
columns: COLUMNS
.iter()
.enumerate()
.map(|(idx, (_, title))| BoardColumn {
title,
cards: board
.cards
.iter()
.filter(|card| card.column == idx)
.map(render_card)
.collect(),
})
.collect(),
}
}
fn render_card(card: &Card) -> BoardCard {
BoardCard {
id: card.id,
title: card.title.clone(),
left_disabled: card.column == 0,
right_disabled: card.column + 1 == COLUMNS.len(),
}
}
#[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 kanban_page_is_composed_by_a_hemplate_view() {
let html = page_html(&BoardState::default());
assert!(!html.as_str().contains("__OPTIONS__"));
assert!(!html.as_str().contains("__BOARD__"));
let document = Html::parse_fragment(html.as_str());
assert_eq!(document.select(&selector("section[data-slhx-root=\"kanban\"]")).count(), 1);
assert_eq!(document.select(&selector("select[name=\"column\"] > option")).count(), 3);
assert_eq!(document.select(&selector("[data-sid]")).count(), 3);
assert_eq!(document.select(&selector("[data-hid]")).count(), 1);
}
// req: html_safety/002 req: view/001 req: test/005
#[test]
fn board_payload_is_rendered_by_a_hemplate_view() {
let board = BoardState {
next_id: 2,
cards: vec![Card {
id: 1,
title: "<b>Compile checked</b>".to_owned(),
column: 0,
}],
};
let html = ui::render(&board_view(&board));
let document = Html::parse_fragment(html.as_str());
assert_eq!(document.select(&selector(".columns > section.column")).count(), 3);
let card = document
.select(&selector("article.card[data-key=\"1\"]"))
.next()
.expect("card renders");
let title = card.select(&selector("strong")).next().expect("card title renders");
assert_eq!(title.text().collect::<String>(), "<b>Compile checked</b>");
assert!(card.select(&selector("b")).next().is_none());
assert_eq!(card.select(&selector("button[disabled]")).count(), 1);
}
// req: html_safety/002 req: view/001 req: test/005
#[test]
fn presence_payload_is_rendered_by_a_hemplate_view() {
let html = ui::render(&Presence { count: 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())
);
}
}