feat(hemx): reuse todo row partials

Make the canonical v0 todo flow use one TodoRow partial shape for initial list rendering and generated keyed append/replace/remove helpers. Teach hemx-build to treat collection hosts with keyed h-for children as keyed targets, so beginner templates can keep the DRY outer slot + child partial composition.

req: canonical_authoring/002

req: canonical_authoring/003

req: examples/004

req: list/001
This commit is contained in:
slhx agent
2026-06-05 08:56:36 +02:00
parent cb92803db4
commit 3dfb3b684f
7 changed files with 91 additions and 70 deletions
+42 -43
View File
@@ -123,16 +123,32 @@ impl IntoHandlerFailure for AppError {
}
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct TodoItems {
items: Vec<TodoItem>,
struct Todos {
summary: String,
rows: Vec<TodoRow>,
}
impl Hemplate for Todos {
fn render_into(&self, buf: &mut String) -> Result<(), hemplate::error::HemplateError> {
use std::fmt::Write as _;
buf.push_str("<section data-hemx-root=\"todos\">\n <form data-hemx-handle=\"add_todo\" data-hemx-form=\"new_todo\">\n <label>New todo\n <input name=\"title\" required=\"required\">\n </label>\n <button type=\"submit\">Add</button>\n <p data-hemx-form-error=\"title\"></p>\n </form>\n\n <p data-hemx-slot=\"summary\">");
write!(buf, "{}", hemplate::HtmlEscape(&self.summary))?;
buf.push_str("</p>\n\n <ul data-hemx-slot=\"todo_row\">\n");
for row in &self.rows {
buf.push_str(" ");
row.render_into(buf)?;
buf.push('\n');
}
buf.push_str(" </ul>\n</section>\n");
Ok(())
}
}
#[derive(Hemplate)]
#[hemplate = "partials"]
struct TodoRow {
id: u64,
id: TodoId,
title: String,
}
@@ -142,11 +158,6 @@ impl hemx::KeyedPartial for TodoRow {
}
}
struct TodoItem {
id: u64,
title: String,
}
#[derive(Hemplate)]
struct Counter;
@@ -313,11 +324,19 @@ mod todo_handlers {
});
let summary = todo_summary(&todos);
Ok((
todos::todo_row.append(TodoRow { id, title }),
todos::todo_row.append(TodoRow {
id: TodoId(id),
title,
}),
todos::summary.set(summary),
todos::new_todo.clear(),
))
}
}
#[hemx::component("todo_row")]
mod todo_row_handlers {
use super::*;
#[hemx::handler]
async fn rename_todo(
@@ -336,27 +355,6 @@ mod todo_handlers {
}
}
#[hemx::component("todo_row")]
mod todo_row_handlers {
use super::*;
#[hemx::handler]
async fn rename_todo_row(
State(state): State<Arc<ExampleState>>,
Form(form): Form<RenameTodo>,
) -> impl IntoEffect {
rename_todo_effect(state, form)
}
#[hemx::handler]
async fn delete_todo_row(
State(state): State<Arc<ExampleState>>,
Form(form): Form<DeleteTodo>,
) -> impl IntoEffect {
delete_todo_effect(state, form)
}
}
#[hemx::component("wizard")]
mod wizard_handlers {
use super::*;
@@ -402,7 +400,7 @@ fn rename_todo_effect(state: Arc<ExampleState>, form: RenameTodo) -> impl IntoEf
.map(|todo| {
todo.title = form.title.into_string();
todos::todo_row.replace(TodoRow {
id: form.id.0,
id: form.id,
title: todo.title.clone(),
})
})
@@ -421,13 +419,14 @@ fn delete_todo_effect(state: Arc<ExampleState>, form: DeleteTodo) -> impl IntoEf
)
}
fn todos_view(todos: &[TodoRecord]) -> TodoItems {
// req: html_safety/002 req: view/001
TodoItems {
items: todos
fn todos_view(todos: &[TodoRecord]) -> Todos {
// req: html_safety/002 req: view/001 req: canonical_authoring/003
Todos {
summary: todo_summary(todos),
rows: todos
.iter()
.map(|todo| TodoItem {
id: todo.id,
.map(|todo| TodoRow {
id: TodoId(todo.id),
title: todo.title.clone(),
})
.collect(),
@@ -577,7 +576,7 @@ mod tests {
.select(&selector(&keyed_selector("li", 7)))
.next()
.expect("generated keyed row");
assert_eq!(row.text().collect::<String>(), "<b>Ship v0</b>");
assert!(row.text().collect::<String>().contains("<b>Ship v0</b>"));
assert!(document
.select(&selector(&escaped_markup_selector("b")))
.next()
@@ -647,7 +646,7 @@ mod tests {
let rename = inspect_batch(
InteractionRequest::from(form(
todo_row::rename_todo_row,
todo_row::rename_todo,
&[("id", "1"), ("title", "Ship 1.0")],
))
.dispatch_async(registry(state.clone()))
@@ -660,7 +659,7 @@ mod tests {
assert!(rename.replaces_keyed_html_containing(todos::todo_row, "1", "Ship 1.0"));
let delete = inspect_batch(
InteractionRequest::from(form(todo_row::delete_todo_row, &[("id", "1")]))
InteractionRequest::from(form(todo_row::delete_todo, &[("id", "1")]))
.dispatch_async(registry(state.clone()))
.await
.unwrap()
@@ -672,7 +671,7 @@ mod tests {
assert!(delete.updates_text(todos::summary));
let missing_delete = inspect_batch(
InteractionRequest::from(form(todo_row::delete_todo_row, &[("id", "99")]))
InteractionRequest::from(form(todo_row::delete_todo, &[("id", "99")]))
.dispatch_async(registry(state.clone()))
.await
.unwrap()