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: The page includes working examples for generated target objects and server-first UI commands:
- counter updates - 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 - wizard step updates
- login form feedback - login form feedback
- page swap/navigation - page swap/navigation
+2 -2
View File
@@ -69,12 +69,12 @@ mod tests {
fn form_handler_is_checked_against_hemplate_form() { fn form_handler_is_checked_against_hemplate_form() {
#[hemx::handler] #[hemx::handler]
fn add_todo(_form: hemx::Form<TodoInput>) -> impl IntoEffect { 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)); 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 // 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)] struct Todos {
#[hemplate = "partials"] summary: String,
struct TodoItems { rows: Vec<TodoRow>,
items: Vec<TodoItem>, }
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)] #[derive(Hemplate)]
#[hemplate = "partials"] #[hemplate = "partials"]
struct TodoRow { struct TodoRow {
id: u64, id: TodoId,
title: String, title: String,
} }
@@ -142,11 +158,6 @@ impl hemx::KeyedPartial for TodoRow {
} }
} }
struct TodoItem {
id: u64,
title: String,
}
#[derive(Hemplate)] #[derive(Hemplate)]
struct Counter; struct Counter;
@@ -313,11 +324,19 @@ mod todo_handlers {
}); });
let summary = todo_summary(&todos); let summary = todo_summary(&todos);
Ok(( Ok((
todos::todo_row.append(TodoRow { id, title }), todos::todo_row.append(TodoRow {
id: TodoId(id),
title,
}),
todos::summary.set(summary), todos::summary.set(summary),
todos::new_todo.clear(), todos::new_todo.clear(),
)) ))
} }
}
#[hemx::component("todo_row")]
mod todo_row_handlers {
use super::*;
#[hemx::handler] #[hemx::handler]
async fn rename_todo( 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")] #[hemx::component("wizard")]
mod wizard_handlers { mod wizard_handlers {
use super::*; use super::*;
@@ -402,7 +400,7 @@ fn rename_todo_effect(state: Arc<ExampleState>, form: RenameTodo) -> impl IntoEf
.map(|todo| { .map(|todo| {
todo.title = form.title.into_string(); todo.title = form.title.into_string();
todos::todo_row.replace(TodoRow { todos::todo_row.replace(TodoRow {
id: form.id.0, id: form.id,
title: todo.title.clone(), 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 { fn todos_view(todos: &[TodoRecord]) -> Todos {
// req: html_safety/002 req: view/001 // req: html_safety/002 req: view/001 req: canonical_authoring/003
TodoItems { Todos {
items: todos summary: todo_summary(todos),
rows: todos
.iter() .iter()
.map(|todo| TodoItem { .map(|todo| TodoRow {
id: todo.id, id: TodoId(todo.id),
title: todo.title.clone(), title: todo.title.clone(),
}) })
.collect(), .collect(),
@@ -577,7 +576,7 @@ mod tests {
.select(&selector(&keyed_selector("li", 7))) .select(&selector(&keyed_selector("li", 7)))
.next() .next()
.expect("generated keyed row"); .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 assert!(document
.select(&selector(&escaped_markup_selector("b"))) .select(&selector(&escaped_markup_selector("b")))
.next() .next()
@@ -647,7 +646,7 @@ mod tests {
let rename = inspect_batch( let rename = inspect_batch(
InteractionRequest::from(form( InteractionRequest::from(form(
todo_row::rename_todo_row, todo_row::rename_todo,
&[("id", "1"), ("title", "Ship 1.0")], &[("id", "1"), ("title", "Ship 1.0")],
)) ))
.dispatch_async(registry(state.clone())) .dispatch_async(registry(state.clone()))
@@ -660,7 +659,7 @@ mod tests {
assert!(rename.replaces_keyed_html_containing(todos::todo_row, "1", "Ship 1.0")); assert!(rename.replaces_keyed_html_containing(todos::todo_row, "1", "Ship 1.0"));
let delete = inspect_batch( 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())) .dispatch_async(registry(state.clone()))
.await .await
.unwrap() .unwrap()
@@ -672,7 +671,7 @@ mod tests {
assert!(delete.updates_text(todos::summary)); assert!(delete.updates_text(todos::summary));
let missing_delete = inspect_batch( 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())) .dispatch_async(registry(state.clone()))
.await .await
.unwrap() .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> <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"> <input type="hidden" name="id" +value="self.id">
<button type="submit" name="title" value="Renamed todo">Rename</button> <button type="submit" name="title" value="Renamed todo">Rename</button>
</form> </form>
<form data-hemx-handle="delete_todo_row"> <form data-hemx-handle="delete_todo">
<button type="submit" name="id" +value="self.id">Delete</button> <button type="submit" name="id" +value="self.id">Delete</button>
</form> </form>
</li> </li>
+5 -16
View File
@@ -4,20 +4,9 @@
<button type="submit">Add</button> <button type="submit">Add</button>
</form> </form>
<p data-hemx-slot="summary">{+ self.summary +}</p> <p data-hemx-slot="summary">{+ self.summary +}</p>
<div data-hemx-slot="todo_list"> <ul data-hemx-slot="todo_row">
<ul data-hemx-slot="todo_row"> <template h-for="row in &self.rows" h-key="row.id">
<template h-for="todo in &self.items" h-key="todo.id"> {+ row +}
<li data-hemx-slot="todo_row" +data-key="todo.id"> </template>
<span>{+ todo.title +}</span> </ul>
<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>
</section> </section>
+38 -2
View File
@@ -175,7 +175,9 @@ impl Resources {
if let Some(name) = static_attr(&node.attrs, "data-hemx-slot") { if let Some(name) = static_attr(&node.attrs, "data-hemx-slot") {
reject_unkeyed_loop(surface, node.scope, path, "slot", &name)?; 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); let canonical = canonical_symbol(root, path, &name);
self.insert_slot(canonical, name, component.clone(), keyed)?; self.insert_slot(canonical, name, component.clone(), keyed)?;
} }
@@ -1275,6 +1277,40 @@ fn event_ident(name: &str) -> Option<String> {
rust_ident(&name.replace(['-', ':'], "_")) 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 { fn is_inside_keyed_for(surface: &SurfaceDocument, mut scope: ScopeId) -> bool {
loop { loop {
let Some(current) = surface.scopes.get(scope.0 as usize) else { 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::create_dir_all(&templates).unwrap();
std::fs::write( std::fs::write(
templates.join("todos.heml"), 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(); .unwrap();