bodged refactor
This commit is contained in:
+8
-15
@@ -1,14 +1,6 @@
|
||||
use livevue_rs::RenderContext;
|
||||
use maud::{html, Markup};
|
||||
|
||||
/// Second page component: a simple "About" page.
|
||||
///
|
||||
/// This exists specifically to exercise:
|
||||
/// - The `tree` signal page routing (resolve_root match arm)
|
||||
/// - SPA-like navigation via tree signal mutation
|
||||
/// - Round-trip: TodoList → About → TodoList without full page reload
|
||||
///
|
||||
/// In a real app this might show user info, settings, etc.
|
||||
pub async fn about(_cx: &mut RenderContext) -> anyhow::Result<Markup> {
|
||||
Ok(html! {
|
||||
div.about-page {
|
||||
@@ -16,7 +8,7 @@ pub async fn about(_cx: &mut RenderContext) -> anyhow::Result<Markup> {
|
||||
|
||||
nav {
|
||||
button.nav-link
|
||||
data-on:click="$tree = {page: 'TodoList'}; @post('/render')" {
|
||||
data-on:click="$tree = {page: 'TodoList'}" {
|
||||
"Todos"
|
||||
}
|
||||
" | "
|
||||
@@ -34,14 +26,15 @@ pub async fn about(_cx: &mut RenderContext) -> anyhow::Result<Markup> {
|
||||
strong { "full rerender is cheap" }
|
||||
", so skip diffing, skip partial updates, skip client-side state management."
|
||||
}
|
||||
h2 { "v0 Status" }
|
||||
h2 { "Architecture" }
|
||||
ul {
|
||||
li { "Single process, single node" }
|
||||
li { "SQLite for storage" }
|
||||
li { "Subscription keys collected (Phase 2 seam)" }
|
||||
li { "No cache, no auth, no WAL consumer" }
|
||||
li { "Actions post signals → 202, no response body" }
|
||||
li { "Rerenders come down the SSE pipe from the fanout task" }
|
||||
li { "KV store writes auto-publish broadcast invalidation events" }
|
||||
li { "SubscriptionRegistry maps CacheKeys → connections" }
|
||||
li { "Clock ticks via " code { "store.kv.set(\"clock\", ...)" } " every second" }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+51
-159
@@ -1,6 +1,6 @@
|
||||
use livevue_rs::{
|
||||
AppState, CacheKey, ComponentTree, RenderContext, SharedState, SignalStore,
|
||||
format_patch_elements, sse_handler,
|
||||
ingest_signals, sse_handler,
|
||||
};
|
||||
|
||||
use axum::extract::{Query as AxumQuery, State};
|
||||
@@ -19,7 +19,6 @@ pub fn router(state: SharedState) -> Router {
|
||||
.route("/", get(initial_page_load))
|
||||
.route("/about", get(initial_about_load))
|
||||
.route("/sse", get(sse_handler))
|
||||
.route("/render", post(render_handler))
|
||||
.route("/action/add_todo", post(action_add_todo))
|
||||
.route("/action/toggle_todo", post(action_toggle_todo))
|
||||
.route("/action/delete_todo", post(action_delete_todo))
|
||||
@@ -30,14 +29,7 @@ pub fn router(state: SharedState) -> Router {
|
||||
// Document shell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Full HTML document for initial page load.
|
||||
///
|
||||
/// DataStar v1 contract:
|
||||
/// - `data-signals` — initialize signals (JS object literal syntax)
|
||||
/// - `data-on-signal-patch__debounce.300ms` — fires when any signal is patched,
|
||||
/// debounced 300ms, posts all non-_ signals as JSON body to /render
|
||||
/// - `data-init` — fires when element enters DOM, opens long-lived SSE pipe
|
||||
fn document_shell(conn_id: Uuid, initial_signals: &str, inner_html: &str) -> String {
|
||||
fn document_shell(conn_id: Uuid) -> String {
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -45,47 +37,42 @@ fn document_shell(conn_id: Uuid, initial_signals: &str, inner_html: &str) -> Str
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LiveVue.rs — Todo</title>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.0-RC.8/bundles/datastar.js"></script>
|
||||
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.8/bundles/datastar.js"></script>
|
||||
<style>
|
||||
{css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{app}
|
||||
<body
|
||||
data-init="@get('/sse?conn={conn}')">
|
||||
<div id="app">
|
||||
<div/>
|
||||
</body>
|
||||
</html>"#,
|
||||
css = INLINE_CSS,
|
||||
app = app_wrapper(conn_id, initial_signals, inner_html),
|
||||
conn = conn_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// The `<div id="app">` wrapper with DataStar bindings.
|
||||
///
|
||||
/// Extracted separately so `/render` can return just this element as
|
||||
/// `text/html` — DataStar morphs it into the existing DOM by id.
|
||||
fn app_wrapper(conn_id: Uuid, signals: &str, inner_html: &str) -> String {
|
||||
/// Used by the fanout task to wrap rendered inner HTML for morphing.
|
||||
pub fn app_wrapper(conn_id: Uuid, signals: &str, inner_html: &str) -> String {
|
||||
format!(
|
||||
r#"<div id="app"
|
||||
data-signals='{signals}'
|
||||
data-on-signal-patch="@post('/render')"
|
||||
data-on-init="@get('/sse?conn={conn}')">
|
||||
{inner}
|
||||
data-signals='{signals}'>
|
||||
{inner}
|
||||
<pre data-json-signals></pre>
|
||||
</div>"#,
|
||||
// Could __debounce.300ms on the signal patch
|
||||
</div>"#,
|
||||
signals = signals,
|
||||
conn = conn_id,
|
||||
inner = inner_html,
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimal CSS for the todo app.
|
||||
const INLINE_CSS: &str = r#"
|
||||
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; background: #fafafa; color: #333; }
|
||||
h1 { color: #2563eb; }
|
||||
nav { margin-bottom: 1.5rem; }
|
||||
.nav-link { background: none; border: none; color: #2563eb; cursor: pointer; text-decoration: underline; font-size: inherit; padding: 0; }
|
||||
.nav-current { font-weight: bold; }
|
||||
.clock { font-variant-numeric: tabular-nums; color: #555; font-size: 0.95rem; margin-bottom: 1rem; }
|
||||
.add-form { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
||||
.add-form input { flex: 1; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; }
|
||||
.add-form button { padding: 0.5rem 1rem; background: #2563eb; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
@@ -101,42 +88,18 @@ const INLINE_CSS: &str = r#"
|
||||
"#;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page resolution
|
||||
// Page resolution + render pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn resolve_and_render(cx: &mut RenderContext) -> anyhow::Result<String> {
|
||||
pub async fn resolve_and_render(cx: &mut RenderContext) -> anyhow::Result<String> {
|
||||
let markup = match cx.tree.page.as_str() {
|
||||
"TodoList" => crate::todo_list::todo_list(cx).await?,
|
||||
"About" => crate::about::about(cx).await?,
|
||||
other => maud::html! {
|
||||
div {
|
||||
h1 { "404 — Unknown page" }
|
||||
p { "No component registered for: " (other) }
|
||||
button.nav-link
|
||||
data-on-click="$tree = {page: 'TodoList'}; @post('/render')" {
|
||||
"Go to Todos"
|
||||
}
|
||||
}
|
||||
},
|
||||
other => crate::todo_list::todo_list(cx).await?
|
||||
};
|
||||
Ok(markup.into_string())
|
||||
}
|
||||
|
||||
/// Shared render pipeline. Returns `(rendered_inner_html, subscription_keys, signals_json)`.
|
||||
async fn render_page(
|
||||
db: &sqlx::SqlitePool,
|
||||
conn_id: Uuid,
|
||||
signals: SignalStore,
|
||||
tree: ComponentTree,
|
||||
) -> anyhow::Result<(String, Vec<CacheKey>, String)> {
|
||||
let signals_json = signals.to_json_string();
|
||||
let mut cx = RenderContext::new(conn_id, signals, tree, db.clone());
|
||||
let html = resolve_and_render(&mut cx).await?;
|
||||
let keys = cx.take_subscription_keys();
|
||||
Ok((html, keys, signals_json))
|
||||
}
|
||||
|
||||
/// Extract conn_id and tree from a signal store.
|
||||
fn extract_conn_and_tree(signals: &SignalStore) -> (Uuid, ComponentTree) {
|
||||
let conn_id = signals
|
||||
.get("connId")
|
||||
@@ -153,133 +116,65 @@ fn extract_conn_and_tree(signals: &SignalStore) -> (Uuid, ComponentTree) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// Initial page load handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /` — Initial page load for the todo list.
|
||||
async fn initial_page_load(State(state): State<SharedState>) -> impl IntoResponse {
|
||||
let conn_id = Uuid::new_v4();
|
||||
|
||||
let tree = ComponentTree {
|
||||
page: "TodoList".into(),
|
||||
..Default::default()
|
||||
};
|
||||
// Seed the initial signal state so the SSE handler can trigger the first
|
||||
// render as soon as the pipe opens, without a client round-trip.
|
||||
let tree = ComponentTree { page: "TodoList".into(), ..Default::default() };
|
||||
let signals = SignalStore::from_pairs(vec![
|
||||
("connId".into(), serde_json::json!(conn_id.to_string())),
|
||||
("tree".into(), serde_json::to_value(&tree).unwrap()),
|
||||
("newTodo".into(), serde_json::json!("")),
|
||||
]);
|
||||
state.connections.update_signals(&conn_id, signals.to_json_string());
|
||||
|
||||
let initial_signals_attr = signals.to_data_signals_attr();
|
||||
|
||||
match render_page(&state.db, conn_id, signals, tree).await {
|
||||
Ok((html, _keys, _signals_json)) => {
|
||||
Html(document_shell(conn_id, &initial_signals_attr, &html))
|
||||
}
|
||||
Err(e) => Html(document_shell(
|
||||
conn_id,
|
||||
&initial_signals_attr,
|
||||
&format!("<p>Render error: {}</p>", e),
|
||||
)),
|
||||
}
|
||||
Html(document_shell(conn_id))
|
||||
}
|
||||
|
||||
/// `GET /about` — Initial page load for the about page.
|
||||
async fn initial_about_load(State(state): State<SharedState>) -> impl IntoResponse {
|
||||
let conn_id = Uuid::new_v4();
|
||||
|
||||
let tree = ComponentTree {
|
||||
page: "About".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let tree = ComponentTree { page: "About".into(), ..Default::default() };
|
||||
let signals = SignalStore::from_pairs(vec![
|
||||
("connId".into(), serde_json::json!(conn_id.to_string())),
|
||||
("tree".into(), serde_json::to_value(&tree).unwrap()),
|
||||
]);
|
||||
|
||||
let initial_signals_attr = signals.to_data_signals_attr();
|
||||
|
||||
match render_page(&state.db, conn_id, signals, tree).await {
|
||||
Ok((html, _keys, _signals_json)) => {
|
||||
Html(document_shell(conn_id, &initial_signals_attr, &html))
|
||||
}
|
||||
Err(e) => Html(document_shell(
|
||||
conn_id,
|
||||
&initial_signals_attr,
|
||||
&format!("<p>Render error: {}</p>", e),
|
||||
)),
|
||||
}
|
||||
state.connections.update_signals(&conn_id, signals.to_json_string());
|
||||
Html(document_shell(conn_id))
|
||||
}
|
||||
|
||||
/// `POST /render` — Signal-triggered rerender.
|
||||
///
|
||||
/// DataStar sends all non-`_` signals as JSON body on every signal patch.
|
||||
/// Returns `text/html` with the full `<div id="app">` wrapper — DataStar
|
||||
/// morphs it into the existing DOM by matching the element id.
|
||||
async fn render_handler(
|
||||
State(state): State<SharedState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let signals = SignalStore::from_json(body);
|
||||
let (conn_id, tree) = extract_conn_and_tree(&signals);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
match render_page(&state.db, conn_id, signals.clone(), tree).await {
|
||||
Ok((html, keys, signals_json)) => {
|
||||
state.connections.update_signals(&conn_id, signals_json.clone());
|
||||
state.subscriptions.update(conn_id, keys);
|
||||
Html(app_wrapper(conn_id, &signals_json, &html))
|
||||
}
|
||||
Err(e) => Html(app_wrapper(
|
||||
conn_id,
|
||||
&signals.to_json_string(),
|
||||
&format!("<p>Render error: {}</p>", e),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query params for action endpoints that need an entity id.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ActionIdParams {
|
||||
id: i64,
|
||||
}
|
||||
|
||||
/// Helper: run a mutation, push the rerendered page down the SSE pipe, return 200.
|
||||
///
|
||||
/// Actions do not return a body — the result comes down the SSE pipe as a
|
||||
/// `datastar-patch-elements` event.
|
||||
async fn action_then_push(
|
||||
/// Ingest signals, run a mutation, then publish an invalidation event so the
|
||||
/// fanout task handles the rerender. Returns 202 Accepted.
|
||||
async fn action_then_publish(
|
||||
state: &AppState,
|
||||
signals: SignalStore,
|
||||
body: serde_json::Value,
|
||||
mutation: impl std::future::Future<Output = anyhow::Result<()>> + Send,
|
||||
invalidation_key: CacheKey,
|
||||
) -> StatusCode {
|
||||
let (conn_id, tree) = extract_conn_and_tree(&signals);
|
||||
let signals = ingest_signals(state, extract_conn_and_tree(&SignalStore::from_json(body.clone())).0, body);
|
||||
let _ = signals; // signals are stored; mutation drives the rerender
|
||||
|
||||
if let Err(e) = mutation.await {
|
||||
eprintln!("Action error for {}: {}", conn_id, e);
|
||||
eprintln!("Action mutation error: {}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
|
||||
match render_page(&state.db, conn_id, signals, tree).await {
|
||||
Ok((html, keys, signals_json)) => {
|
||||
state.connections.update_signals(&conn_id, signals_json.clone());
|
||||
state.subscriptions.update(conn_id, keys);
|
||||
|
||||
if let Some(sender) = state.connections.get_sender(&conn_id) {
|
||||
let event = format_patch_elements(&app_wrapper(conn_id, &signals_json, &html));
|
||||
// Non-blocking: if the pipe is closed the cleanup task handles it
|
||||
let _ = sender.try_send(event);
|
||||
}
|
||||
|
||||
StatusCode::OK
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Render error for {}: {}", conn_id, e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
state.store.publish(invalidation_key);
|
||||
StatusCode::ACCEPTED
|
||||
}
|
||||
|
||||
/// `POST /action/add_todo` — Insert a new todo, push rerender down SSE pipe.
|
||||
async fn action_add_todo(
|
||||
State(state): State<SharedState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
@@ -290,55 +185,52 @@ async fn action_add_todo(
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let db = state.store.db.clone();
|
||||
|
||||
let signals = SignalStore::from_json(body);
|
||||
let db = state.db.clone();
|
||||
|
||||
action_then_push(
|
||||
action_then_publish(
|
||||
&state,
|
||||
signals,
|
||||
body,
|
||||
async move {
|
||||
if !title.is_empty() {
|
||||
crate::queries::create_todo(&db, &title).await?;
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
CacheKey::Table { table: "todos" },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `POST /action/toggle_todo?id=<i64>` — Toggle completion, push rerender.
|
||||
async fn action_toggle_todo(
|
||||
State(state): State<SharedState>,
|
||||
AxumQuery(params): AxumQuery<ActionIdParams>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let signals = SignalStore::from_json(body);
|
||||
let db = state.db.clone();
|
||||
let db = state.store.db.clone();
|
||||
let id = params.id;
|
||||
|
||||
action_then_push(
|
||||
action_then_publish(
|
||||
&state,
|
||||
signals,
|
||||
body,
|
||||
async move { crate::queries::toggle_todo(&db, id).await },
|
||||
CacheKey::Table { table: "todos" },
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// `POST /action/delete_todo?id=<i64>` — Delete a todo, push rerender.
|
||||
async fn action_delete_todo(
|
||||
State(state): State<SharedState>,
|
||||
AxumQuery(params): AxumQuery<ActionIdParams>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let signals = SignalStore::from_json(body);
|
||||
let db = state.db.clone();
|
||||
let db = state.store.db.clone();
|
||||
let id = params.id;
|
||||
|
||||
action_then_push(
|
||||
action_then_publish(
|
||||
&state,
|
||||
signals,
|
||||
body,
|
||||
async move { crate::queries::delete_todo(&db, id).await },
|
||||
CacheKey::Table { table: "todos" },
|
||||
)
|
||||
.await
|
||||
}
|
||||
+59
-11
@@ -3,8 +3,10 @@ mod queries;
|
||||
mod todo_list;
|
||||
mod about;
|
||||
|
||||
use livevue_rs::{AppState, ConnectionManager, SubscriptionRegistry};
|
||||
use livevue_rs::{AppState, CacheKey, RenderContext, Store, spawn_fanout};
|
||||
use crate::app::{app_wrapper, resolve_and_render};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
@@ -12,10 +14,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
// Database setup
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// SQLite with create-if-missing. In-memory `:memory:` also works for dev.
|
||||
let db = sqlx::SqlitePool::connect("sqlite:todo.db?mode=rwc").await?;
|
||||
|
||||
// Run migrations inline (no migration files needed for v0).
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS todos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -26,7 +26,6 @@ async fn main() -> anyhow::Result<()> {
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Seed a few todos if the table is empty (nice for first run)
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM todos")
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
@@ -39,14 +38,65 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Store
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
let store = Store::new(db);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Render function
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// The render_fn is the app's half of the framework contract. It receives
|
||||
// a RenderContext (with signals, tree, store access) and returns the
|
||||
// rendered inner HTML plus the accumulated subscription keys.
|
||||
//
|
||||
// The fanout task calls this for every server-initiated rerender.
|
||||
let render_fn: livevue_rs::RenderFn = Arc::new(|mut cx: RenderContext| {
|
||||
Box::pin(async move {
|
||||
println!("Render Called");
|
||||
let signals_json = cx.signals.to_json_string();
|
||||
let conn_id = cx.connection_id;
|
||||
let html = resolve_and_render(&mut cx).await?;
|
||||
let keys = cx.take_subscription_keys();
|
||||
// Wrap in app_wrapper so DataStar morphs the full #app element.
|
||||
// Signals are re-emitted from the stored json so the client stays
|
||||
// in sync even on server-initiated rerenders.
|
||||
let wrapped = app_wrapper(conn_id, &signals_json, &html);
|
||||
Ok((wrapped, keys))
|
||||
})
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Application state
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
db,
|
||||
connections: ConnectionManager::new(),
|
||||
subscriptions: SubscriptionRegistry::new(),
|
||||
let state = AppState::new(store.clone(), render_fn);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fanout task
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
spawn_fanout(state.clone());
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Clock ticker
|
||||
//
|
||||
// Publishes CacheKey::Channel { key: "clock" } every second. Any
|
||||
// connection whose last render called cx.kv_run("clock") will be
|
||||
// scheduled for a rerender by the fanout task.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
let clock_store = store.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let now = chrono::Utc::now().format("%H:%M:%S UTC").to_string();
|
||||
// set() writes the value and publishes the invalidation event.
|
||||
clock_store.kv.set("clock", serde_json::json!(now));
|
||||
}
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -54,11 +104,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
let app = app::router(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||
println!("Todo example listening on http://localhost:3000");
|
||||
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+14
-48
@@ -2,24 +2,17 @@ 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
|
||||
// Fetch all todos (registers CacheKey::Table { table: "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
|
||||
// Read the clock from KV (registers CacheKey::Channel { key: "clock" })
|
||||
let clock = cx
|
||||
.kv_run("clock")
|
||||
.await
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
|
||||
let new_todo = cx.signal("newTodo", global());
|
||||
|
||||
let todo_count = todos.len();
|
||||
@@ -29,21 +22,17 @@ pub async fn todo_list(cx: &mut RenderContext) -> anyhow::Result<Markup> {
|
||||
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'};" {
|
||||
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).
|
||||
p.clock { "🕐 " (clock) }
|
||||
|
||||
div.add-form {
|
||||
input
|
||||
type="text"
|
||||
@@ -57,12 +46,10 @@ pub async fn todo_list(cx: &mut RenderContext) -> anyhow::Result<Markup> {
|
||||
}
|
||||
}
|
||||
|
||||
// Stats
|
||||
p.stats {
|
||||
(format!("{} items, {} completed", todo_count, done_count))
|
||||
}
|
||||
|
||||
// Todo items
|
||||
ul.todo-list {
|
||||
@for todo in &todos {
|
||||
(todo_item(cx, todo))
|
||||
@@ -76,21 +63,9 @@ pub async fn todo_list(cx: &mut RenderContext) -> anyhow::Result<Markup> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -99,25 +74,16 @@ fn todo_item(cx: &mut RenderContext, todo: &Todo) -> Markup {
|
||||
|
||||
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
|
||||
span class=(title_class) { (todo.title) }
|
||||
button.delete-btn
|
||||
data-on:click=(format!("@post('/action/delete_todo?id={}')", todo.id))
|
||||
{
|
||||
data-on:click=(format!("@post('/action/delete_todo?id={}')", todo.id)) {
|
||||
"×"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user