feat(api): streamline generated app authoring
Move the canonical examples toward generated component-root helpers, typed form decoding, async/state handler registration, and derive-driven app/component registry wiring. Tighten requirements and diagnostics for the server-first, selectorless authoring path. Verified with cargo run -p slhx-xtask -- test, cargo check --workspace, redgate list, redgate refs, redgate health --strict, and git diff --check. req: canonical/001 req: canonical/003 req: canonical/004 req: dx/002 req: derive_app/001 req: component/003 req: form/004 req: axum_integration/003
This commit is contained in:
+43
-34
@@ -5,7 +5,7 @@ pub mod ui {}
|
||||
mod tests {
|
||||
use super::ui::{auth, counter, notifications, page_swap, todos, wizard};
|
||||
use hemplate::Hemplate;
|
||||
use slhx::{push, Effect, IntoEffect, NavigateMode, Payload};
|
||||
use slhx::{push, IntoEffect};
|
||||
use slhx_test::inspect;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -36,9 +36,16 @@ mod tests {
|
||||
#[derive(Hemplate)]
|
||||
#[hemplate = "partials"]
|
||||
struct TodoRow {
|
||||
id: u64,
|
||||
title: String,
|
||||
}
|
||||
|
||||
impl slhx::KeyedPartial for TodoRow {
|
||||
fn slhx_key(&self) -> String {
|
||||
self.id.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Hemplate)]
|
||||
#[hemplate = "partials"]
|
||||
struct DocsContent {
|
||||
@@ -49,13 +56,12 @@ mod tests {
|
||||
#[test]
|
||||
fn counter_updates_a_generated_slot() {
|
||||
fn increment(count: u64) -> impl IntoEffect {
|
||||
counter::targets::counter_value.text(count + 1)
|
||||
counter::counter_value.set(count + 1)
|
||||
}
|
||||
|
||||
let effect = inspect(increment(1));
|
||||
|
||||
assert!(effect.has_slot(counter::slots::counter_value));
|
||||
assert!(matches!(effect.ops(), [Effect::Put { .. }]));
|
||||
assert!(effect.updates_text(counter::counter_value));
|
||||
}
|
||||
|
||||
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
|
||||
@@ -63,27 +69,33 @@ mod tests {
|
||||
fn form_handler_is_checked_against_hemplate_form() {
|
||||
#[slhx::handler]
|
||||
fn add_todo(_form: slhx::Form<TodoInput>) -> impl IntoEffect {
|
||||
todos::slots::todo_list.text("queued")
|
||||
todos::todo_list.set("queued")
|
||||
}
|
||||
|
||||
let effect = inspect(add_todo(TodoInput::FORM));
|
||||
|
||||
assert!(effect.has_slot(todos::slots::todo_list));
|
||||
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
|
||||
assert!(effect.updates_text(todos::todo_list));
|
||||
}
|
||||
|
||||
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/002
|
||||
#[test]
|
||||
fn todos_append_keyed_rows_from_form_input() {
|
||||
fn add_todo(input: TodoInput) -> impl IntoEffect {
|
||||
let todo = Todo { id: 7, title: input.title };
|
||||
todos::targets::todo_row.append(todo.id, &TodoRow { title: todo.title })
|
||||
let todo = Todo {
|
||||
id: 7,
|
||||
title: input.title,
|
||||
};
|
||||
todos::todo_row.append(TodoRow {
|
||||
id: todo.id,
|
||||
title: todo.title,
|
||||
})
|
||||
}
|
||||
|
||||
let effect = inspect(add_todo(TodoInput { title: "Ship v0".into() }));
|
||||
let effect = inspect(add_todo(TodoInput {
|
||||
title: "Ship v0".into(),
|
||||
}));
|
||||
|
||||
assert!(effect.has_resource(todos::slots::todo_row.id()));
|
||||
assert!(matches!(effect.ops(), [Effect::Insert { key, payload, .. }] if key == "7" && matches!(payload, Payload::Html(_))));
|
||||
assert!(effect.inserts_html_containing(todos::todo_row, 7, "Ship v0"));
|
||||
}
|
||||
|
||||
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
|
||||
@@ -91,26 +103,24 @@ mod tests {
|
||||
fn wizard_form_handler_is_checked_against_hemplate_form() {
|
||||
#[slhx::handler]
|
||||
fn next_step(_form: slhx::Form<WizardInput>) -> impl IntoEffect {
|
||||
wizard::slots::wizard_step.text("queued")
|
||||
wizard::wizard_step.set("queued")
|
||||
}
|
||||
|
||||
let effect = inspect(next_step(WizardInput::FORM));
|
||||
|
||||
assert!(effect.has_slot(wizard::slots::wizard_step));
|
||||
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
|
||||
assert!(effect.updates_text(wizard::wizard_step));
|
||||
}
|
||||
|
||||
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/004
|
||||
#[test]
|
||||
fn wizard_form_swaps_the_current_step() {
|
||||
fn next_step(input: WizardInput) -> impl IntoEffect {
|
||||
wizard::slots::wizard_step.text(format!("Step {}", input.step + 1))
|
||||
wizard::wizard_step.set(format!("Step {}", input.step + 1))
|
||||
}
|
||||
|
||||
let effect = inspect(next_step(WizardInput { step: 1 }));
|
||||
|
||||
assert!(effect.has_slot(wizard::slots::wizard_step));
|
||||
assert!(matches!(effect.ops(), [Effect::Put { .. }]));
|
||||
assert!(effect.updates_text(wizard::wizard_step));
|
||||
}
|
||||
|
||||
// req: examples/001 req: page_swap/002 req: page_swap/003 req: build/001 req: build/005 req: view/001
|
||||
@@ -118,20 +128,19 @@ mod tests {
|
||||
fn page_swap_updates_content_and_history() {
|
||||
fn load_docs() -> impl IntoEffect {
|
||||
(
|
||||
page_swap::put(page_swap::slots::content, &DocsContent {
|
||||
page_swap::content.put(&DocsContent {
|
||||
message: "This page was swapped.",
|
||||
}),
|
||||
page_swap::slots::title.text("Docs"),
|
||||
page_swap::title.set("Docs"),
|
||||
push("/docs"),
|
||||
)
|
||||
}
|
||||
|
||||
let effect = inspect(load_docs());
|
||||
|
||||
assert!(effect.has_slot(page_swap::slots::content));
|
||||
assert!(effect.has_slot(page_swap::slots::title));
|
||||
assert!(matches!(effect.ops().first(), Some(Effect::Put { payload: Payload::Html(_), .. })));
|
||||
assert!(matches!(effect.ops().last(), Some(Effect::Navigate { url, mode: NavigateMode::Push, .. }) if url == "/docs"));
|
||||
assert!(effect.updates_html_containing(page_swap::content, "This page was swapped."));
|
||||
assert!(effect.updates_text(page_swap::title));
|
||||
assert!(effect.pushes_to("/docs"));
|
||||
}
|
||||
|
||||
// req: examples/001 req: form/001 req: form/004 req: form/006 req: derive_handler/003
|
||||
@@ -139,13 +148,12 @@ mod tests {
|
||||
fn auth_form_handler_is_checked_against_hemplate_form() {
|
||||
#[slhx::handler]
|
||||
fn login(_form: slhx::Form<Credentials>) -> impl IntoEffect {
|
||||
auth::slots::login_status.text("queued")
|
||||
auth::login_status.set("queued")
|
||||
}
|
||||
|
||||
let effect = inspect(login(Credentials::FORM));
|
||||
|
||||
assert!(effect.has_slot(auth::slots::login_status));
|
||||
assert!(matches!(effect.ops(), [Effect::Put { payload: Payload::Text(_), .. }]));
|
||||
assert!(effect.updates_text(auth::login_status));
|
||||
}
|
||||
|
||||
// req: examples/001 req: progressive_disclosure/001 req: build/001 req: build/005 req: codegen/004
|
||||
@@ -158,25 +166,26 @@ mod tests {
|
||||
"Try again"
|
||||
};
|
||||
|
||||
auth::slots::login_status.text(status)
|
||||
auth::login_status.set(status)
|
||||
}
|
||||
|
||||
let effect = inspect(login(Credentials { email: "demo@example.com".into(), password: "secret".into() }));
|
||||
let effect = inspect(login(Credentials {
|
||||
email: "demo@example.com".into(),
|
||||
password: "secret".into(),
|
||||
}));
|
||||
|
||||
assert!(effect.has_slot(auth::slots::login_status));
|
||||
assert!(matches!(effect.ops(), [Effect::Put { .. }]));
|
||||
assert!(effect.updates_text(auth::login_status));
|
||||
}
|
||||
|
||||
// req: examples/001 req: push/001 req: push/003 req: build/001 req: build/005
|
||||
#[test]
|
||||
fn sse_notifications_update_a_generated_slot() {
|
||||
fn notification(message: &str) -> impl IntoEffect {
|
||||
notifications::slots::notifications.text(message)
|
||||
notifications::notifications.set(message)
|
||||
}
|
||||
|
||||
let effect = inspect(notification("Build finished"));
|
||||
|
||||
assert!(effect.has_slot(notifications::slots::notifications));
|
||||
assert!(matches!(effect.ops(), [Effect::Put { .. }]));
|
||||
assert!(effect.updates_text(notifications::notifications));
|
||||
}
|
||||
}
|
||||
|
||||
+412
-102
@@ -4,18 +4,12 @@ use axum::routing::get;
|
||||
use axum::Router;
|
||||
use futures_util::{stream, StreamExt};
|
||||
use hemplate::Hemplate;
|
||||
use slhx::{push, IntoEffect, SafeHtml};
|
||||
use slhx::{Html, IntoEffect};
|
||||
use slhx_axum::{
|
||||
interactions, runtime_js, sse, DispatchRegistry, EffectResponse, InteractionRequest, PageRequest,
|
||||
interactions, runtime_js, sse, EffectResponse, Form, InteractionRequest, PageRequest, Registry,
|
||||
};
|
||||
use slhx_v0_examples::ui;
|
||||
use slhx_v0_examples::ui::{auth, counter, notifications, page_swap, todos, wizard};
|
||||
use slhx_v0_examples::ui::auth::{handles as auth_handles, targets as auth_targets};
|
||||
use slhx_v0_examples::ui::counter::{handles as counter_handles, targets as counter_targets};
|
||||
use slhx_v0_examples::ui::notifications::targets as notification_targets;
|
||||
use slhx_v0_examples::ui::page_swap::{handles as page_handles, targets as page_targets};
|
||||
use slhx_v0_examples::ui::todos::{forms as todo_forms, handles as todo_handles, targets as todo_targets};
|
||||
use slhx_v0_examples::ui::wizard::{handles as wizard_handles, targets as wizard_targets};
|
||||
use slhx_v0_examples::ui::{auth, counter, notifications, page_swap, todo_row, todos, wizard};
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
@@ -25,36 +19,101 @@ use std::time::Duration;
|
||||
#[derive(Default)]
|
||||
struct ExampleState {
|
||||
counter: Mutex<u64>,
|
||||
todos: Mutex<Vec<Todo>>,
|
||||
todos: Mutex<Vec<TodoRecord>>,
|
||||
wizard_step: Mutex<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Todo {
|
||||
struct TodoRecord {
|
||||
// Domain/SQL-shaped rows stay as boring Rust data; hemplate view structs are derived at render/update boundaries.
|
||||
// req: examples/001 req: view/001
|
||||
id: u64,
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[slhx::form("new_todo")]
|
||||
struct NewTodo {
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[slhx::form("rename_todo")]
|
||||
struct RenameTodo {
|
||||
id: u64,
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[slhx::form("delete_todo")]
|
||||
struct DeleteTodo {
|
||||
id: u64,
|
||||
}
|
||||
|
||||
#[slhx::form("wizard_input")]
|
||||
struct WizardInput {
|
||||
step: String,
|
||||
}
|
||||
|
||||
#[slhx::form("credentials")]
|
||||
struct Credentials {
|
||||
email: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TodoMutationError(String);
|
||||
|
||||
impl std::fmt::Display for TodoMutationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TodoMutationError {}
|
||||
|
||||
#[derive(Hemplate)]
|
||||
#[hemplate = "partials"]
|
||||
struct TodoItems {
|
||||
items: Vec<TodoItem>,
|
||||
}
|
||||
|
||||
#[derive(Hemplate)]
|
||||
#[hemplate = "partials"]
|
||||
struct TodoRow {
|
||||
id: u64,
|
||||
title: String,
|
||||
}
|
||||
|
||||
impl slhx::KeyedPartial for TodoRow {
|
||||
fn slhx_key(&self) -> String {
|
||||
self.id.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
struct TodoItem {
|
||||
id: u64,
|
||||
title: String,
|
||||
}
|
||||
|
||||
#[derive(Hemplate)]
|
||||
struct Counter;
|
||||
|
||||
#[derive(Hemplate)]
|
||||
struct Wizard;
|
||||
|
||||
#[derive(Hemplate)]
|
||||
struct Auth;
|
||||
|
||||
#[derive(Hemplate)]
|
||||
struct Notifications;
|
||||
|
||||
#[derive(Hemplate)]
|
||||
struct PageSwap {
|
||||
content: SafeHtml,
|
||||
content: Html,
|
||||
title: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Hemplate)]
|
||||
struct AppShell {
|
||||
body: SafeHtml,
|
||||
body: Html,
|
||||
}
|
||||
|
||||
#[derive(Hemplate)]
|
||||
@@ -100,7 +159,7 @@ async fn interact(
|
||||
State(state): State<Arc<ExampleState>>,
|
||||
request: InteractionRequest,
|
||||
) -> Result<EffectResponse, impl IntoResponse> {
|
||||
request.dispatch(registry(state))
|
||||
request.dispatch_async(registry(state)).await
|
||||
}
|
||||
|
||||
async fn runtime() -> impl IntoResponse {
|
||||
@@ -110,96 +169,201 @@ async fn runtime() -> impl IntoResponse {
|
||||
// req: push/001, req: push/003, req: examples/001
|
||||
async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResponse {
|
||||
if params.contains_key("once") {
|
||||
let effect = notification_targets::notifications.text("Server event #1");
|
||||
return sse(stream::iter([Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT))]).boxed());
|
||||
let effect = notifications::notifications.set("Server event #1");
|
||||
return sse(stream::iter([Ok::<_, Infallible>(
|
||||
effect.into_batch(ui::BUILD_FINGERPRINT),
|
||||
)])
|
||||
.boxed());
|
||||
}
|
||||
|
||||
let batches = stream::unfold(1_u64, |count| async move {
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
let effect = notification_targets::notifications.text(format!("Server event #{count}"));
|
||||
Some((Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)), count + 1))
|
||||
let effect = notifications::notifications.set(format!("Server event #{count}"));
|
||||
Some((
|
||||
Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)),
|
||||
count + 1,
|
||||
))
|
||||
})
|
||||
.boxed();
|
||||
sse(batches)
|
||||
}
|
||||
|
||||
fn registry(state: Arc<ExampleState>) -> impl DispatchRegistry {
|
||||
#[slhx::app(
|
||||
counter_handlers,
|
||||
todo_handlers,
|
||||
todo_row_handlers,
|
||||
wizard_handlers,
|
||||
auth_handlers
|
||||
)]
|
||||
fn registry(state: Arc<ExampleState>) -> Registry {
|
||||
interactions(ui::BUILD_FINGERPRINT)
|
||||
.on(counter_handles::increment, {
|
||||
let state = state.clone();
|
||||
move |_| {
|
||||
// req: examples/001
|
||||
let mut counter = state.counter.lock().unwrap();
|
||||
*counter += 1;
|
||||
counter_targets::counter_value.text(*counter)
|
||||
}
|
||||
})
|
||||
.on(todo_handles::add_todo, {
|
||||
let state = state.clone();
|
||||
move |form| {
|
||||
// req: examples/001
|
||||
let title = form.value("title").unwrap_or("").trim();
|
||||
let mut todos = state.todos.lock().unwrap();
|
||||
if !title.is_empty() {
|
||||
let id = todos.last().map_or(1, |todo| todo.id + 1);
|
||||
todos.push(Todo { id, title: title.into() });
|
||||
}
|
||||
(
|
||||
todo_targets::todo_list.put(&todos_view(&todos)),
|
||||
todo_forms::new_todo.clear("title"),
|
||||
)
|
||||
}
|
||||
})
|
||||
.on(wizard_handles::next_step, {
|
||||
let state = state.clone();
|
||||
move |_| {
|
||||
// req: examples/001
|
||||
let mut step = state.wizard_step.lock().unwrap();
|
||||
*step += 1;
|
||||
wizard_targets::wizard_step.text(format!("Step {}", *step + 1))
|
||||
}
|
||||
})
|
||||
.on(auth_handles::login, |form| {
|
||||
// req: examples/001
|
||||
let ok = form.value("email") == Some("demo@example.com")
|
||||
&& form.value("password").is_some_and(|password| !password.is_empty());
|
||||
auth_targets::login_status.text(if ok {
|
||||
"Signed in as demo@example.com"
|
||||
} else {
|
||||
"Try demo@example.com with any password"
|
||||
})
|
||||
})
|
||||
.on(page_handles::load_docs, |_| {
|
||||
// req: page_swap/002, req: examples/001
|
||||
(
|
||||
page_targets::content.put(&DocsContent {
|
||||
message: "This content came from a generated update response.",
|
||||
}),
|
||||
page_targets::title.text("Docs"),
|
||||
push("/docs"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn all_examples() -> SafeHtml {
|
||||
// Static `.heml` fragments are lowered by generated code before they join rendered views.
|
||||
fn all_examples() -> Html {
|
||||
// req: html_safety/001 req: html_safety/002 req: component/003
|
||||
SafeHtml::join([
|
||||
counter::static_fragment(include_str!("../templates/counter.heml")),
|
||||
todos::static_fragment(include_str!("../templates/todos.heml")),
|
||||
wizard::static_fragment(include_str!("../templates/wizard.heml")),
|
||||
auth::static_fragment(include_str!("../templates/auth.heml")),
|
||||
Html::join([
|
||||
counter::render(&Counter),
|
||||
ui::render(&todos_view(&[])),
|
||||
wizard::render(&Wizard),
|
||||
auth::render(&Auth),
|
||||
render_page_swap("Welcome", "Welcome"),
|
||||
notifications::static_fragment(include_str!("../templates/notifications.heml")),
|
||||
notifications::render(&Notifications),
|
||||
])
|
||||
}
|
||||
|
||||
fn shell(body: SafeHtml) -> SafeHtml {
|
||||
fn shell(body: Html) -> Html {
|
||||
// req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003
|
||||
ui::render(&AppShell { body })
|
||||
}
|
||||
|
||||
fn todos_view(todos: &[Todo]) -> TodoItems {
|
||||
#[slhx::component("counter")]
|
||||
mod counter_handlers {
|
||||
use super::*;
|
||||
|
||||
#[slhx::handler]
|
||||
async fn increment(State(state): State<Arc<ExampleState>>) -> impl IntoEffect {
|
||||
// req: examples/001
|
||||
let mut counter = state.counter.lock().unwrap();
|
||||
*counter += 1;
|
||||
counter::counter_value.set(*counter)
|
||||
}
|
||||
}
|
||||
|
||||
#[slhx::component("todos")]
|
||||
mod todo_handlers {
|
||||
use super::*;
|
||||
|
||||
#[slhx::handler]
|
||||
async fn add_todo(
|
||||
State(state): State<Arc<ExampleState>>,
|
||||
Form(form): Form<NewTodo>,
|
||||
) -> Result<impl IntoEffect, TodoMutationError> {
|
||||
// req: examples/001 req: canonical_authoring/003
|
||||
let mut todos = state.todos.lock().unwrap();
|
||||
let id = todos.last().map_or(1, |todo| todo.id + 1);
|
||||
let created = if form.title.is_empty() {
|
||||
None
|
||||
} else {
|
||||
todos.push(TodoRecord {
|
||||
id,
|
||||
title: form.title.clone(),
|
||||
});
|
||||
Some(todos::todo_row.append(TodoRow {
|
||||
id,
|
||||
title: form.title,
|
||||
}))
|
||||
};
|
||||
let summary = todo_summary(&todos);
|
||||
Ok((
|
||||
created,
|
||||
todos::summary.set(summary),
|
||||
todos::new_todo.clear(),
|
||||
))
|
||||
}
|
||||
|
||||
#[slhx::handler]
|
||||
async fn rename_todo(
|
||||
State(state): State<Arc<ExampleState>>,
|
||||
Form(form): Form<RenameTodo>,
|
||||
) -> impl IntoEffect {
|
||||
rename_todo_effect(state, form)
|
||||
}
|
||||
|
||||
#[slhx::handler]
|
||||
async fn delete_todo(
|
||||
State(state): State<Arc<ExampleState>>,
|
||||
Form(form): Form<DeleteTodo>,
|
||||
) -> impl IntoEffect {
|
||||
delete_todo_effect(state, form)
|
||||
}
|
||||
}
|
||||
|
||||
#[slhx::component("todo_row")]
|
||||
mod todo_row_handlers {
|
||||
use super::*;
|
||||
|
||||
#[slhx::handler]
|
||||
async fn rename_todo_row(
|
||||
State(state): State<Arc<ExampleState>>,
|
||||
Form(form): Form<RenameTodo>,
|
||||
) -> impl IntoEffect {
|
||||
rename_todo_effect(state, form)
|
||||
}
|
||||
|
||||
#[slhx::handler]
|
||||
async fn delete_todo_row(
|
||||
State(state): State<Arc<ExampleState>>,
|
||||
Form(form): Form<DeleteTodo>,
|
||||
) -> impl IntoEffect {
|
||||
delete_todo_effect(state, form)
|
||||
}
|
||||
}
|
||||
|
||||
#[slhx::component("wizard")]
|
||||
mod wizard_handlers {
|
||||
use super::*;
|
||||
|
||||
#[slhx::handler]
|
||||
async fn next_step(
|
||||
State(state): State<Arc<ExampleState>>,
|
||||
Form(form): Form<WizardInput>,
|
||||
) -> impl IntoEffect {
|
||||
// req: examples/001
|
||||
let _submitted_step = form.step;
|
||||
let mut step = state.wizard_step.lock().unwrap();
|
||||
*step += 1;
|
||||
wizard::wizard_step.set(format!("Step {}", *step + 1))
|
||||
}
|
||||
}
|
||||
|
||||
#[slhx::component("auth")]
|
||||
mod auth_handlers {
|
||||
use super::*;
|
||||
|
||||
#[slhx::handler]
|
||||
async fn login(
|
||||
State(_state): State<Arc<ExampleState>>,
|
||||
Form(credentials): Form<Credentials>,
|
||||
) -> impl IntoEffect {
|
||||
// req: examples/001
|
||||
let ok = credentials.email == "demo@example.com" && !credentials.password.is_empty();
|
||||
auth::login_status.set(if ok {
|
||||
"Signed in as demo@example.com"
|
||||
} else {
|
||||
"Try demo@example.com with any password"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_todo_effect(state: Arc<ExampleState>, form: RenameTodo) -> impl IntoEffect {
|
||||
// req: examples/001 req: canonical_authoring/003
|
||||
let mut todos = state.todos.lock().unwrap();
|
||||
todos
|
||||
.iter_mut()
|
||||
.find(|todo| todo.id == form.id)
|
||||
.map(|todo| {
|
||||
todo.title = form.title.clone();
|
||||
todos::todo_row.replace(TodoRow {
|
||||
id: form.id,
|
||||
title: form.title,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_todo_effect(state: Arc<ExampleState>, form: DeleteTodo) -> impl IntoEffect {
|
||||
// req: examples/001 req: canonical_authoring/003
|
||||
let mut todos = state.todos.lock().unwrap();
|
||||
let before = todos.len();
|
||||
todos.retain(|todo| todo.id != form.id);
|
||||
let deleted = todos.len() != before;
|
||||
let summary = todo_summary(&todos);
|
||||
(
|
||||
deleted.then(|| todos::todo_row.remove(form.id)),
|
||||
deleted.then(|| todos::summary.set(summary)),
|
||||
)
|
||||
}
|
||||
|
||||
fn todos_view(todos: &[TodoRecord]) -> TodoItems {
|
||||
// req: html_safety/002 req: view/001
|
||||
TodoItems {
|
||||
items: todos
|
||||
@@ -212,7 +376,15 @@ fn todos_view(todos: &[Todo]) -> TodoItems {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_page_swap(title: &'static str, message: &'static str) -> SafeHtml {
|
||||
fn todo_summary(todos: &[TodoRecord]) -> String {
|
||||
match todos.len() {
|
||||
0 => "No todos".to_owned(),
|
||||
1 => "1 todo".to_owned(),
|
||||
count => format!("{count} todos"),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_page_swap(title: &'static str, message: &'static str) -> Html {
|
||||
// req: html_safety/002 req: view/001 req: component/003
|
||||
page_swap::render(&PageSwap {
|
||||
content: render_docs_content(message),
|
||||
@@ -220,7 +392,7 @@ fn render_page_swap(title: &'static str, message: &'static str) -> SafeHtml {
|
||||
})
|
||||
}
|
||||
|
||||
fn render_docs_content(message: &'static str) -> SafeHtml {
|
||||
fn render_docs_content(message: &'static str) -> Html {
|
||||
// req: html_safety/002 req: view/001 req: component/003
|
||||
ui::render(&DocsContent { message })
|
||||
}
|
||||
@@ -229,11 +401,25 @@ fn render_docs_content(message: &'static str) -> SafeHtml {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use scraper::{Html, Selector};
|
||||
use slhx_test::{
|
||||
article_selector, document_title_selector, escaped_markup_selector, heading_selector,
|
||||
inspect_batch, keyed_items_selector, keyed_selector, list_item_selector,
|
||||
page_nav_link_selector, prose_selector, root_element_selector, runtime_script_selector,
|
||||
};
|
||||
|
||||
fn selector(value: &str) -> Selector {
|
||||
Selector::parse(value).expect("test selector parses")
|
||||
}
|
||||
|
||||
fn form<I>(handle: slhx::Handle<I>, fields: &[(&str, &str)]) -> slhx_axum::InteractionForm {
|
||||
slhx_axum::InteractionForm::for_handle(
|
||||
handle,
|
||||
fields
|
||||
.iter()
|
||||
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned())),
|
||||
)
|
||||
}
|
||||
|
||||
// req: html_safety/002 req: view/001 req: test/005
|
||||
#[test]
|
||||
fn shell_is_rendered_by_a_hemplate_view() {
|
||||
@@ -241,15 +427,28 @@ mod tests {
|
||||
let document = Html::parse_document(html.as_str());
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector("title"))
|
||||
.select(&selector(document_title_selector()))
|
||||
.next()
|
||||
.map(|title| title.text().collect::<String>()),
|
||||
Some("slhx v0 examples".to_owned())
|
||||
);
|
||||
assert_eq!(document.select(&selector("script[src=\"/slhx.js\"]")).count(), 1);
|
||||
assert_eq!(document.select(&selector("main[data-slhx-root=\"docs\"]")).count(), 1);
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector(runtime_script_selector()))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector(&root_element_selector("main", "docs")))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
document.root_element().text().all(|text| !text.contains("{+=")),
|
||||
document
|
||||
.root_element()
|
||||
.text()
|
||||
.all(|text| !text.contains("{+=")),
|
||||
"hemplate insertion markers must not leak into rendered text"
|
||||
);
|
||||
}
|
||||
@@ -261,14 +460,14 @@ mod tests {
|
||||
let document = Html::parse_fragment(html.as_str());
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector("h1"))
|
||||
.select(&selector(&heading_selector("", 1)))
|
||||
.next()
|
||||
.map(|heading| heading.text().collect::<String>()),
|
||||
Some("Docs".to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector("p"))
|
||||
.select(&selector(&prose_selector("")))
|
||||
.next()
|
||||
.map(|paragraph| paragraph.text().collect::<String>()),
|
||||
Some("This content came from a generated update response.".to_owned())
|
||||
@@ -280,11 +479,22 @@ mod tests {
|
||||
fn docs_page_partial_is_rendered_by_a_hemplate_view() {
|
||||
let html = render_page_swap("Docs", "This page was swapped without a full reload.");
|
||||
let document = Html::parse_fragment(html.as_str());
|
||||
assert_eq!(document.select(&selector("main[data-slhx-root=\"docs\"]")).count(), 1);
|
||||
assert_eq!(document.select(&selector("article[data-sid]")).count(), 1);
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector("article h1"))
|
||||
.select(&selector(&root_element_selector("main", "docs")))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(document.select(&selector(article_selector())).count(), 1);
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector(&page_nav_link_selector("/docs")))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
document
|
||||
.select(&selector(&heading_selector("article", 1)))
|
||||
.next()
|
||||
.map(|heading| heading.text().collect::<String>()),
|
||||
Some("Docs".to_owned())
|
||||
@@ -294,15 +504,26 @@ mod tests {
|
||||
// req: html_safety/002 req: view/001 req: test/005
|
||||
#[test]
|
||||
fn todos_payload_is_rendered_by_a_hemplate_view() {
|
||||
let todos = vec![Todo { id: 7, title: "<b>Ship v0</b>".to_owned() }];
|
||||
let todos = vec![TodoRecord {
|
||||
id: 7,
|
||||
title: "<b>Ship v0</b>".to_owned(),
|
||||
}];
|
||||
|
||||
let html = ui::render(&todos_view(&todos));
|
||||
let document = Html::parse_fragment(html.as_str());
|
||||
let rows = document.select(&selector("li[data-key]")).collect::<Vec<_>>();
|
||||
let rows = document
|
||||
.select(&selector(&keyed_items_selector("li")))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].value().attr("data-key"), Some("7"));
|
||||
assert_eq!(rows[0].text().collect::<String>(), "<b>Ship v0</b>");
|
||||
assert!(document.select(&selector("b")).next().is_none());
|
||||
let row = document
|
||||
.select(&selector(&keyed_selector("li", 7)))
|
||||
.next()
|
||||
.expect("generated keyed row");
|
||||
assert_eq!(row.text().collect::<String>(), "<b>Ship v0</b>");
|
||||
assert!(document
|
||||
.select(&selector(&escaped_markup_selector("b")))
|
||||
.next()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
// req: html_safety/002 req: view/001 req: test/005
|
||||
@@ -310,8 +531,97 @@ mod tests {
|
||||
fn empty_todos_payload_is_rendered_by_a_hemplate_view() {
|
||||
let html = ui::render(&todos_view(&[]));
|
||||
let document = Html::parse_fragment(html.as_str());
|
||||
let rows = document.select(&selector("li")).collect::<Vec<_>>();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].text().collect::<String>(), "No todos yet");
|
||||
let rows = document
|
||||
.select(&selector(&list_item_selector("")))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(rows.len(), 0);
|
||||
}
|
||||
|
||||
// req: examples/001 req: page_swap/002 req: page_swap/003 req: component/005 req: public_api/003 req: test/005
|
||||
#[tokio::test]
|
||||
async fn registry_dispatches_generated_keyed_crud_effects() {
|
||||
let state = Arc::new(ExampleState::default());
|
||||
|
||||
let counter = inspect_batch(
|
||||
InteractionRequest::from(form(counter::increment, &[]))
|
||||
.dispatch_async(registry(state.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert_eq!(*state.counter.lock().unwrap(), 1);
|
||||
assert!(counter.updates_text(counter::counter_value));
|
||||
assert!(counter.payload_contains("1"));
|
||||
|
||||
let add = inspect_batch(
|
||||
InteractionRequest::from(form(todos::add_todo, &[("title", "Ship v0")]))
|
||||
.dispatch_async(registry(state.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert_eq!(state.todos.lock().unwrap()[0].title, "Ship v0");
|
||||
assert_eq!(add.op_count(), 3);
|
||||
assert!(add.inserts_html_containing(todos::todo_row, "1", "Ship v0"));
|
||||
assert!(add.updates_text(todos::summary));
|
||||
assert!(add.resets_form(todos::new_todo));
|
||||
|
||||
let rename = inspect_batch(
|
||||
InteractionRequest::from(form(
|
||||
todo_row::rename_todo_row,
|
||||
&[("id", "1"), ("title", "Ship 1.0")],
|
||||
))
|
||||
.dispatch_async(registry(state.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert_eq!(state.todos.lock().unwrap()[0].title, "Ship 1.0");
|
||||
assert_eq!(rename.op_count(), 1);
|
||||
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")]))
|
||||
.dispatch_async(registry(state.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert!(state.todos.lock().unwrap().is_empty());
|
||||
assert_eq!(delete.op_count(), 2);
|
||||
assert!(delete.removes_key(todos::todo_row, "1"));
|
||||
assert!(delete.updates_text(todos::summary));
|
||||
|
||||
let missing_delete = inspect_batch(
|
||||
InteractionRequest::from(form(todo_row::delete_todo_row, &[("id", "99")]))
|
||||
.dispatch_async(registry(state.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert!(missing_delete.is_empty());
|
||||
|
||||
let wizard = inspect_batch(
|
||||
InteractionRequest::from(form(wizard::next_step, &[("step", "1")]))
|
||||
.dispatch_async(registry(state.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert!(wizard.updates_text(wizard::wizard_step));
|
||||
assert!(wizard.payload_contains("Step 2"));
|
||||
|
||||
let auth = inspect_batch(
|
||||
InteractionRequest::from(form(
|
||||
auth::login,
|
||||
&[("email", "demo@example.com"), ("password", "secret")],
|
||||
))
|
||||
.dispatch_async(registry(state))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert!(auth.updates_text(auth::login_status));
|
||||
assert!(auth.payload_contains("Signed in as demo@example.com"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<main data-slhx-root="docs">
|
||||
<nav data-slhx-slot="nav">
|
||||
<a href="/docs" data-slhx-nav="" data-slhx-handle="load_docs">Docs</a>
|
||||
<a href="/docs" data-slhx-nav="">Docs</a>
|
||||
</nav>
|
||||
<article data-slhx-slot="content">{+= self.content =+}</article>
|
||||
<title data-slhx-slot="title">{+ self.title +}</title>
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
<template h-if="self.items.is_empty()">
|
||||
<li>No todos yet</li>
|
||||
</template>
|
||||
<template h-else>
|
||||
<template h-if="!self.items.is_empty()">
|
||||
<li h-for="todo in &self.items" +data-key="todo.id">{+ todo.title +}</li>
|
||||
</template>
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
<li>{+ self.title +}</li>
|
||||
<li>
|
||||
<span>{+ self.title +}</span>
|
||||
<form data-slhx-handle="rename_todo_row">
|
||||
<input type="hidden" name="id" +value="self.id">
|
||||
<button type="submit" name="title" value="Renamed todo">Rename</button>
|
||||
</form>
|
||||
<form data-slhx-handle="delete_todo_row">
|
||||
<button type="submit" name="id" +value="self.id">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
|
||||
@@ -3,9 +3,21 @@
|
||||
<input name="title" required="required">
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
<ul data-slhx-slot="todo_list">
|
||||
<template h-for="todo in &self.todos" h-key="todo.id">
|
||||
<li data-slhx-slot="todo_row">{+ todo.title +}</li>
|
||||
</template>
|
||||
</ul>
|
||||
<p data-slhx-slot="summary">{+ self.summary +}</p>
|
||||
<div data-slhx-slot="todo_list">
|
||||
<ul data-slhx-slot="todo_row">
|
||||
<template h-for="todo in &self.items" h-key="todo.id">
|
||||
<li data-slhx-slot="todo_row" +data-key="todo.id">
|
||||
<span>{+ todo.title +}</span>
|
||||
<form data-slhx-handle="rename_todo">
|
||||
<input type="hidden" name="id" +value="todo.id">
|
||||
<button type="submit" name="title" value="Renamed todo">Rename</button>
|
||||
</form>
|
||||
<form data-slhx-handle="delete_todo">
|
||||
<button type="submit" name="id" +value="todo.id">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user