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
+58 -17
View File
@@ -17,11 +17,11 @@ const COLUMNS: [(&str, &str); 3] = [("backlog", "Backlog"), ("doing", "Doing"),
#[derive(Default)] #[derive(Default)]
struct AppState { struct AppState {
board: Mutex<Board>, board: Mutex<BoardState>,
} }
#[derive(Default, Clone)] #[derive(Default, Clone)]
struct Board { struct BoardState {
next_id: u64, next_id: u64,
cards: Vec<Card>, cards: Vec<Card>,
} }
@@ -33,6 +33,25 @@ struct Card {
column: usize, 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)] #[derive(Hemplate)]
#[hemplate = "partials"] #[hemplate = "partials"]
struct Presence { struct Presence {
@@ -42,7 +61,7 @@ struct Presence {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let state = Arc::new(AppState { let state = Arc::new(AppState {
board: Mutex::new(Board { board: Mutex::new(BoardState {
next_id: 4, next_id: 4,
cards: vec![ cards: vec![
Card { id: 1, title: "Write requirements".into(), column: 0 }, 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::board.html(SafeHtml::trusted(render_board(board))),
ui::board::slots::notice.text(notice), 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 { let Some(id) = card_id.and_then(|id| id.parse::<u64>().ok()) else {
return false; return false;
}; };
@@ -180,10 +199,12 @@ fn parse_column(value: Option<&str>) -> usize {
.unwrap_or(0) .unwrap_or(0)
} }
fn page_html(board: &Board) -> String { fn page_html(board: &BoardState) -> String {
ui::board::lower_html(include_str!("../templates/board.heml")) // req: html_safety/002 req: view/001
.replace("__OPTIONS__", &render_options()) ui::board::lower_html(render_template(&Board {
.replace("__BOARD__", &render_board(board)) options: render_options(),
board: SafeHtml::trusted(render_board(board)),
}))
} }
fn shell(body: String) -> String { fn shell(body: String) -> String {
@@ -213,15 +234,17 @@ fn shell(body: String) -> String {
) )
} }
fn render_options() -> String { fn render_options() -> SafeHtml {
COLUMNS // req: html_safety/002 req: view/001
.iter() render_html(&ColumnOptions {
.map(|(id, title)| format!(r#"<option value="{}">{}</option>"#, escape_html(id), escape_html(title))) options: COLUMNS
.collect::<Vec<_>>() .iter()
.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\">"); let mut out = String::from("<div class=\"columns\">");
for (idx, (_, title)) in COLUMNS.iter().enumerate() { for (idx, (_, title)) in COLUMNS.iter().enumerate() {
out.push_str(&format!(r#"<section class="column"><h2>{}</h2>"#, escape_html(title))); 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 { 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 { fn escape_html(value: &str) -> String {
@@ -281,6 +308,20 @@ mod tests {
Selector::parse(value).expect("test selector parses") 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 // req: html_safety/002 req: view/001 req: test/005
#[test] #[test]
fn presence_payload_is_rendered_by_a_hemplate_view() { fn presence_payload_is_rendered_by_a_hemplate_view() {
+2 -2
View File
@@ -3,13 +3,13 @@
<h1>slhx Kanban</h1> <h1>slhx Kanban</h1>
<form data-slhx-handle="create_card" data-slhx-form="create_card" data-slhx-disable-while-pending> <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"> <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> <button type="submit">Add card</button>
</form> </form>
<p data-slhx-slot="notice">Ready</p> <p data-slhx-slot="notice">Ready</p>
</header> </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> <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_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>