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::{Handle, IntoEffect, RenderSlotExt, SafeHtml}; use slhx_axum::{runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm, PageRequest}; use slhx_kanban_example::ui; 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, } #[derive(Default, Clone)] struct BoardState { next_id: u64, cards: Vec, } #[derive(Clone)] struct Card { id: u64, title: String, column: usize, } #[derive(Hemplate)] #[hemplate = "partials"] struct BoardColumns { columns: Vec, } #[derive(Hemplate)] #[hemplate = "partials"] struct BoardColumn { title: &'static str, cards: Vec, } #[derive(Hemplate)] #[hemplate = "partials"] struct BoardCard { id: u64, title: String, left_handle: Handle<()>, right_handle: Handle<()>, delete_handle: Handle<()>, left_disabled: bool, right_disabled: bool, } #[derive(Hemplate)] struct Board { options: SafeHtml, board: SafeHtml, } #[derive(Hemplate)] #[hemplate = "partials"] struct ColumnOptions { options: Vec, } #[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>, request: PageRequest) -> impl IntoResponse { let board = state.board.lock().unwrap().clone(); request .page(page_html(&board), shell) .title("slhx Kanban") .fingerprint(ui::BUILD_FINGERPRINT) } async fn runtime() -> impl IntoResponse { runtime_js() } async fn interact( State(state): State>, form: InteractionForm, ) -> Result { registry(state).dispatch(form) } // req: push/001 req: push/003 req: examples/001 async fn events(Query(params): Query>) -> impl IntoResponse { if params.contains_key("once") { let effect = ui::board::slots::presence.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 = ui::board::slots::presence.render(&Presence { count }); Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1)) }) .boxed(); sse(batches) } fn registry(state: Arc) -> HandlerRegistry { HandlerRegistry::new(ui::BUILD_FINGERPRINT) .register_handle(ui::board::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") } }) .register_handle(ui::board::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.value("card_id"), |card| { card.column = card.column.saturating_sub(1); }); board_effects(&board, if moved { "Card moved left" } else { "Card not found" }) } }) .register_handle(ui::board::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.value("card_id"), |card| { card.column = (card.column + 1).min(COLUMNS.len() - 1); }); board_effects(&board, if moved { "Card moved right" } else { "Card not found" }) } }) .register_handle(ui::board::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.value("card_id").and_then(|id| id.parse::().ok()) { 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 { ( ui::board::slots::board.render(&board_view(board)), ui::board::slots::notice.text(notice), ui::board::forms::create_card.clear("title"), ) } fn update_card(board: &mut BoardState, card_id: Option<&str>, update: impl FnOnce(&mut Card)) -> bool { let Some(id) = card_id.and_then(|id| id.parse::().ok()) 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) -> String { // Explicit full-page composition boundary for already-rendered hemplate fragments. // req: html_safety/002 req: view/001 ui::board::lower_html(render_template(&Board { options: render_options(), board: render_board(board), })) } fn shell(body: String) -> String { format!( r#" slhx Kanban {body} "# ) } fn render_options() -> SafeHtml { // req: html_safety/002 req: view/001 slhx::render_html(&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_board(board: &BoardState) -> SafeHtml { // req: html_safety/002 req: view/001 slhx::render_html(&board_view(board)) } fn render_card(card: &Card) -> BoardCard { BoardCard { id: card.id, title: card.title.clone(), left_handle: ui::board::handles::move_left, right_handle: ui::board::handles::move_right, delete_handle: ui::board::handles::delete_card, left_disabled: card.column == 0, right_disabled: card.column + 1 == COLUMNS.len(), } } fn render_template(template: &impl Hemplate) -> String { let mut html = String::new(); template .render_into(&mut html) .expect("kanban hemplate view renders"); html } #[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.contains("__OPTIONS__")); assert!(!html.contains("__BOARD__")); let document = Html::parse_fragment(&html); 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(), 4); } // 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: "Compile checked".to_owned(), column: 0, }], }; let html = render_board(&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::(), "Compile checked"); 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 = slhx::render_html(&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::()), Some("tick #7".to_owned()) ); } }