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