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
+1 -1
View File
@@ -11,7 +11,7 @@ Open <http://127.0.0.1:3000>.
The page includes working examples for generated target objects and server-first UI commands:
- counter updates
- todo form submission
- todo form submission with one `TodoRow` hemplate partial reused by the initial page render and generated row append/replace/remove commands
- wizard step updates
- login form feedback
- page swap/navigation
+2 -2
View File
@@ -69,12 +69,12 @@ mod tests {
fn form_handler_is_checked_against_hemplate_form() {
#[hemx::handler]
fn add_todo(_form: hemx::Form<TodoInput>) -> impl IntoEffect {
todos::todo_list.set("queued")
todos::summary.set("queued")
}
let effect = inspect(add_todo(TodoInput::FORM));
assert!(effect.updates_text(todos::todo_list));
assert!(effect.updates_text(todos::summary));
}
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/002
+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()
@@ -1,3 +0,0 @@
<template h-if="!self.items.is_empty()">
<li h-for="todo in &self.items" +data-key="todo.id">{+ todo.title +}</li>
</template>
+3 -3
View File
@@ -1,10 +1,10 @@
<li>
<li +data-key="self.id">
<span>{+ self.title +}</span>
<form data-hemx-handle="rename_todo_row">
<form data-hemx-handle="rename_todo">
<input type="hidden" name="id" +value="self.id">
<button type="submit" name="title" value="Renamed todo">Rename</button>
</form>
<form data-hemx-handle="delete_todo_row">
<form data-hemx-handle="delete_todo">
<button type="submit" name="id" +value="self.id">Delete</button>
</form>
</li>
+5 -16
View File
@@ -4,20 +4,9 @@
<button type="submit">Add</button>
</form>
<p data-hemx-slot="summary">{+ self.summary +}</p>
<div data-hemx-slot="todo_list">
<ul data-hemx-slot="todo_row">
<template h-for="todo in &self.items" h-key="todo.id">
<li data-hemx-slot="todo_row" +data-key="todo.id">
<span>{+ todo.title +}</span>
<form data-hemx-handle="rename_todo">
<input type="hidden" name="id" +value="todo.id">
<button type="submit" name="title" value="Renamed todo">Rename</button>
</form>
<form data-hemx-handle="delete_todo">
<button type="submit" name="id" +value="todo.id">Delete</button>
</form>
</li>
</template>
</ul>
</div>
<ul data-hemx-slot="todo_row">
<template h-for="row in &self.rows" h-key="row.id">
{+ row +}
</template>
</ul>
</section>
+38 -2
View File
@@ -175,7 +175,9 @@ impl Resources {
if let Some(name) = static_attr(&node.attrs, "data-hemx-slot") {
reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?;
let keyed = is_inside_keyed_for(surface, node.scope);
let keyed = is_inside_keyed_for(surface, node.scope)
|| (can_host_keyed_collection(tag)
&& has_descendant_keyed_for_scope(surface, node.scope));
let canonical = canonical_symbol(root, path, &name);
self.insert_slot(canonical, name, component.clone(), keyed)?;
}
@@ -1275,6 +1277,40 @@ fn event_ident(name: &str) -> Option<String> {
rust_ident(&name.replace(['-', ':'], "_"))
}
fn can_host_keyed_collection(tag: &str) -> bool {
matches!(
tag,
"ul" | "ol" | "tbody" | "thead" | "tfoot" | "table" | "select" | "datalist"
)
}
fn has_descendant_keyed_for_scope(surface: &SurfaceDocument, scope: ScopeId) -> bool {
surface.scopes.iter().enumerate().any(|(index, current)| {
matches!(
current.kind,
ScopeKind::For {
key_expr: Some(_),
..
}
) && is_descendant_scope(surface, ScopeId(index as u32), scope)
})
}
fn is_descendant_scope(surface: &SurfaceDocument, mut scope: ScopeId, ancestor: ScopeId) -> bool {
loop {
let Some(current) = surface.scopes.get(scope.0 as usize) else {
return false;
};
let Some(parent) = current.parent else {
return false;
};
if parent == ancestor {
return true;
}
scope = parent;
}
}
fn is_inside_keyed_for(surface: &SurfaceDocument, mut scope: ScopeId) -> bool {
loop {
let Some(current) = surface.scopes.get(scope.0 as usize) else {
@@ -1732,7 +1768,7 @@ mod tests {
std::fs::create_dir_all(&templates).unwrap();
std::fs::write(
templates.join("todos.heml"),
r#"<ul data-hemx-slot="row"><template h-for="todo in &self.todos" h-key="todo.id"><li data-hemx-slot="row" +data-key="todo.id">{+ todo.title +}</li></template></ul>"#,
r#"<ul data-hemx-slot="row"><template h-for="todo in &self.todos" h-key="todo.id"><li +data-key="todo.id">{+ todo +}</li></template></ul>"#,
)
.unwrap();