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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user