refactor(kanban): compose page with hemplate

Replace board page placeholder string replacement for options/board content with a hemplate page view and partial-rendered options. Cover page structure with DOM-aware scraper assertions.

req: html_safety/002

req: view/001

req: test/005
This commit is contained in:
slhx agent
2026-05-25 21:38:36 +02:00
parent d76682e98f
commit 3b61451858
4 changed files with 64 additions and 19 deletions
+57 -16
View File
@@ -17,11 +17,11 @@ const COLUMNS: [(&str, &str); 3] = [("backlog", "Backlog"), ("doing", "Doing"),
#[derive(Default)]
struct AppState {
board: Mutex<Board>,
board: Mutex<BoardState>,
}
#[derive(Default, Clone)]
struct Board {
struct BoardState {
next_id: u64,
cards: Vec<Card>,
}
@@ -33,6 +33,25 @@ struct Card {
column: usize,
}
#[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 {
@@ -42,7 +61,7 @@ struct Presence {
#[tokio::main]
async fn main() {
let state = Arc::new(AppState {
board: Mutex::new(Board {
board: Mutex::new(BoardState {
next_id: 4,
cards: vec![
Card { id: 1, title: "Write requirements".into(), column: 0 },
@@ -153,7 +172,7 @@ fn registry(state: Arc<AppState>) -> HandlerRegistry {
})
}
fn board_effects(board: &Board, notice: &'static str) -> impl IntoEffect {
fn board_effects(board: &BoardState, notice: &'static str) -> impl IntoEffect {
(
ui::board::slots::board.html(SafeHtml::trusted(render_board(board))),
ui::board::slots::notice.text(notice),
@@ -161,7 +180,7 @@ fn board_effects(board: &Board, notice: &'static str) -> impl IntoEffect {
)
}
fn update_card(board: &mut Board, card_id: Option<&str>, update: impl FnOnce(&mut Card)) -> bool {
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;
};
@@ -180,10 +199,12 @@ fn parse_column(value: Option<&str>) -> usize {
.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 page_html(board: &BoardState) -> String {
// req: html_safety/002 req: view/001
ui::board::lower_html(render_template(&Board {
options: render_options(),
board: SafeHtml::trusted(render_board(board)),
}))
}
fn shell(body: String) -> String {
@@ -213,15 +234,17 @@ fn shell(body: String) -> String {
)
}
fn render_options() -> String {
COLUMNS
fn render_options() -> SafeHtml {
// req: html_safety/002 req: view/001
render_html(&ColumnOptions {
options: COLUMNS
.iter()
.map(|(id, title)| format!(r#"<option value="{}">{}</option>"#, escape_html(id), escape_html(title)))
.collect::<Vec<_>>()
.join("")
.map(|(id, title)| ColumnOption { id, title })
.collect(),
})
}
fn render_board(board: &Board) -> String {
fn render_board(board: &BoardState) -> 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)));
@@ -260,7 +283,11 @@ fn render_presence(count: u64) -> SafeHtml {
}
fn render_html(template: &impl Hemplate) -> SafeHtml {
SafeHtml::trusted(template.render().expect("kanban hemplate partial renders"))
SafeHtml::trusted(render_template(template))
}
fn render_template(template: &impl Hemplate) -> String {
template.render().expect("kanban hemplate view renders")
}
fn escape_html(value: &str) -> String {
@@ -281,6 +308,20 @@ mod tests {
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 presence_payload_is_rendered_by_a_hemplate_view() {
+2 -2
View File
@@ -3,13 +3,13 @@
<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>
<select name="column" required="required">{+= self.options =+}</select>
<button type="submit">Add card</button>
</form>
<p data-slhx-slot="notice">Ready</p>
</header>
<div data-slhx-slot="board">__BOARD__</div>
<div data-slhx-slot="board">{+= self.board =+}</div>
<aside data-slhx-slot="presence">Waiting for presence…</aside>
<button type="button" data-slhx-handle="move_left" hidden="hidden">Move left</button>
@@ -0,0 +1 @@
<option +value="self.id">{+ self.title +}</option>
@@ -0,0 +1,3 @@
<template h-for="option in &self.options">
{+ option +}
</template>