feat(examples): add browser kanban demo
req: examples/001 req: form/002 req: list/003 req: push/003
This commit is contained in:
Generated
+13
@@ -595,6 +595,19 @@ dependencies = [
|
|||||||
name = "slhx-js"
|
name = "slhx-js"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "slhx-kanban-example"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"axum",
|
||||||
|
"futures-util",
|
||||||
|
"slhx",
|
||||||
|
"slhx-axum",
|
||||||
|
"slhx-build",
|
||||||
|
"slhx-test",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "slhx-test"
|
name = "slhx-test"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["slhx", "slhx-core", "slhx-derive", "slhx-js", "slhx-axum", "slhx-build", "slhx-test", "examples/v0"]
|
members = ["slhx", "slhx-core", "slhx-derive", "slhx-js", "slhx-axum", "slhx-build", "slhx-test", "examples/v0", "examples/kanban"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "slhx-kanban-example"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.7"
|
||||||
|
futures-util = "0.3"
|
||||||
|
slhx = { path = "../../slhx" }
|
||||||
|
slhx-axum = { path = "../../slhx-axum" }
|
||||||
|
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "time"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
slhx-test = { path = "../../slhx-test" }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
slhx-build = { path = "../../slhx-build" }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# slhx Kanban browser example
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
cargo run -p slhx-kanban-example
|
||||||
|
|
||||||
|
Open <http://127.0.0.1:3001>.
|
||||||
|
|
||||||
|
The example is a server-first Kanban board with:
|
||||||
|
|
||||||
|
- add-card form
|
||||||
|
- move-left / move-right card controls
|
||||||
|
- delete-card controls
|
||||||
|
- generated handle ids and slots
|
||||||
|
- application/slhx EffectBatch responses
|
||||||
|
- SSE presence updates
|
||||||
|
|
||||||
|
It intentionally uses buttons instead of custom JavaScript drag-and-drop; drag/local-first sync remain north-star features in `examples/kanban.md`.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
slhx_build::app().run().unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#[slhx::surface]
|
||||||
|
pub mod ui {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::ui;
|
||||||
|
use slhx::{IntoEffect, SafeHtml};
|
||||||
|
use slhx_test::inspect;
|
||||||
|
|
||||||
|
// req: examples/001 req: codegen/002 req: list/003
|
||||||
|
#[test]
|
||||||
|
fn kanban_board_updates_generated_slot() {
|
||||||
|
fn render_board() -> impl IntoEffect {
|
||||||
|
ui::board::slots::board.html(SafeHtml::trusted("<div class=\"columns\"></div>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(inspect(render_board()).has_slot(ui::board::slots::board));
|
||||||
|
}
|
||||||
|
|
||||||
|
// req: examples/001 req: form/002 req: codegen/003
|
||||||
|
#[test]
|
||||||
|
fn kanban_template_exports_form_and_card_handles() {
|
||||||
|
assert_ne!(ui::board::handles::create_card.id().id, ui::board::handles::move_right.id().id);
|
||||||
|
assert_eq!(ui::board::forms::create_card.field("title").resource, ui::board::forms::create_card.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
use axum::extract::{Query, State};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
use futures_util::{stream, StreamExt};
|
||||||
|
use slhx::{IntoEffect, 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<Board>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct Board {
|
||||||
|
next_id: u64,
|
||||||
|
cards: Vec<Card>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Card {
|
||||||
|
id: u64,
|
||||||
|
title: String,
|
||||||
|
column: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
board: Mutex::new(Board {
|
||||||
|
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.html(SafeHtml::trusted(render_presence(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.html(SafeHtml::trusted(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(ui::board::handles::create_card.id().id, {
|
||||||
|
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(ui::board::handles::move_left.id().id, {
|
||||||
|
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(ui::board::handles::move_right.id().id, {
|
||||||
|
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(ui::board::handles::delete_card.id().id, {
|
||||||
|
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: &Board, notice: &'static str) -> impl IntoEffect {
|
||||||
|
(
|
||||||
|
ui::board::slots::board.html(SafeHtml::trusted(render_board(board))),
|
||||||
|
ui::board::slots::notice.text(notice),
|
||||||
|
ui::board::forms::create_card.clear("title"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_card(board: &mut Board, 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: &Board) -> String {
|
||||||
|
ui::board::lower_html(include_str!("../templates/board.heml"))
|
||||||
|
.replace("__OPTIONS__", &render_options())
|
||||||
|
.replace("__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() -> String {
|
||||||
|
COLUMNS
|
||||||
|
.iter()
|
||||||
|
.map(|(id, title)| format!(r#"<option value="{}">{}</option>"#, escape_html(id), escape_html(title)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_board(board: &Board) -> String {
|
||||||
|
let mut out = String::from("<div class=\"columns\">");
|
||||||
|
for (idx, (_, title)) in COLUMNS.iter().enumerate() {
|
||||||
|
out.push_str(&format!(r#"<section class="column"><h2>{}</h2>"#, escape_html(title)));
|
||||||
|
for card in board.cards.iter().filter(|card| card.column == idx) {
|
||||||
|
out.push_str(&render_card(card));
|
||||||
|
}
|
||||||
|
out.push_str("</section>");
|
||||||
|
}
|
||||||
|
out.push_str("</div>");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_card(card: &Card) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<article class="card" data-key="{id}">
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<menu>
|
||||||
|
<button type="button" data-hid="{left}" data-card-id="{id}" {left_disabled}>←</button>
|
||||||
|
<button type="button" data-hid="{right}" data-card-id="{id}" {right_disabled}>→</button>
|
||||||
|
<button type="button" data-hid="{delete}" data-card-id="{id}">Delete</button>
|
||||||
|
</menu>
|
||||||
|
</article>"#,
|
||||||
|
id = card.id,
|
||||||
|
title = escape_html(&card.title),
|
||||||
|
left = ui::board::handles::move_left.id().id,
|
||||||
|
right = ui::board::handles::move_right.id().id,
|
||||||
|
delete = ui::board::handles::delete_card.id().id,
|
||||||
|
left_disabled = if card.column == 0 { "disabled" } else { "" },
|
||||||
|
right_disabled = if card.column + 1 == COLUMNS.len() { "disabled" } else { "" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_presence(count: u64) -> String {
|
||||||
|
format!(
|
||||||
|
r#"<span class="presence">Ada online</span> <span class="presence">Grace online</span> <small>tick #{count}</small>"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_html(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<section data-slhx-root="kanban" data-slhx-sse="/events">
|
||||||
|
<header>
|
||||||
|
<h1>slhx Kanban</h1>
|
||||||
|
<form data-slhx-handle="create_card" data-slhx-form="create_card" data-slhx-disable-while-pending>
|
||||||
|
<input name="title" type="text" required="required" placeholder="Card title">
|
||||||
|
<select name="column" required="required">__OPTIONS__</select>
|
||||||
|
<button type="submit">Add card</button>
|
||||||
|
</form>
|
||||||
|
<p data-slhx-slot="notice">Ready</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div data-slhx-slot="board">__BOARD__</div>
|
||||||
|
<aside data-slhx-slot="presence">Waiting for presence…</aside>
|
||||||
|
|
||||||
|
<button type="button" data-slhx-handle="move_left" hidden="hidden">Move left</button>
|
||||||
|
<button type="button" data-slhx-handle="move_right" hidden="hidden">Move right</button>
|
||||||
|
<button type="button" data-slhx-handle="delete_card" hidden="hidden">Delete</button>
|
||||||
|
</section>
|
||||||
Reference in New Issue
Block a user