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:
+112
-33
@@ -6,7 +6,8 @@ use futures_util::{stream, StreamExt};
|
||||
use hemplate::Hemplate;
|
||||
use hemx::{Html, IntoEffect};
|
||||
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::{auth, counter, notifications, page_swap, todo_row, todos, wizard};
|
||||
@@ -31,20 +32,64 @@ struct TodoRecord {
|
||||
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")]
|
||||
struct NewTodo {
|
||||
title: String,
|
||||
title: TodoTitle,
|
||||
}
|
||||
|
||||
#[hemx::form("rename_todo")]
|
||||
struct RenameTodo {
|
||||
id: u64,
|
||||
title: String,
|
||||
id: TodoId,
|
||||
title: TodoTitle,
|
||||
}
|
||||
|
||||
#[hemx::form("delete_todo")]
|
||||
struct DeleteTodo {
|
||||
id: u64,
|
||||
id: TodoId,
|
||||
}
|
||||
|
||||
#[hemx::form("wizard_input")]
|
||||
@@ -59,15 +104,24 @@ struct Credentials {
|
||||
}
|
||||
|
||||
#[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 {
|
||||
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)]
|
||||
#[hemplate = "partials"]
|
||||
@@ -203,7 +257,7 @@ fn all_examples() -> Html {
|
||||
// req: html_safety/001 req: html_safety/002 req: component/003
|
||||
Html::join([
|
||||
counter::render(&Counter),
|
||||
ui::render(&todos_view(&[])),
|
||||
hemx::page(&todos_view(&[])),
|
||||
wizard::render(&Wizard),
|
||||
auth::render(&Auth),
|
||||
render_page_swap("Welcome", "Welcome"),
|
||||
@@ -213,7 +267,7 @@ fn all_examples() -> Html {
|
||||
|
||||
fn shell(body: Html) -> Html {
|
||||
// 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")]
|
||||
@@ -237,25 +291,29 @@ mod todo_handlers {
|
||||
async fn add_todo(
|
||||
State(state): State<Arc<ExampleState>>,
|
||||
Form(form): Form<NewTodo>,
|
||||
) -> Result<impl IntoEffect, TodoMutationError> {
|
||||
) -> Result<impl IntoEffect, AppError> {
|
||||
// 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 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 title = form.title.into_string();
|
||||
todos.push(TodoRecord {
|
||||
id,
|
||||
title: title.clone(),
|
||||
});
|
||||
let summary = todo_summary(&todos);
|
||||
Ok((
|
||||
created,
|
||||
todos::todo_row.append(TodoRow { id, title }),
|
||||
todos::summary.set(summary),
|
||||
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();
|
||||
todos
|
||||
.iter_mut()
|
||||
.find(|todo| todo.id == form.id)
|
||||
.find(|todo| todo.id == form.id.0)
|
||||
.map(|todo| {
|
||||
todo.title = form.title.clone();
|
||||
todo.title = form.title.into_string();
|
||||
todos::todo_row.replace(TodoRow {
|
||||
id: form.id,
|
||||
title: form.title,
|
||||
id: form.id.0,
|
||||
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
|
||||
let mut todos = state.todos.lock().unwrap();
|
||||
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 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 {
|
||||
// req: html_safety/002 req: view/001 req: component/003
|
||||
ui::render(&DocsContent { message })
|
||||
hemx::page(&DocsContent { message })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use scraper::{Html, Selector};
|
||||
use hemx_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,
|
||||
};
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
fn selector(value: &str) -> Selector {
|
||||
Selector::parse(value).expect("test selector parses")
|
||||
@@ -509,7 +567,7 @@ mod tests {
|
||||
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 rows = document
|
||||
.select(&selector(&keyed_items_selector("li")))
|
||||
@@ -529,7 +587,7 @@ mod tests {
|
||||
// req: html_safety/002 req: view/001 req: test/005
|
||||
#[test]
|
||||
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 rows = document
|
||||
.select(&selector(&list_item_selector("")))
|
||||
@@ -553,6 +611,27 @@ mod tests {
|
||||
assert!(counter.updates_text(counter::counter_value));
|
||||
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(
|
||||
InteractionRequest::from(form(todos::add_todo, &[("title", "Ship v0")]))
|
||||
.dispatch_async(registry(state.clone()))
|
||||
|
||||
Reference in New Issue
Block a user