feat(api): streamline generated app authoring
Move the canonical examples toward generated component-root helpers, typed form decoding, async/state handler registration, and derive-driven app/component registry wiring. Tighten requirements and diagnostics for the server-first, selectorless authoring path. Verified with cargo run -p slhx-xtask -- test, cargo check --workspace, redgate list, redgate refs, redgate health --strict, and git diff --check. req: canonical/001 req: canonical/003 req: canonical/004 req: dx/002 req: derive_app/001 req: component/003 req: form/004 req: axum_integration/003
This commit is contained in:
+119
-38
@@ -4,14 +4,14 @@ use axum::routing::get;
|
||||
use axum::Router;
|
||||
use futures_util::{stream, StreamExt};
|
||||
use hemplate::Hemplate;
|
||||
use slhx::{IntoEffect, SafeHtml};
|
||||
use slhx::{Html, IntoEffect};
|
||||
use slhx_axum::{
|
||||
interactions, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse,
|
||||
InteractionRequest, PageRequest,
|
||||
};
|
||||
use slhx_kanban_example::ui::board::{self as board};
|
||||
use slhx_kanban_example::ui::board_card as card_board;
|
||||
use slhx_kanban_example::ui::{self, board as board_ui};
|
||||
use slhx_kanban_example::ui::board::{forms, handles, slots, targets};
|
||||
use slhx_kanban_example::ui::board_card::handles as card_handles;
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
@@ -40,7 +40,7 @@ struct Card {
|
||||
|
||||
#[derive(Hemplate)]
|
||||
struct AppShell {
|
||||
body: SafeHtml,
|
||||
body: Html,
|
||||
}
|
||||
|
||||
#[derive(Hemplate)]
|
||||
@@ -67,8 +67,8 @@ struct BoardCard {
|
||||
|
||||
#[derive(Hemplate)]
|
||||
struct Board {
|
||||
options: SafeHtml,
|
||||
board: SafeHtml,
|
||||
options: Html,
|
||||
board: Html,
|
||||
}
|
||||
|
||||
#[derive(Hemplate)]
|
||||
@@ -96,9 +96,21 @@ async fn main() {
|
||||
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 },
|
||||
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,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
@@ -138,14 +150,20 @@ async fn interact(
|
||||
// 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 = targets::presence.put(&Presence { count: 1 });
|
||||
return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed());
|
||||
let effect = board::presence.put(&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 = targets::presence.put(&Presence { count });
|
||||
Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1))
|
||||
let effect = board::presence.put(&Presence { count });
|
||||
Some((
|
||||
Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)),
|
||||
count + 1,
|
||||
))
|
||||
})
|
||||
.boxed();
|
||||
sse(batches)
|
||||
@@ -153,7 +171,7 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
|
||||
|
||||
fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
|
||||
interactions(ui::BUILD_FINGERPRINT)
|
||||
.on(handles::create_card, {
|
||||
.on(board::create_card, {
|
||||
let state = state.clone();
|
||||
move |form| {
|
||||
// req: examples/001 req: form/002
|
||||
@@ -163,12 +181,16 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
|
||||
if !title.is_empty() {
|
||||
let id = board.next_id;
|
||||
board.next_id += 1;
|
||||
board.cards.push(Card { id, title: title.into(), column });
|
||||
board.cards.push(Card {
|
||||
id,
|
||||
title: title.into(),
|
||||
column,
|
||||
});
|
||||
}
|
||||
board_effects(&board, "Card added")
|
||||
}
|
||||
})
|
||||
.on(card_handles::move_left, {
|
||||
.on(card_board::move_left, {
|
||||
let state = state.clone();
|
||||
move |form| {
|
||||
// req: examples/001 req: list/003
|
||||
@@ -176,10 +198,17 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
|
||||
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" })
|
||||
board_effects(
|
||||
&board,
|
||||
if moved {
|
||||
"Card moved left"
|
||||
} else {
|
||||
"Card not found"
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.on(card_handles::move_right, {
|
||||
.on(card_board::move_right, {
|
||||
let state = state.clone();
|
||||
move |form| {
|
||||
// req: examples/001 req: list/003
|
||||
@@ -187,10 +216,17 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
|
||||
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" })
|
||||
board_effects(
|
||||
&board,
|
||||
if moved {
|
||||
"Card moved right"
|
||||
} else {
|
||||
"Card not found"
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
.on(card_handles::delete_card, {
|
||||
.on(card_board::delete_card, {
|
||||
let state = state.clone();
|
||||
move |form| {
|
||||
// req: examples/001 req: list/003
|
||||
@@ -199,16 +235,23 @@ fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
|
||||
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" })
|
||||
board_effects(
|
||||
&board,
|
||||
if board.cards.len() < before {
|
||||
"Card deleted"
|
||||
} else {
|
||||
"Card not found"
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn board_effects(board: &BoardState, notice: &'static str) -> impl IntoEffect {
|
||||
(
|
||||
targets::board.put(&board_view(board)),
|
||||
slots::notice.text(notice),
|
||||
forms::create_card.clear("title"),
|
||||
board::board.put(&board_view(board)),
|
||||
board::notice.text(notice),
|
||||
board::create_card_form.clear(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -235,7 +278,7 @@ fn parse_column(value: Option<&str>) -> usize {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn page_html(board: &BoardState) -> SafeHtml {
|
||||
fn page_html(board: &BoardState) -> Html {
|
||||
// req: html_safety/002 req: view/001
|
||||
board_ui::render(&Board {
|
||||
options: render_options(),
|
||||
@@ -243,12 +286,12 @@ fn page_html(board: &BoardState) -> SafeHtml {
|
||||
})
|
||||
}
|
||||
|
||||
fn shell(body: SafeHtml) -> SafeHtml {
|
||||
fn shell(body: Html) -> Html {
|
||||
// req: html_safety/001 req: html_safety/002 req: axum_integration/001
|
||||
slhx::render(&AppShell { body })
|
||||
}
|
||||
|
||||
fn render_options() -> SafeHtml {
|
||||
fn render_options() -> Html {
|
||||
// req: html_safety/002 req: view/001
|
||||
ui::render(&ColumnOptions {
|
||||
options: COLUMNS
|
||||
@@ -290,6 +333,11 @@ fn render_card(card: &Card) -> BoardCard {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use scraper::{Html, Selector};
|
||||
use slhx_test::{
|
||||
class_child_selector, disabled_button_selector, element_class_selector,
|
||||
escaped_markup_selector, form_selector, keyed_selector, root_element_selector,
|
||||
select_options_selector, small_text_selector, strong_text_selector,
|
||||
};
|
||||
|
||||
fn selector(value: &str) -> Selector {
|
||||
Selector::parse(value).expect("test selector parses")
|
||||
@@ -303,10 +351,22 @@ mod tests {
|
||||
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);
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector(&root_element_selector("section", "kanban")))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector(&select_options_selector("column")))
|
||||
.count(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
document.select(&selector(&form_selector("header"))).count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
// req: html_safety/002 req: view/001 req: test/005
|
||||
@@ -323,15 +383,31 @@ mod tests {
|
||||
|
||||
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);
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector(&class_child_selector(
|
||||
"columns", "section", "column"
|
||||
)))
|
||||
.count(),
|
||||
3
|
||||
);
|
||||
let card = document
|
||||
.select(&selector("article.card[data-key=\"1\"]"))
|
||||
.select(&selector(&keyed_selector("article.card", 1)))
|
||||
.next()
|
||||
.expect("card renders");
|
||||
let title = card.select(&selector("strong")).next().expect("card title renders");
|
||||
let title = card
|
||||
.select(&selector(strong_text_selector()))
|
||||
.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);
|
||||
assert!(card
|
||||
.select(&selector(&escaped_markup_selector("b")))
|
||||
.next()
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
card.select(&selector(disabled_button_selector())).count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
// req: html_safety/002 req: view/001 req: test/005
|
||||
@@ -339,10 +415,15 @@ mod tests {
|
||||
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"))
|
||||
.select(&selector(&element_class_selector("span", "presence")))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector(small_text_selector()))
|
||||
.next()
|
||||
.map(|small| small.text().collect::<String>()),
|
||||
Some("tick #7".to_owned())
|
||||
|
||||
Reference in New Issue
Block a user