feat(v0): show reusable partial feedback

Add a generated todo notice slot to the v0 normal path so initial render, append, replace, remove, and dynamic Vec<IntoEffect> refreshes visibly compose one reusable TodoRow partial with adjacent generated UI feedback.

req: canonical_authoring/003
This commit is contained in:
slhx agent
2026-06-12 15:01:20 +02:00
parent d2049c559a
commit cac23578e2
3 changed files with 31 additions and 11 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 with one `TodoRow` hemplate partial reused by the initial page render, generated row append/replace/remove commands, and dynamic `Vec<T: IntoEffect>` row batches
- todo form submission with one `TodoRow` hemplate partial, a notice slot, handlers, generated row append/replace/remove commands, and dynamic `Vec<T: IntoEffect>` row batches reused across initial render and updates
- wizard step updates
- login form feedback
- page swap/navigation
+29 -10
View File
@@ -125,6 +125,7 @@ impl IntoHandlerFailure for AppError {
struct Todos {
summary: String,
notice: String,
rows: Vec<TodoRow>,
}
@@ -134,6 +135,8 @@ impl Hemplate for Todos {
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 <p data-hemx-slot=\"notice\">");
write!(buf, "{}", hemplate::HtmlEscape(&self.notice))?;
buf.push_str("</p>\n\n <ul data-hemx-slot=\"todo_row\">\n");
for row in &self.rows {
buf.push_str(" ");
@@ -313,6 +316,7 @@ mod todo_handlers {
todos::new_todo.error("title", "Title required"),
todos::new_todo.focus("title"),
todos::summary.set(todo_summary(&state.todos.lock().unwrap())),
todos::notice.set("Add a title to create a todo"),
));
}
if form.title.as_str() == "fail-db" {
@@ -330,9 +334,10 @@ mod todo_handlers {
Ok((
todos::todo_row.append(TodoRow {
id: TodoId(id),
title,
title: title.clone(),
}),
todos::summary.set(summary),
todos::notice.set(format!("Added {title}")),
todos::new_todo.clear(),
))
}
@@ -403,10 +408,13 @@ fn rename_todo_effect(state: Arc<ExampleState>, form: RenameTodo) -> impl IntoEf
.find(|todo| todo.id == form.id.0)
.map(|todo| {
todo.title = form.title.into_string();
todos::todo_row.replace(TodoRow {
id: form.id,
title: todo.title.clone(),
})
(
todos::todo_row.replace(TodoRow {
id: form.id,
title: todo.title.clone(),
}),
todos::notice.set("Todo renamed"),
)
})
}
@@ -420,6 +428,7 @@ fn delete_todo_effect(state: Arc<ExampleState>, form: DeleteTodo) -> impl IntoEf
(
deleted.then(|| todos::todo_row.remove(form.id)),
deleted.then(|| todos::summary.set(summary)),
deleted.then(|| todos::notice.set("Todo deleted")),
)
}
@@ -427,6 +436,7 @@ fn todos_view(todos: &[TodoRecord]) -> Todos {
// req: html_safety/002 req: view/001 req: canonical_authoring/003
Todos {
summary: todo_summary(todos),
notice: String::new(),
rows: todo_rows(todos),
}
}
@@ -438,7 +448,11 @@ fn refresh_todo_rows_effect(todos: &[TodoRecord]) -> impl IntoEffect {
.into_iter()
.map(|row| todos::todo_row.replace(row))
.collect::<Vec<_>>();
(row_updates, todos::summary.set(todo_summary(todos)))
(
row_updates,
todos::summary.set(todo_summary(todos)),
todos::notice.set("Todos refreshed"),
)
}
fn todo_rows(todos: &[TodoRecord]) -> Vec<TodoRow> {
@@ -626,10 +640,11 @@ mod tests {
let refresh =
inspect_batch(refresh_todo_rows_effect(&todos).into_batch(ui::BUILD_FINGERPRINT));
assert_eq!(refresh.op_count(), 3);
assert_eq!(refresh.op_count(), 4);
assert!(refresh.replaces_keyed_html_containing(todos::todo_row, "7", "Ship v0"));
assert!(refresh.replaces_keyed_html_containing(todos::todo_row, "8", "Write docs"));
assert!(refresh.updates_text(todos::summary));
assert!(refresh.updates_text(todos::notice));
}
// req: html_safety/002 req: view/001 req: test/005
@@ -688,9 +703,11 @@ mod tests {
.batch,
);
assert_eq!(state.todos.lock().unwrap()[0].title, "Ship v0");
assert_eq!(add.op_count(), 3);
assert_eq!(add.op_count(), 4);
assert!(add.inserts_html_containing(todos::todo_row, "1", "Ship v0"));
assert!(add.updates_text(todos::summary));
assert!(add.updates_text(todos::notice));
assert!(add.payload_contains("Added Ship v0"));
assert!(add.resets_form(todos::new_todo));
let rename = inspect_batch(
@@ -704,8 +721,9 @@ mod tests {
.batch,
);
assert_eq!(state.todos.lock().unwrap()[0].title, "Ship 1.0");
assert_eq!(rename.op_count(), 1);
assert_eq!(rename.op_count(), 2);
assert!(rename.replaces_keyed_html_containing(todos::todo_row, "1", "Ship 1.0"));
assert!(rename.updates_text(todos::notice));
let delete = inspect_batch(
InteractionRequest::from(form(todo_row::delete_todo, &[("id", "1")]))
@@ -715,9 +733,10 @@ mod tests {
.batch,
);
assert!(state.todos.lock().unwrap().is_empty());
assert_eq!(delete.op_count(), 2);
assert_eq!(delete.op_count(), 3);
assert!(delete.removes_key(todos::todo_row, "1"));
assert!(delete.updates_text(todos::summary));
assert!(delete.updates_text(todos::notice));
let missing_delete = inspect_batch(
InteractionRequest::from(form(todo_row::delete_todo, &[("id", "99")]))
+1
View File
@@ -4,6 +4,7 @@
<button type="submit">Add</button>
</form>
<p data-hemx-slot="summary">{+ self.summary +}</p>
<p data-hemx-slot="notice">{+ self.notice +}</p>
<ul data-hemx-slot="todo_row">
<template h-for="row in &self.rows" h-key="row.id">
{+ row +}