Files
livevue-rs/examples/todo/todo_list.rs
T
2026-03-08 17:22:13 +01:00

124 lines
4.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::queries::{ListTodos, Todo};
use livevue_rs::{RenderContext, global, mangle};
use maud::{html, Markup};
/// Root page component: renders the full todo list with add form.
///
/// Reads signals:
/// - `newTodo` (global, server-relevant): the text input value
///
/// Exercises:
/// - `cx.run()` for data fetching
/// - `cx.signal()` with `global()` scoping + `Signal::val` in expressions
/// - `cx.signal()` with `mangle().client_only()` scoping (in todo_item)
/// - DataStar `data-bind`, `data-on:click`, `data-attr` attributes
/// - `@post()` actions for mutations
pub async fn todo_list(cx: &mut RenderContext) -> anyhow::Result<Markup> {
// Fetch all todos
let todos = cx.run(ListTodos).await?;
// Global signal for the "new todo" input. This call does two things:
// 1. Records "newTodo" as a touched signal (for demangling tracking)
// 2. Returns a Signal struct whose .val is "$newTodo" for expressions
let new_todo = cx.signal("newTodo", global());
let todo_count = todos.len();
let done_count = todos.iter().filter(|t| t.completed).count();
Ok(html! {
div.todo-app {
h1 { "LiveVue.rs — Todo List" }
// Navigation link to About page (exercises tree signal + page routing)
nav {
span.nav-current { "Todos" }
" | "
button.nav-link
data-on:click="$tree = {page: 'About'};" {
"About"
}
}
// Add todo form.
// data-bind="newTodo" binds the input to the $newTodo signal.
// Value syntax is used instead of data-bind-newTodo because HTML
// normalises attribute names to lowercase, which would corrupt the
// camelCase signal name (newTodo → newtodo).
div.add-form {
input
type="text"
placeholder="What needs to be done?"
data-bind="newTodo"
;
button
data-on:click="@post('/action/add_todo')"
data-attr=(format!("{{disabled: {}.trim() === ''}}", new_todo.val)) {
"Add"
}
}
// Stats
p.stats {
(format!("{} items, {} completed", todo_count, done_count))
}
// Todo items
ul.todo-list {
@for todo in &todos {
(todo_item(cx, todo))
}
}
@if todos.is_empty() {
p.empty { "No todos yet. Add one above!" }
}
}
})
}
/// Renders a single todo item.
///
/// This is a static child component — called directly from `todo_list`,
/// not resolved through the component registry.
///
/// Demonstrates per-instance, client-only signal via `mangle().client_only()`.
fn todo_item(cx: &mut RenderContext, todo: &Todo) -> Markup {
// Per-instance, client-only signal for edit mode.
// Not used in v0 rendering, but demonstrates the mangled signal API
// and ensures it's tracked as touched. Produces: "_editing__<id>"
let _editing = cx.signal("editing", mangle(todo.id).client_only());
// Build class string server-side. Since we do a full rerender on every
// state change, there's no need for data-class here — the server knows
// whether the todo is completed.
let title_class = if todo.completed {
"todo-title completed"
} else {
"todo-title"
};
html! {
li.todo-item id=(format!("todo-{}", todo.id)) {
// Checkbox to toggle completion.
// `checked[expr]` is maud's syntax for conditional boolean attributes.
input
type="checkbox"
checked[todo.completed]
data-on:click=(format!("@post('/action/toggle_todo?id={}')", todo.id))
;
// Title with strikethrough when completed (class set server-side)
span class=(title_class) {
(todo.title)
}
// Delete button
button.delete-btn
data-on:click=(format!("@post('/action/delete_todo?id={}')", todo.id))
{
"×"
}
}
}
}