refactor(kanban): render board cards with hemplate

Move Kanban board/column/card rendering from Rust raw HTML assembly into hemplate partial views. Keep handler effects returning generated slot payloads and cover rendered structure with scraper.

req: html_safety/002

req: view/001

req: test/005
This commit is contained in:
slhx agent
2026-05-25 21:45:25 +02:00
parent c655d877ca
commit bd35347fa2
4 changed files with 99 additions and 40 deletions
+78 -40
View File
@@ -33,6 +33,31 @@ struct Card {
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: u32,
right_handle: u32,
delete_handle: u32,
left_disabled: bool,
right_disabled: bool,
}
#[derive(Hemplate)]
struct Board {
options: SafeHtml,
@@ -174,7 +199,7 @@ fn registry(state: Arc<AppState>) -> HandlerRegistry {
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(render_board(board)),
ui::board::slots::notice.text(notice),
ui::board::forms::create_card.clear("title"),
)
@@ -203,7 +228,7 @@ 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)),
board: render_board(board),
}))
}
@@ -244,37 +269,35 @@ fn render_options() -> SafeHtml {
})
}
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)));
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_board(board: &BoardState) -> SafeHtml {
// req: html_safety/002 req: view/001
render_html(&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_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_card(card: &Card) -> BoardCard {
BoardCard {
id: card.id,
title: card.title.clone(),
left_handle: ui::board::handles::move_left.id().id,
right_handle: ui::board::handles::move_right.id().id,
delete_handle: ui::board::handles::delete_card.id().id,
left_disabled: card.column == 0,
right_disabled: card.column + 1 == COLUMNS.len(),
}
}
fn render_presence(count: u64) -> SafeHtml {
@@ -290,15 +313,6 @@ fn render_template(template: &impl Hemplate) -> String {
template.render().expect("kanban hemplate view renders")
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -322,6 +336,30 @@ mod tests {
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");
assert!(card.text().collect::<String>().contains("<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() {
@@ -0,0 +1,10 @@
<article class="card" +data-key="self.id">
<strong>{+ self.title +}</strong>
<menu>
<button h-if="self.left_disabled" type="button" +data-hid="self.left_handle" +data-card-id="self.id" disabled="disabled">←</button>
<button h-else type="button" +data-hid="self.left_handle" +data-card-id="self.id">←</button>
<button h-if="self.right_disabled" type="button" +data-hid="self.right_handle" +data-card-id="self.id" disabled="disabled">→</button>
<button h-else type="button" +data-hid="self.right_handle" +data-card-id="self.id">→</button>
<button type="button" +data-hid="self.delete_handle" +data-card-id="self.id">Delete</button>
</menu>
</article>
@@ -0,0 +1,6 @@
<section class="column">
<h2>{+ self.title +}</h2>
<template h-for="card in &self.cards">
{+ card +}
</template>
</section>
@@ -0,0 +1,5 @@
<div class="columns">
<template h-for="column in &self.columns">
{+ column +}
</template>
</div>