e20d356c1d
Document that remaining SafeHtml use in examples is limited to full-page shell composition of already-rendered hemplate fragments, while effect payloads use render helpers. req: html_safety/002 req: view/001
380 lines
12 KiB
Rust
380 lines
12 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::{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<BoardState>,
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct BoardState {
|
|
next_id: u64,
|
|
cards: Vec<Card>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct Card {
|
|
id: u64,
|
|
title: String,
|
|
column: usize,
|
|
}
|
|
|
|
#[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_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<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(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>>,
|
|
form: InteractionForm,
|
|
) -> Result<EffectResponse, DispatchRejection> {
|
|
registry(state).dispatch(form)
|
|
}
|
|
|
|
// 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 = 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<AppState>) -> 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::<u64>().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::<u64>().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#"<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>slhx Kanban</title>
|
|
<script src="/slhx.js" defer></script>
|
|
<style>
|
|
body {{ font-family: system-ui, sans-serif; margin: 2rem; }}
|
|
form {{ display: flex; gap: .5rem; flex-wrap: wrap; margin: 1rem 0; }}
|
|
.columns {{ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; }}
|
|
.column {{ border: 1px solid #ddd; border-radius: .5rem; padding: 1rem; background: #fafafa; }}
|
|
.card {{ background: white; border: 1px solid #ccc; border-radius: .5rem; margin: .75rem 0; padding: .75rem; }}
|
|
.card menu {{ display: flex; gap: .35rem; padding: 0; margin: .5rem 0 0; }}
|
|
.presence {{ color: #376; }}
|
|
button, input, select {{ font: inherit; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
{body}
|
|
</body>
|
|
</html>"#
|
|
)
|
|
}
|
|
|
|
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: "<b>Compile checked</b>".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::<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 = 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::<String>()),
|
|
Some("tick #7".to_owned())
|
|
);
|
|
}
|
|
}
|