feat(hemx): clarify generated authoring path
Close the fallible handler and example coherence slice: v0 uses typed todo newtypes, Result handler failure mapping, generated form/partial helpers, and page composition; kanban is explicitly marked as an advanced north-star milestone; raw render is isolated behind hemx::advanced::render. req: canonical_authoring/003 req: failure/004 req: public_api/005 req: examples/004
This commit is contained in:
+2
-2
@@ -287,7 +287,7 @@ a `data-*` handle param is statically known or runtime-extracted.
|
|||||||
004 If a common UI operation requires raw `EffectWriter`, the public API is considered incomplete.
|
004 If a common UI operation requires raw `EffectWriter`, the public API is considered incomplete.
|
||||||
|
|
||||||
### req: public_api/005
|
### req: public_api/005
|
||||||
005 Beginner-facing page/template composition uses generated render or page helpers. Direct `SafeHtml` construction, raw `html(...)`, raw `target(...)`, raw route fragments, and explicit `ui::render(...)` calls are advanced escape hatches and must not appear in beginner examples or docs.
|
005 Beginner-facing page/template composition uses generated render or page helpers. Direct `SafeHtml` construction, raw `html(...)`, raw `target(...)`, raw route fragments, `hemx::advanced::render(...)`, and explicit `ui::render(...)` calls are advanced escape hatches and must not appear in beginner examples or docs. Server-rendered page boundaries may use `hemx::page(...)`; handlers and ordinary partial updates must use generated target/form/page commands.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -872,7 +872,7 @@ async fn add(app: State<App>, form: Form<NewTodo>) -> impl IntoEffect
|
|||||||
async fn rename(app: State<App>, todo_id: TodoId, title: Title) -> impl IntoEffect
|
async fn rename(app: State<App>, todo_id: TodoId, title: Title) -> impl IntoEffect
|
||||||
async fn delete(app: State<App>, todo_id: TodoId) -> impl IntoEffect
|
async fn delete(app: State<App>, todo_id: TodoId) -> impl IntoEffect
|
||||||
```
|
```
|
||||||
`State<App>` is illustrative integration context; equivalent framework extractors or app references are adapter concerns. Form fields and `data-*` params parse through normal Rust `FromForm`/`FormValue`/`FromStr`-style traits, so domain newtypes remain user-authored. Handlers may return `impl IntoEffect` or `Result<impl IntoEffect, E>` for fallible database/domain work; `IntoEffect` values compose through tuples while `Result` paths preserve a typed error boundary for integrations to map to form errors, toasts, events, or HTTP responses.
|
`State<App>` is illustrative integration context; equivalent framework extractors or app references are adapter concerns. Form fields and `data-*` params parse through normal Rust `FromForm`/`FormValue`/`FromStr`-style traits, so domain newtypes remain user-authored. Handlers may return `impl IntoEffect` or `Result<impl IntoEffect, E>` for fallible database/domain work; `IntoEffect` values compose through tuples while `Result` paths preserve a typed error boundary (`IntoHandlerFailure` in the Axum adapter) for integrations to map failures to generated UI effects, toasts, events, or HTTP responses. Result-specific registry adapters are generated/integration internals; canonical app code uses the same `#[hemx::handler]` and `#[hemx::app]` authoring shape for plain and fallible handlers.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
# hemx Kanban browser example
|
# hemx Kanban advanced milestone example
|
||||||
|
|
||||||
|
This is an explicitly advanced/low-level north-star boundary sketch, not beginner-facing guidance. It exercises the product boundary described in `../kanban.md`; use `examples/v0` for the canonical beginner path.
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ fn page_html(board: &BoardState) -> Html {
|
|||||||
|
|
||||||
fn shell(body: Html) -> Html {
|
fn shell(body: Html) -> Html {
|
||||||
// req: html_safety/001 req: html_safety/002 req: axum_integration/001
|
// req: html_safety/001 req: html_safety/002 req: axum_integration/001
|
||||||
hemx::render(&AppShell { body })
|
hemx::page(&AppShell { body })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_options() -> Html {
|
fn render_options() -> Html {
|
||||||
@@ -332,12 +332,12 @@ fn render_card(card: &Card) -> BoardCard {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use scraper::{Html, Selector};
|
|
||||||
use hemx_test::{
|
use hemx_test::{
|
||||||
class_child_selector, disabled_button_selector, element_class_selector,
|
class_child_selector, disabled_button_selector, element_class_selector,
|
||||||
escaped_markup_selector, form_selector, keyed_selector, root_element_selector,
|
escaped_markup_selector, form_selector, keyed_selector, root_element_selector,
|
||||||
select_options_selector, small_text_selector, strong_text_selector,
|
select_options_selector, small_text_selector, strong_text_selector,
|
||||||
};
|
};
|
||||||
|
use scraper::{Html, Selector};
|
||||||
|
|
||||||
fn selector(value: &str) -> Selector {
|
fn selector(value: &str) -> Selector {
|
||||||
Selector::parse(value).expect("test selector parses")
|
Selector::parse(value).expect("test selector parses")
|
||||||
|
|||||||
+112
-33
@@ -6,7 +6,8 @@ use futures_util::{stream, StreamExt};
|
|||||||
use hemplate::Hemplate;
|
use hemplate::Hemplate;
|
||||||
use hemx::{Html, IntoEffect};
|
use hemx::{Html, IntoEffect};
|
||||||
use hemx_axum::{
|
use hemx_axum::{
|
||||||
interactions, runtime_js, sse, EffectResponse, Form, InteractionRequest, PageRequest, Registry,
|
interactions, runtime_js, sse, EffectResponse, Form, HandlerErrorContext, HandlerFailure,
|
||||||
|
InteractionRequest, IntoHandlerFailure, PageRequest, Registry,
|
||||||
};
|
};
|
||||||
use hemx_v0_examples::ui;
|
use hemx_v0_examples::ui;
|
||||||
use hemx_v0_examples::ui::{auth, counter, notifications, page_swap, todo_row, todos, wizard};
|
use hemx_v0_examples::ui::{auth, counter, notifications, page_swap, todo_row, todos, wizard};
|
||||||
@@ -31,20 +32,64 @@ struct TodoRecord {
|
|||||||
title: String,
|
title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
struct TodoId(u64);
|
||||||
|
|
||||||
|
impl std::fmt::Display for TodoId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for TodoId {
|
||||||
|
type Err = std::num::ParseIntError;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
value.parse().map(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
struct TodoTitle(String);
|
||||||
|
|
||||||
|
impl TodoTitle {
|
||||||
|
fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_string(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TodoTitle {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for TodoTitle {
|
||||||
|
type Err = Infallible;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
Ok(Self(value.trim().to_owned()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[hemx::form("new_todo")]
|
#[hemx::form("new_todo")]
|
||||||
struct NewTodo {
|
struct NewTodo {
|
||||||
title: String,
|
title: TodoTitle,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hemx::form("rename_todo")]
|
#[hemx::form("rename_todo")]
|
||||||
struct RenameTodo {
|
struct RenameTodo {
|
||||||
id: u64,
|
id: TodoId,
|
||||||
title: String,
|
title: TodoTitle,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hemx::form("delete_todo")]
|
#[hemx::form("delete_todo")]
|
||||||
struct DeleteTodo {
|
struct DeleteTodo {
|
||||||
id: u64,
|
id: TodoId,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hemx::form("wizard_input")]
|
#[hemx::form("wizard_input")]
|
||||||
@@ -59,15 +104,24 @@ struct Credentials {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct TodoMutationError(String);
|
struct AppError(String);
|
||||||
|
|
||||||
impl std::fmt::Display for TodoMutationError {
|
impl std::fmt::Display for AppError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.write_str(&self.0)
|
f.write_str(&self.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for TodoMutationError {}
|
impl std::error::Error for AppError {}
|
||||||
|
|
||||||
|
impl IntoHandlerFailure for AppError {
|
||||||
|
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
|
||||||
|
HandlerFailure::effects(
|
||||||
|
notifications::notifications.set(format!("Could not save todo: {}", self.0)),
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Hemplate)]
|
#[derive(Hemplate)]
|
||||||
#[hemplate = "partials"]
|
#[hemplate = "partials"]
|
||||||
@@ -203,7 +257,7 @@ fn all_examples() -> Html {
|
|||||||
// req: html_safety/001 req: html_safety/002 req: component/003
|
// req: html_safety/001 req: html_safety/002 req: component/003
|
||||||
Html::join([
|
Html::join([
|
||||||
counter::render(&Counter),
|
counter::render(&Counter),
|
||||||
ui::render(&todos_view(&[])),
|
hemx::page(&todos_view(&[])),
|
||||||
wizard::render(&Wizard),
|
wizard::render(&Wizard),
|
||||||
auth::render(&Auth),
|
auth::render(&Auth),
|
||||||
render_page_swap("Welcome", "Welcome"),
|
render_page_swap("Welcome", "Welcome"),
|
||||||
@@ -213,7 +267,7 @@ fn all_examples() -> Html {
|
|||||||
|
|
||||||
fn shell(body: Html) -> Html {
|
fn shell(body: Html) -> Html {
|
||||||
// req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003
|
// req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003
|
||||||
ui::render(&AppShell { body })
|
hemx::page(&AppShell { body })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hemx::component("counter")]
|
#[hemx::component("counter")]
|
||||||
@@ -237,25 +291,29 @@ mod todo_handlers {
|
|||||||
async fn add_todo(
|
async fn add_todo(
|
||||||
State(state): State<Arc<ExampleState>>,
|
State(state): State<Arc<ExampleState>>,
|
||||||
Form(form): Form<NewTodo>,
|
Form(form): Form<NewTodo>,
|
||||||
) -> Result<impl IntoEffect, TodoMutationError> {
|
) -> Result<impl IntoEffect, AppError> {
|
||||||
// req: examples/001 req: canonical_authoring/003
|
// req: examples/001 req: canonical_authoring/003
|
||||||
|
if form.title.as_str().is_empty() {
|
||||||
|
return Ok((
|
||||||
|
todos::new_todo.error("title", "Title required"),
|
||||||
|
todos::new_todo.focus("title"),
|
||||||
|
todos::summary.set(todo_summary(&state.todos.lock().unwrap())),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if form.title.as_str() == "fail-db" {
|
||||||
|
return Err(AppError("todo store unavailable".to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
let mut todos = state.todos.lock().unwrap();
|
let mut todos = state.todos.lock().unwrap();
|
||||||
let id = todos.last().map_or(1, |todo| todo.id + 1);
|
let id = todos.last().map_or(1, |todo| todo.id + 1);
|
||||||
let created = if form.title.is_empty() {
|
let title = form.title.into_string();
|
||||||
None
|
todos.push(TodoRecord {
|
||||||
} else {
|
id,
|
||||||
todos.push(TodoRecord {
|
title: title.clone(),
|
||||||
id,
|
});
|
||||||
title: form.title.clone(),
|
|
||||||
});
|
|
||||||
Some(todos::todo_row.append(TodoRow {
|
|
||||||
id,
|
|
||||||
title: form.title,
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
let summary = todo_summary(&todos);
|
let summary = todo_summary(&todos);
|
||||||
Ok((
|
Ok((
|
||||||
created,
|
todos::todo_row.append(TodoRow { id, title }),
|
||||||
todos::summary.set(summary),
|
todos::summary.set(summary),
|
||||||
todos::new_todo.clear(),
|
todos::new_todo.clear(),
|
||||||
))
|
))
|
||||||
@@ -340,12 +398,12 @@ fn rename_todo_effect(state: Arc<ExampleState>, form: RenameTodo) -> impl IntoEf
|
|||||||
let mut todos = state.todos.lock().unwrap();
|
let mut todos = state.todos.lock().unwrap();
|
||||||
todos
|
todos
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|todo| todo.id == form.id)
|
.find(|todo| todo.id == form.id.0)
|
||||||
.map(|todo| {
|
.map(|todo| {
|
||||||
todo.title = form.title.clone();
|
todo.title = form.title.into_string();
|
||||||
todos::todo_row.replace(TodoRow {
|
todos::todo_row.replace(TodoRow {
|
||||||
id: form.id,
|
id: form.id.0,
|
||||||
title: form.title,
|
title: todo.title.clone(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -354,7 +412,7 @@ fn delete_todo_effect(state: Arc<ExampleState>, form: DeleteTodo) -> impl IntoEf
|
|||||||
// req: examples/001 req: canonical_authoring/003
|
// req: examples/001 req: canonical_authoring/003
|
||||||
let mut todos = state.todos.lock().unwrap();
|
let mut todos = state.todos.lock().unwrap();
|
||||||
let before = todos.len();
|
let before = todos.len();
|
||||||
todos.retain(|todo| todo.id != form.id);
|
todos.retain(|todo| todo.id != form.id.0);
|
||||||
let deleted = todos.len() != before;
|
let deleted = todos.len() != before;
|
||||||
let summary = todo_summary(&todos);
|
let summary = todo_summary(&todos);
|
||||||
(
|
(
|
||||||
@@ -394,18 +452,18 @@ fn render_page_swap(title: &'static str, message: &'static str) -> Html {
|
|||||||
|
|
||||||
fn render_docs_content(message: &'static str) -> Html {
|
fn render_docs_content(message: &'static str) -> Html {
|
||||||
// req: html_safety/002 req: view/001 req: component/003
|
// req: html_safety/002 req: view/001 req: component/003
|
||||||
ui::render(&DocsContent { message })
|
hemx::page(&DocsContent { message })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use scraper::{Html, Selector};
|
|
||||||
use hemx_test::{
|
use hemx_test::{
|
||||||
article_selector, document_title_selector, escaped_markup_selector, heading_selector,
|
article_selector, document_title_selector, escaped_markup_selector, heading_selector,
|
||||||
inspect_batch, keyed_items_selector, keyed_selector, list_item_selector,
|
inspect_batch, keyed_items_selector, keyed_selector, list_item_selector,
|
||||||
page_nav_link_selector, prose_selector, root_element_selector, runtime_script_selector,
|
page_nav_link_selector, prose_selector, root_element_selector, runtime_script_selector,
|
||||||
};
|
};
|
||||||
|
use scraper::{Html, Selector};
|
||||||
|
|
||||||
fn selector(value: &str) -> Selector {
|
fn selector(value: &str) -> Selector {
|
||||||
Selector::parse(value).expect("test selector parses")
|
Selector::parse(value).expect("test selector parses")
|
||||||
@@ -509,7 +567,7 @@ mod tests {
|
|||||||
title: "<b>Ship v0</b>".to_owned(),
|
title: "<b>Ship v0</b>".to_owned(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let html = ui::render(&todos_view(&todos));
|
let html = hemx::page(&todos_view(&todos));
|
||||||
let document = Html::parse_fragment(html.as_str());
|
let document = Html::parse_fragment(html.as_str());
|
||||||
let rows = document
|
let rows = document
|
||||||
.select(&selector(&keyed_items_selector("li")))
|
.select(&selector(&keyed_items_selector("li")))
|
||||||
@@ -529,7 +587,7 @@ mod tests {
|
|||||||
// req: html_safety/002 req: view/001 req: test/005
|
// req: html_safety/002 req: view/001 req: test/005
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_todos_payload_is_rendered_by_a_hemplate_view() {
|
fn empty_todos_payload_is_rendered_by_a_hemplate_view() {
|
||||||
let html = ui::render(&todos_view(&[]));
|
let html = hemx::page(&todos_view(&[]));
|
||||||
let document = Html::parse_fragment(html.as_str());
|
let document = Html::parse_fragment(html.as_str());
|
||||||
let rows = document
|
let rows = document
|
||||||
.select(&selector(&list_item_selector("")))
|
.select(&selector(&list_item_selector("")))
|
||||||
@@ -553,6 +611,27 @@ mod tests {
|
|||||||
assert!(counter.updates_text(counter::counter_value));
|
assert!(counter.updates_text(counter::counter_value));
|
||||||
assert!(counter.payload_contains("1"));
|
assert!(counter.payload_contains("1"));
|
||||||
|
|
||||||
|
let validation = inspect_batch(
|
||||||
|
InteractionRequest::from(form(todos::add_todo, &[("title", " ")]))
|
||||||
|
.dispatch_async(registry(state.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.batch,
|
||||||
|
);
|
||||||
|
assert!(state.todos.lock().unwrap().is_empty());
|
||||||
|
assert!(validation.payload_contains("Title required"));
|
||||||
|
|
||||||
|
let store_failure = inspect_batch(
|
||||||
|
InteractionRequest::from(form(todos::add_todo, &[("title", "fail-db")]))
|
||||||
|
.dispatch_async(registry(state.clone()))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.batch,
|
||||||
|
);
|
||||||
|
assert!(state.todos.lock().unwrap().is_empty());
|
||||||
|
assert!(store_failure.updates_text(notifications::notifications));
|
||||||
|
assert!(store_failure.payload_contains("todo store unavailable"));
|
||||||
|
|
||||||
let add = inspect_batch(
|
let add = inspect_batch(
|
||||||
InteractionRequest::from(form(todos::add_todo, &[("title", "Ship v0")]))
|
InteractionRequest::from(form(todos::add_todo, &[("title", "Ship v0")]))
|
||||||
.dispatch_async(registry(state.clone()))
|
.dispatch_async(registry(state.clone()))
|
||||||
|
|||||||
+263
-18
@@ -9,7 +9,6 @@ use futures_util::{Stream, StreamExt};
|
|||||||
use hemx_core::{BuildFingerprint, EffectBatch, FromForm, Handle, IntoEffect, SafeHtml};
|
use hemx_core::{BuildFingerprint, EffectBatch, FromForm, Handle, IntoEffect, SafeHtml};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::fmt;
|
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
|
|
||||||
@@ -229,11 +228,61 @@ pub enum InteractionFormRejection {
|
|||||||
InvalidHandle,
|
InvalidHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct HandlerErrorContext {
|
||||||
|
pub handle_id: u32,
|
||||||
|
pub fingerprint: BuildFingerprint,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum HandlerFailure {
|
||||||
|
Response { status: StatusCode, message: String },
|
||||||
|
Effects(EffectBatch),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IntoHandlerFailure {
|
||||||
|
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_handler_failure<E>(
|
||||||
|
error: E,
|
||||||
|
handle_id: u32,
|
||||||
|
fingerprint: BuildFingerprint,
|
||||||
|
) -> Result<EffectBatch, DispatchRejection>
|
||||||
|
where
|
||||||
|
E: IntoHandlerFailure,
|
||||||
|
{
|
||||||
|
match error.into_handler_failure(HandlerErrorContext {
|
||||||
|
handle_id,
|
||||||
|
fingerprint,
|
||||||
|
}) {
|
||||||
|
HandlerFailure::Effects(batch) => Ok(batch),
|
||||||
|
failure => Err(DispatchRejection::HandlerError(failure)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub enum DispatchRejection {
|
pub enum DispatchRejection {
|
||||||
UnknownHandle(u32),
|
UnknownHandle(u32),
|
||||||
InvalidForm { handle_id: u32, message: String },
|
InvalidForm { handle_id: u32, message: String },
|
||||||
HandlerError { handle_id: u32, message: String },
|
HandlerError(HandlerFailure),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HandlerFailure {
|
||||||
|
pub fn internal(message: impl Into<String>) -> Self {
|
||||||
|
Self::response(StatusCode::INTERNAL_SERVER_ERROR, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn response(status: StatusCode, message: impl Into<String>) -> Self {
|
||||||
|
Self::Response {
|
||||||
|
status,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn effects(effects: impl IntoEffect, context: HandlerErrorContext) -> Self {
|
||||||
|
Self::Effects(effects.into_batch(context.fingerprint))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EffectResponse {
|
impl EffectResponse {
|
||||||
@@ -528,6 +577,67 @@ impl HandlerRegistry {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn register_state_result<S, C, E, O>(
|
||||||
|
mut self,
|
||||||
|
handle_id: u32,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C) -> Result<O, E> + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
O: IntoEffect,
|
||||||
|
E: IntoHandlerFailure,
|
||||||
|
{
|
||||||
|
let fingerprint = self.fingerprint;
|
||||||
|
self.handlers.insert(
|
||||||
|
handle_id,
|
||||||
|
Box::new(move |form| {
|
||||||
|
let handle_id = form.handle_id;
|
||||||
|
match handler(C::from_handler_state(state.clone())) {
|
||||||
|
Ok(effects) => Ok(effects.into_batch(fingerprint)),
|
||||||
|
Err(error) => map_handler_failure(error, handle_id, fingerprint),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn register_state_typed_result<S, C, T, E, O>(
|
||||||
|
mut self,
|
||||||
|
handle_id: u32,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C, T) -> Result<O, E> + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
T: FromInteractionForm,
|
||||||
|
O: IntoEffect,
|
||||||
|
E: IntoHandlerFailure,
|
||||||
|
{
|
||||||
|
let fingerprint = self.fingerprint;
|
||||||
|
self.handlers.insert(
|
||||||
|
handle_id,
|
||||||
|
Box::new(move |form| {
|
||||||
|
let handle_id = form.handle_id;
|
||||||
|
let input = T::from_interaction_form(&form).map_err(|error| {
|
||||||
|
DispatchRejection::InvalidForm {
|
||||||
|
handle_id,
|
||||||
|
message: error.message,
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
match handler(C::from_handler_state(state.clone()), input) {
|
||||||
|
Ok(effects) => Ok(effects.into_batch(fingerprint)),
|
||||||
|
Err(error) => map_handler_failure(error, handle_id, fingerprint),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn register_async<E, F>(
|
pub fn register_async<E, F>(
|
||||||
mut self,
|
mut self,
|
||||||
handle_id: u32,
|
handle_id: u32,
|
||||||
@@ -604,6 +714,37 @@ impl HandlerRegistry {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn register_state_async_result<S, C, E, O, F>(
|
||||||
|
mut self,
|
||||||
|
handle_id: u32,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
F: Future<Output = Result<O, E>> + Send + 'static,
|
||||||
|
O: IntoEffect,
|
||||||
|
E: IntoHandlerFailure,
|
||||||
|
{
|
||||||
|
let fingerprint = self.fingerprint;
|
||||||
|
self.async_handlers.insert(
|
||||||
|
handle_id,
|
||||||
|
Box::new(move |form| {
|
||||||
|
let handle_id = form.handle_id;
|
||||||
|
let future = handler(C::from_handler_state(state.clone()));
|
||||||
|
Box::pin(async move {
|
||||||
|
match future.await {
|
||||||
|
Ok(effects) => Ok(effects.into_batch(fingerprint)),
|
||||||
|
Err(error) => map_handler_failure(error, handle_id, fingerprint),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn register_state_typed_async<S, C, T, E, F>(
|
pub fn register_state_typed_async<S, C, T, E, F>(
|
||||||
mut self,
|
mut self,
|
||||||
handle_id: u32,
|
handle_id: u32,
|
||||||
@@ -640,6 +781,7 @@ impl HandlerRegistry {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
pub fn register_state_typed_async_result<S, C, T, E, O, F>(
|
pub fn register_state_typed_async_result<S, C, T, E, O, F>(
|
||||||
mut self,
|
mut self,
|
||||||
handle_id: u32,
|
handle_id: u32,
|
||||||
@@ -652,7 +794,7 @@ impl HandlerRegistry {
|
|||||||
T: FromInteractionForm,
|
T: FromInteractionForm,
|
||||||
F: Future<Output = Result<O, E>> + Send + 'static,
|
F: Future<Output = Result<O, E>> + Send + 'static,
|
||||||
O: IntoEffect,
|
O: IntoEffect,
|
||||||
E: fmt::Display,
|
E: IntoHandlerFailure,
|
||||||
{
|
{
|
||||||
let fingerprint = self.fingerprint;
|
let fingerprint = self.fingerprint;
|
||||||
self.async_handlers.insert(
|
self.async_handlers.insert(
|
||||||
@@ -672,13 +814,10 @@ impl HandlerRegistry {
|
|||||||
};
|
};
|
||||||
let future = handler(C::from_handler_state(state.clone()), input);
|
let future = handler(C::from_handler_state(state.clone()), input);
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
future
|
match future.await {
|
||||||
.await
|
Ok(effects) => Ok(effects.into_batch(fingerprint)),
|
||||||
.map(|effects| effects.into_batch(fingerprint))
|
Err(error) => map_handler_failure(error, handle_id, fingerprint),
|
||||||
.map_err(|error| DispatchRejection::HandlerError {
|
}
|
||||||
handle_id,
|
|
||||||
message: error.to_string(),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -758,6 +897,39 @@ impl HandlerRegistry {
|
|||||||
self.register_state_typed(handle.id().id, state, handler)
|
self.register_state_typed(handle.id().id, state, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn on_state_result<I, S, C, E, O>(
|
||||||
|
self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C) -> Result<O, E> + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
O: IntoEffect,
|
||||||
|
E: IntoHandlerFailure,
|
||||||
|
{
|
||||||
|
self.register_state_result(handle.id().id, state, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn on_state_form_result<I, S, C, T, E, O>(
|
||||||
|
self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C, T) -> Result<O, E> + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
T: FromInteractionForm,
|
||||||
|
O: IntoEffect,
|
||||||
|
E: IntoHandlerFailure,
|
||||||
|
{
|
||||||
|
self.register_state_typed_result(handle.id().id, state, handler)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn on_async<I, E, F>(
|
pub fn on_async<I, E, F>(
|
||||||
self,
|
self,
|
||||||
handle: Handle<I>,
|
handle: Handle<I>,
|
||||||
@@ -798,6 +970,23 @@ impl HandlerRegistry {
|
|||||||
self.register_state_async(handle.id().id, state, handler)
|
self.register_state_async(handle.id().id, state, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn on_state_async_result<I, S, C, E, O, F>(
|
||||||
|
self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
state: S,
|
||||||
|
handler: impl Fn(C) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
F: Future<Output = Result<O, E>> + Send + 'static,
|
||||||
|
O: IntoEffect,
|
||||||
|
E: IntoHandlerFailure,
|
||||||
|
{
|
||||||
|
self.register_state_async_result(handle.id().id, state, handler)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn on_state_form_async<I, S, C, T, E, F>(
|
pub fn on_state_form_async<I, S, C, T, E, F>(
|
||||||
self,
|
self,
|
||||||
handle: Handle<I>,
|
handle: Handle<I>,
|
||||||
@@ -814,6 +1003,7 @@ impl HandlerRegistry {
|
|||||||
self.register_state_typed_async(handle.id().id, state, handler)
|
self.register_state_typed_async(handle.id().id, state, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
pub fn on_state_form_async_result<I, S, C, T, E, O, F>(
|
pub fn on_state_form_async_result<I, S, C, T, E, O, F>(
|
||||||
self,
|
self,
|
||||||
handle: Handle<I>,
|
handle: Handle<I>,
|
||||||
@@ -826,7 +1016,7 @@ impl HandlerRegistry {
|
|||||||
T: FromInteractionForm,
|
T: FromInteractionForm,
|
||||||
F: Future<Output = Result<O, E>> + Send + 'static,
|
F: Future<Output = Result<O, E>> + Send + 'static,
|
||||||
O: IntoEffect,
|
O: IntoEffect,
|
||||||
E: fmt::Display,
|
E: IntoHandlerFailure,
|
||||||
{
|
{
|
||||||
self.register_state_typed_async_result(handle.id().id, state, handler)
|
self.register_state_typed_async_result(handle.id().id, state, handler)
|
||||||
}
|
}
|
||||||
@@ -892,6 +1082,41 @@ where
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn on_state_result<I, C, E, O>(
|
||||||
|
mut self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
handler: impl Fn(C) -> Result<O, E> + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
O: IntoEffect,
|
||||||
|
E: IntoHandlerFailure,
|
||||||
|
{
|
||||||
|
self.registry = self
|
||||||
|
.registry
|
||||||
|
.on_state_result(handle, self.state.clone(), handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn on_result<I, C, T, E, O>(
|
||||||
|
mut self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
handler: impl Fn(C, T) -> Result<O, E> + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
T: FromInteractionForm,
|
||||||
|
O: IntoEffect,
|
||||||
|
E: IntoHandlerFailure,
|
||||||
|
{
|
||||||
|
self.registry = self
|
||||||
|
.registry
|
||||||
|
.on_state_form_result(handle, self.state.clone(), handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn on_state_async<I, C, E, F>(
|
pub fn on_state_async<I, C, E, F>(
|
||||||
mut self,
|
mut self,
|
||||||
handle: Handle<I>,
|
handle: Handle<I>,
|
||||||
@@ -908,6 +1133,24 @@ where
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn on_state_async_result<I, C, E, O, F>(
|
||||||
|
mut self,
|
||||||
|
handle: Handle<I>,
|
||||||
|
handler: impl Fn(C) -> F + Send + Sync + 'static,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
C: FromHandlerState<S>,
|
||||||
|
F: Future<Output = Result<O, E>> + Send + 'static,
|
||||||
|
O: IntoEffect,
|
||||||
|
E: IntoHandlerFailure,
|
||||||
|
{
|
||||||
|
self.registry = self
|
||||||
|
.registry
|
||||||
|
.on_state_async_result(handle, self.state.clone(), handler);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn on_async<I, C, T, E, F>(
|
pub fn on_async<I, C, T, E, F>(
|
||||||
mut self,
|
mut self,
|
||||||
handle: Handle<I>,
|
handle: Handle<I>,
|
||||||
@@ -925,6 +1168,7 @@ where
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
pub fn on_async_result<I, C, T, E, O, F>(
|
pub fn on_async_result<I, C, T, E, O, F>(
|
||||||
mut self,
|
mut self,
|
||||||
handle: Handle<I>,
|
handle: Handle<I>,
|
||||||
@@ -935,7 +1179,7 @@ where
|
|||||||
T: FromInteractionForm,
|
T: FromInteractionForm,
|
||||||
F: Future<Output = Result<O, E>> + Send + 'static,
|
F: Future<Output = Result<O, E>> + Send + 'static,
|
||||||
O: IntoEffect,
|
O: IntoEffect,
|
||||||
E: fmt::Display,
|
E: IntoHandlerFailure,
|
||||||
{
|
{
|
||||||
self.registry =
|
self.registry =
|
||||||
self.registry
|
self.registry
|
||||||
@@ -1117,11 +1361,12 @@ impl IntoResponse for DispatchRejection {
|
|||||||
format!("invalid hemx form for handle id {handle_id}: {message}"),
|
format!("invalid hemx form for handle id {handle_id}: {message}"),
|
||||||
)
|
)
|
||||||
.into_response(),
|
.into_response(),
|
||||||
Self::HandlerError { handle_id, message } => (
|
Self::HandlerError(HandlerFailure::Response { status, message }) => {
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
(status, message).into_response()
|
||||||
format!("hemx handler for handle id {handle_id} failed: {message}"),
|
}
|
||||||
)
|
Self::HandlerError(HandlerFailure::Effects(batch)) => {
|
||||||
.into_response(),
|
EffectResponse { batch }.into_response()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1257,8 +1502,8 @@ mod tests {
|
|||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
use futures_util::stream;
|
use futures_util::stream;
|
||||||
use scraper::{Html, Selector};
|
|
||||||
use hemx_core::{EffectBatch, EFFECT_BATCH_ABI_VERSION};
|
use hemx_core::{EffectBatch, EFFECT_BATCH_ABI_VERSION};
|
||||||
|
use scraper::{Html, Selector};
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
|
||||||
fn selector(value: &str) -> Selector {
|
fn selector(value: &str) -> Selector {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::http::{header, HeaderMap};
|
use axum::http::{header, HeaderMap};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use scraper::{Html, Selector};
|
|
||||||
use hemx_axum::{
|
use hemx_axum::{
|
||||||
interactions, runtime_js, DispatchRejection, EffectResponse, Form, InteractionForm,
|
interactions, runtime_js, DispatchRejection, EffectResponse, Form, HandlerErrorContext,
|
||||||
InteractionFormRejection, InteractionRequest, PageMode, PageRequest, PageResponse,
|
HandlerFailure, InteractionForm, InteractionFormRejection, InteractionRequest,
|
||||||
HEMX_CONTENT_TYPE, HEMX_FINGERPRINT_HEADER, HEMX_PARTIAL_HEADER, HEMX_RUNTIME_CONTENT_TYPE,
|
IntoHandlerFailure, PageMode, PageRequest, PageResponse, HEMX_CONTENT_TYPE,
|
||||||
HEMX_TITLE_HEADER,
|
HEMX_FINGERPRINT_HEADER, HEMX_PARTIAL_HEADER, HEMX_RUNTIME_CONTENT_TYPE, HEMX_TITLE_HEADER,
|
||||||
};
|
};
|
||||||
use hemx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot};
|
use hemx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot};
|
||||||
|
use scraper::{Html, Selector};
|
||||||
|
|
||||||
fn selector(value: &str) -> Selector {
|
fn selector(value: &str) -> Selector {
|
||||||
Selector::parse(value).expect("test selector parses")
|
Selector::parse(value).expect("test selector parses")
|
||||||
@@ -306,6 +306,21 @@ impl std::fmt::Display for HandlerBoom {
|
|||||||
|
|
||||||
impl std::error::Error for HandlerBoom {}
|
impl std::error::Error for HandlerBoom {}
|
||||||
|
|
||||||
|
impl IntoHandlerFailure for HandlerBoom {
|
||||||
|
fn into_handler_failure(self, _context: HandlerErrorContext) -> HandlerFailure {
|
||||||
|
HandlerFailure::internal(self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct UiFailure;
|
||||||
|
|
||||||
|
impl IntoHandlerFailure for UiFailure {
|
||||||
|
fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
|
||||||
|
HandlerFailure::effects(Slot::<String>::new(3).text("try again"), context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn interaction_request_dispatches_async_typed_state_extractors() {
|
async fn interaction_request_dispatches_async_typed_state_extractors() {
|
||||||
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
|
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
|
||||||
@@ -359,10 +374,69 @@ async fn interaction_request_reports_async_result_handler_errors() {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
error,
|
error,
|
||||||
DispatchRejection::HandlerError {
|
DispatchRejection::HandlerError(HandlerFailure::Response {
|
||||||
handle_id: 11,
|
status: axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
message: "database unavailable".to_owned(),
|
message: "database unavailable".to_owned(),
|
||||||
}
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interaction_request_maps_async_result_errors_to_effects() {
|
||||||
|
// req: axum_integration/003 req: failure/004
|
||||||
|
async fn open_project(
|
||||||
|
State(_multiplier): State<u32>,
|
||||||
|
Form(_input): Form<OpenProject>,
|
||||||
|
) -> Result<impl IntoEffect, UiFailure> {
|
||||||
|
Err::<(), _>(UiFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = InteractionRequest::from(InteractionForm::for_handle(
|
||||||
|
Handle::<()>::new(12),
|
||||||
|
vec![("project_id".to_owned(), "21".to_owned())],
|
||||||
|
));
|
||||||
|
let response = request
|
||||||
|
.dispatch_async(
|
||||||
|
interactions(BuildFingerprint(4))
|
||||||
|
.with_state(2_u32)
|
||||||
|
.on_async_result(Handle::<()>::new(12), open_project)
|
||||||
|
.into_registry(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
response.batch.ops,
|
||||||
|
vec![Slot::<String>::new(3).text("try again")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interaction_request_maps_sync_result_errors_to_effects() {
|
||||||
|
// req: axum_integration/003 req: failure/004
|
||||||
|
fn open_project(
|
||||||
|
State(_multiplier): State<u32>,
|
||||||
|
Form(_input): Form<OpenProject>,
|
||||||
|
) -> Result<impl IntoEffect, UiFailure> {
|
||||||
|
Err::<(), _>(UiFailure)
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = InteractionRequest::from(InteractionForm::for_handle(
|
||||||
|
Handle::<()>::new(13),
|
||||||
|
vec![("project_id".to_owned(), "21".to_owned())],
|
||||||
|
));
|
||||||
|
let response = request
|
||||||
|
.dispatch(
|
||||||
|
interactions(BuildFingerprint(4))
|
||||||
|
.with_state(2_u32)
|
||||||
|
.on_result(Handle::<()>::new(13), open_project)
|
||||||
|
.into_registry(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
response.batch.ops,
|
||||||
|
vec![Slot::<String>::new(3).text("try again")]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+50
-35
@@ -1,4 +1,5 @@
|
|||||||
use proc_macro::TokenStream;
|
use proc_macro::TokenStream;
|
||||||
|
use proc_macro2::TokenStream as TokenStream2;
|
||||||
use quote::{format_ident, quote};
|
use quote::{format_ident, quote};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use syn::parse::Parser;
|
use syn::parse::Parser;
|
||||||
@@ -651,20 +652,9 @@ fn add_component_register_helper(mut module: ItemMod, component: &str) -> ItemMo
|
|||||||
if handlers.is_empty() {
|
if handlers.is_empty() {
|
||||||
return module;
|
return module;
|
||||||
}
|
}
|
||||||
let calls = handlers.iter().map(|handler| {
|
let calls = handlers
|
||||||
let ident = &handler.ident;
|
.iter()
|
||||||
if handler.typed_arg_count == 1 && handler.is_async {
|
.map(|handler| component_registration_call(handler, &component_ident));
|
||||||
quote!(.on_state_async(super::#component_ident::#ident, #ident))
|
|
||||||
} else if handler.typed_arg_count == 1 {
|
|
||||||
quote!(.on_state(super::#component_ident::#ident, #ident))
|
|
||||||
} else if handler.is_async && handler.returns_result {
|
|
||||||
quote!(.on_async_result(super::#component_ident::#ident, #ident))
|
|
||||||
} else if handler.is_async {
|
|
||||||
quote!(.on_async(super::#component_ident::#ident, #ident))
|
|
||||||
} else {
|
|
||||||
quote!(.on(super::#component_ident::#ident, #ident))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let register: Item = syn::parse2(quote! {
|
let register: Item = syn::parse2(quote! {
|
||||||
pub fn register(
|
pub fn register(
|
||||||
registry: ::hemx_axum::StateHandlerRegistry<#state_ty>,
|
registry: ::hemx_axum::StateHandlerRegistry<#state_ty>,
|
||||||
@@ -676,20 +666,9 @@ fn add_component_register_helper(mut module: ItemMod, component: &str) -> ItemMo
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.expect("generated component register helper parses");
|
.expect("generated component register helper parses");
|
||||||
let calls = handlers.iter().map(|handler| {
|
let calls = handlers
|
||||||
let ident = &handler.ident;
|
.iter()
|
||||||
if handler.typed_arg_count == 1 && handler.is_async {
|
.map(|handler| component_registration_call(handler, &component_ident));
|
||||||
quote!(.on_state_async(super::#component_ident::#ident, #ident))
|
|
||||||
} else if handler.typed_arg_count == 1 {
|
|
||||||
quote!(.on_state(super::#component_ident::#ident, #ident))
|
|
||||||
} else if handler.is_async && handler.returns_result {
|
|
||||||
quote!(.on_async_result(super::#component_ident::#ident, #ident))
|
|
||||||
} else if handler.is_async {
|
|
||||||
quote!(.on_async(super::#component_ident::#ident, #ident))
|
|
||||||
} else {
|
|
||||||
quote!(.on(super::#component_ident::#ident, #ident))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let register_with_state: Item = syn::parse2(quote! {
|
let register_with_state: Item = syn::parse2(quote! {
|
||||||
pub fn register_with_state(
|
pub fn register_with_state(
|
||||||
registry: ::hemx_axum::HandlerRegistry,
|
registry: ::hemx_axum::HandlerRegistry,
|
||||||
@@ -710,6 +689,30 @@ fn add_component_register_helper(mut module: ItemMod, component: &str) -> ItemMo
|
|||||||
module
|
module
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn component_registration_call(
|
||||||
|
handler: &ComponentHandler,
|
||||||
|
component_ident: &syn::Ident,
|
||||||
|
) -> TokenStream2 {
|
||||||
|
let ident = &handler.ident;
|
||||||
|
if handler.typed_arg_count == 1 && handler.is_async && handler.returns_result {
|
||||||
|
quote!(.on_state_async_result(super::#component_ident::#ident, #ident))
|
||||||
|
} else if handler.typed_arg_count == 1 && handler.returns_result {
|
||||||
|
quote!(.on_state_result(super::#component_ident::#ident, #ident))
|
||||||
|
} else if handler.typed_arg_count == 1 && handler.is_async {
|
||||||
|
quote!(.on_state_async(super::#component_ident::#ident, #ident))
|
||||||
|
} else if handler.typed_arg_count == 1 {
|
||||||
|
quote!(.on_state(super::#component_ident::#ident, #ident))
|
||||||
|
} else if handler.is_async && handler.returns_result {
|
||||||
|
quote!(.on_async_result(super::#component_ident::#ident, #ident))
|
||||||
|
} else if handler.returns_result {
|
||||||
|
quote!(.on_result(super::#component_ident::#ident, #ident))
|
||||||
|
} else if handler.is_async {
|
||||||
|
quote!(.on_async(super::#component_ident::#ident, #ident))
|
||||||
|
} else {
|
||||||
|
quote!(.on(super::#component_ident::#ident, #ident))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn component_contract_errors(
|
fn component_contract_errors(
|
||||||
path: &PathBuf,
|
path: &PathBuf,
|
||||||
component: Option<&str>,
|
component: Option<&str>,
|
||||||
@@ -1048,18 +1051,28 @@ mod tests {
|
|||||||
let module = parse_quote! {
|
let module = parse_quote! {
|
||||||
mod handlers {
|
mod handlers {
|
||||||
#[hemx::handler]
|
#[hemx::handler]
|
||||||
fn create(app: super::App, form: super::NewTodo) -> impl hemx::IntoEffect {
|
fn sync_form(app: super::App, form: super::NewTodo) -> impl hemx::IntoEffect {
|
||||||
hemx::EventName::new("created").emit("")
|
hemx::EventName::new("sync-form").emit("")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hemx::handler]
|
#[hemx::handler]
|
||||||
async fn increment(app: super::App) -> impl hemx::IntoEffect {
|
async fn async_state(app: super::App) -> impl hemx::IntoEffect {
|
||||||
hemx::EventName::new("incremented").emit("")
|
hemx::EventName::new("async-state").emit("")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hemx::handler]
|
#[hemx::handler]
|
||||||
async fn save(app: super::App, form: super::NewTodo) -> Result<impl hemx::IntoEffect, super::Error> {
|
fn sync_result(app: super::App) -> Result<impl hemx::IntoEffect, super::Error> {
|
||||||
Ok(hemx::EventName::new("saved").emit(""))
|
Ok(hemx::EventName::new("sync-result").emit(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[hemx::handler]
|
||||||
|
async fn async_result(app: super::App) -> Result<impl hemx::IntoEffect, super::Error> {
|
||||||
|
Ok(hemx::EventName::new("async-result").emit(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[hemx::handler]
|
||||||
|
async fn async_form_result(app: super::App, form: super::NewTodo) -> Result<impl hemx::IntoEffect, super::Error> {
|
||||||
|
Ok(hemx::EventName::new("async-form-result").emit(""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1070,11 +1083,13 @@ mod tests {
|
|||||||
assert!(generated.contains("register_with_state"), "{generated}");
|
assert!(generated.contains("register_with_state"), "{generated}");
|
||||||
assert!(generated.contains("StateHandlerRegistry"), "{generated}");
|
assert!(generated.contains("StateHandlerRegistry"), "{generated}");
|
||||||
assert!(
|
assert!(
|
||||||
generated.contains("super :: todos :: create"),
|
generated.contains("super :: todos :: sync_form"),
|
||||||
"{generated}"
|
"{generated}"
|
||||||
);
|
);
|
||||||
assert!(generated.contains(". on"), "{generated}");
|
assert!(generated.contains(". on"), "{generated}");
|
||||||
assert!(generated.contains(". on_state_async"), "{generated}");
|
assert!(generated.contains(". on_state_async"), "{generated}");
|
||||||
|
assert!(generated.contains(". on_state_result"), "{generated}");
|
||||||
|
assert!(generated.contains(". on_state_async_result"), "{generated}");
|
||||||
assert!(generated.contains(". on_async_result"), "{generated}");
|
assert!(generated.contains(". on_async_result"), "{generated}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-11
@@ -18,6 +18,10 @@ pub use hemx_derive::{app, component, form, handler, surface};
|
|||||||
/// handles/forms/classes, `Html`, `IntoEffect`, and tuple composition. req: dx/001 req: public_api/005
|
/// handles/forms/classes, `Html`, `IntoEffect`, and tuple composition. req: dx/001 req: public_api/005
|
||||||
pub mod advanced {
|
pub mod advanced {
|
||||||
pub use hemx_core::*;
|
pub use hemx_core::*;
|
||||||
|
|
||||||
|
pub fn render(view: &impl hemplate::Hemplate) -> crate::Html {
|
||||||
|
crate::render_template(view)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rendered, checked HTML produced by hemplate/hemx rendering helpers.
|
/// Rendered, checked HTML produced by hemplate/hemx rendering helpers.
|
||||||
@@ -68,13 +72,17 @@ pub mod __private {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render(view: &impl hemplate::Hemplate) -> Html {
|
fn render_template(view: &impl hemplate::Hemplate) -> Html {
|
||||||
let mut html = String::new();
|
let mut html = String::new();
|
||||||
view.render_into(&mut html)
|
view.render_into(&mut html)
|
||||||
.expect("hemplate view renders into hemx effect payload");
|
.expect("hemplate view renders into hemx effect payload");
|
||||||
__private::html_trusted(html)
|
__private::html_trusted(html)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn page(view: &impl hemplate::Hemplate) -> Html {
|
||||||
|
render_template(view)
|
||||||
|
}
|
||||||
|
|
||||||
/// A hemplate partial that carries the stable key for a generated keyed target.
|
/// A hemplate partial that carries the stable key for a generated keyed target.
|
||||||
///
|
///
|
||||||
/// Generated keyed target helpers use this to keep ordinary handler code at the
|
/// Generated keyed target helpers use this to keep ordinary handler code at the
|
||||||
@@ -86,7 +94,7 @@ pub trait KeyedPartial {
|
|||||||
|
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub fn render_html(view: &impl hemplate::Hemplate) -> Html {
|
pub fn render_html(view: &impl hemplate::Hemplate) -> Html {
|
||||||
render(view)
|
render_template(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compatibility shim for raw slot rendering.
|
/// Compatibility shim for raw slot rendering.
|
||||||
@@ -107,7 +115,7 @@ pub trait RenderSlotExt {
|
|||||||
|
|
||||||
impl<T> RenderSlotExt for Slot<T> {
|
impl<T> RenderSlotExt for Slot<T> {
|
||||||
fn render(self, view: &impl hemplate::Hemplate) -> Effect {
|
fn render(self, view: &impl hemplate::Hemplate) -> Effect {
|
||||||
self.html(render(view))
|
self.html(render_template(view))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,21 +156,20 @@ where
|
|||||||
K: ToString,
|
K: ToString,
|
||||||
{
|
{
|
||||||
fn append(self, key: K, view: &impl hemplate::Hemplate) -> Effect {
|
fn append(self, key: K, view: &impl hemplate::Hemplate) -> Effect {
|
||||||
self.append_html(key, render(view))
|
self.append_html(key, render_template(view))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepend(self, key: K, view: &impl hemplate::Hemplate) -> Effect {
|
fn prepend(self, key: K, view: &impl hemplate::Hemplate) -> Effect {
|
||||||
self.prepend_html(key, render(view))
|
self.prepend_html(key, render_template(view))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace(self, key: K, view: &impl hemplate::Hemplate) -> Effect {
|
fn replace(self, key: K, view: &impl hemplate::Hemplate) -> Effect {
|
||||||
self.replace_html(key, render(view))
|
self.replace_html(key, render_template(view))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod prelude {
|
pub mod prelude {
|
||||||
pub use crate::render;
|
pub use crate::{page, Html};
|
||||||
pub use crate::Html;
|
|
||||||
pub use hemx_core::{
|
pub use hemx_core::{
|
||||||
navigate, push, redirect, replace, Atom, ComponentRef, CssClass, CssClasses, EventName,
|
navigate, push, redirect, replace, Atom, ComponentRef, CssClass, CssClasses, EventName,
|
||||||
Form, FormModel, FormValue, Handle, IntoEffect, ParamName,
|
Form, FormModel, FormValue, Handle, IntoEffect, ParamName,
|
||||||
@@ -182,13 +189,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn render_is_the_short_safe_html_helper() {
|
fn page_is_the_short_safe_html_helper() {
|
||||||
// req: dx/006 req: html_safety/002
|
// req: dx/006 req: html_safety/002
|
||||||
assert_eq!(crate::render(&InlineView).as_str(), "<strong>ok</strong>");
|
assert_eq!(crate::page(&InlineView).as_str(), "<strong>ok</strong>");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
crate::render_html(&InlineView).as_str(),
|
crate::render_html(&InlineView).as_str(),
|
||||||
"<strong>ok</strong>"
|
"<strong>ok</strong>"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
crate::advanced::render(&InlineView).as_str(),
|
||||||
|
"<strong>ok</strong>"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -196,7 +207,7 @@ mod tests {
|
|||||||
// req: dx/006 req: public_api/005 req: html_safety/002
|
// req: dx/006 req: public_api/005 req: html_safety/002
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
let html = Html::join([render(&InlineView)]);
|
let html = Html::join([page(&InlineView)]);
|
||||||
|
|
||||||
assert_eq!(html.as_str(), "<strong>ok</strong>");
|
assert_eq!(html.as_str(), "<strong>ok</strong>");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user