From 3dfb3b684f09389e7062f8c0b9eb778117c557fc Mon Sep 17 00:00:00 2001 From: slhx agent Date: Fri, 5 Jun 2026 08:56:36 +0200 Subject: [PATCH] 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 --- examples/v0/README.md | 2 +- examples/v0/src/lib.rs | 4 +- examples/v0/src/main.rs | 85 +++++++++---------- .../v0/templates/partials/todo_items.heml | 3 - examples/v0/templates/partials/todo_row.heml | 6 +- examples/v0/templates/todos.heml | 21 ++--- hemx-build/src/lib.rs | 40 ++++++++- 7 files changed, 91 insertions(+), 70 deletions(-) delete mode 100644 examples/v0/templates/partials/todo_items.heml diff --git a/examples/v0/README.md b/examples/v0/README.md index d8d6f86..f3a23ce 100644 --- a/examples/v0/README.md +++ b/examples/v0/README.md @@ -11,7 +11,7 @@ Open . 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 diff --git a/examples/v0/src/lib.rs b/examples/v0/src/lib.rs index 87be02a..18a0976 100644 --- a/examples/v0/src/lib.rs +++ b/examples/v0/src/lib.rs @@ -69,12 +69,12 @@ mod tests { fn form_handler_is_checked_against_hemplate_form() { #[hemx::handler] fn add_todo(_form: hemx::Form) -> 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 diff --git a/examples/v0/src/main.rs b/examples/v0/src/main.rs index bd2e3e2..1a6ac18 100644 --- a/examples/v0/src/main.rs +++ b/examples/v0/src/main.rs @@ -123,16 +123,32 @@ impl IntoHandlerFailure for AppError { } } -#[derive(Hemplate)] -#[hemplate = "partials"] -struct TodoItems { - items: Vec, +struct Todos { + summary: String, + rows: Vec, +} + +impl Hemplate for Todos { + fn render_into(&self, buf: &mut String) -> Result<(), hemplate::error::HemplateError> { + use std::fmt::Write as _; + + buf.push_str("
\n
\n \n \n

\n
\n\n

"); + write!(buf, "{}", hemplate::HtmlEscape(&self.summary))?; + buf.push_str("

\n\n
    \n"); + for row in &self.rows { + buf.push_str(" "); + row.render_into(buf)?; + buf.push('\n'); + } + buf.push_str("
\n
\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>, - Form(form): Form, - ) -> impl IntoEffect { - rename_todo_effect(state, form) - } - - #[hemx::handler] - async fn delete_todo_row( - State(state): State>, - Form(form): Form, - ) -> 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, 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, 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::(), "Ship v0"); + assert!(row.text().collect::().contains("Ship v0")); 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() diff --git a/examples/v0/templates/partials/todo_items.heml b/examples/v0/templates/partials/todo_items.heml deleted file mode 100644 index ecca0f0..0000000 --- a/examples/v0/templates/partials/todo_items.heml +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/examples/v0/templates/partials/todo_row.heml b/examples/v0/templates/partials/todo_row.heml index 775cda9..fe3cf18 100644 --- a/examples/v0/templates/partials/todo_row.heml +++ b/examples/v0/templates/partials/todo_row.heml @@ -1,10 +1,10 @@ -
  • +
  • {+ self.title +} -
    +
    -
    +
  • diff --git a/examples/v0/templates/todos.heml b/examples/v0/templates/todos.heml index 648aa67..dfac2ca 100644 --- a/examples/v0/templates/todos.heml +++ b/examples/v0/templates/todos.heml @@ -4,20 +4,9 @@

    {+ self.summary +}

    -
    -
      - -
    -
    +
      + +
    diff --git a/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index efe6b48..8964496 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -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 { 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#"
    "#, + r#"
    "#, ) .unwrap();