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