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, } #[derive(Default, Clone)] struct BoardState { next_id: u64, cards: Vec, } #[derive(Clone)] struct Card { id: u64, title: String, column: usize, } #[derive(Hemplate)] struct AppShell { body: SafeHtml, } #[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_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_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>, request: InteractionRequest, ) -> Result { request.dispatch(registry(state)) } // req: push/001 req: push/003 req: examples/001 async fn events(Query(params): Query>) -> 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) -> 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::("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, 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: "Compile checked".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::(), "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 = 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::()), Some("tick #7".to_owned()) ); } }