f1d84e4837
Remove the hand-written push_str renderer for the v0 todos view so the canonical beginner example uses its .heml template for the whole page path, including form validation targets, notices, and keyed rows. req: examples/001 req: canonical_authoring/003
789 lines
23 KiB
Rust
789 lines
23 KiB
Rust
use axum::extract::{Query, State};
|
|
use axum::response::IntoResponse;
|
|
use axum::routing::get;
|
|
use axum::Router;
|
|
use futures_util::{stream, StreamExt};
|
|
use hemplate::Hemplate;
|
|
use hemx::{Html, IntoEffect};
|
|
use hemx_axum::{
|
|
interactions, runtime_js, runtime_js_path, sse, EffectResponse, Form, HandlerErrorContext,
|
|
HandlerFailure, InteractionHandlers, InteractionRequest, IntoHandlerFailure, PageRequest,
|
|
};
|
|
use hemx_v0_examples::ui;
|
|
use hemx_v0_examples::ui::{auth, counter, notifications, page_swap, todo_row, todos, wizard};
|
|
use std::collections::BTreeMap;
|
|
use std::convert::Infallible;
|
|
use std::net::SocketAddr;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
#[derive(Default)]
|
|
struct ExampleState {
|
|
counter: Mutex<u64>,
|
|
todos: Mutex<Vec<TodoRecord>>,
|
|
wizard_step: Mutex<u8>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
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,
|
|
}
|
|
|
|
#[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: TodoTitle,
|
|
}
|
|
|
|
#[hemx::form("rename_todo")]
|
|
struct RenameTodo {
|
|
id: TodoId,
|
|
title: TodoTitle,
|
|
}
|
|
|
|
#[hemx::form("delete_todo")]
|
|
struct DeleteTodo {
|
|
id: TodoId,
|
|
}
|
|
|
|
#[hemx::form("wizard_input")]
|
|
struct WizardInput {
|
|
step: String,
|
|
}
|
|
|
|
#[hemx::form("credentials")]
|
|
struct Credentials {
|
|
email: String,
|
|
password: String,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct AppError(String);
|
|
|
|
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 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)]
|
|
struct Todos {
|
|
summary: String,
|
|
notice: String,
|
|
rows: Vec<TodoRow>,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct TodoRow {
|
|
id: TodoId,
|
|
title: String,
|
|
}
|
|
|
|
impl hemx::KeyedPartial for TodoRow {
|
|
fn hemx_key(&self) -> String {
|
|
self.id.to_string()
|
|
}
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
struct Counter;
|
|
|
|
#[derive(Hemplate)]
|
|
struct Wizard;
|
|
|
|
#[derive(Hemplate)]
|
|
struct Auth;
|
|
|
|
#[derive(Hemplate)]
|
|
struct Notifications;
|
|
|
|
#[derive(Hemplate)]
|
|
struct PageSwap {
|
|
content: Html,
|
|
title: &'static str,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
struct AppShell {
|
|
runtime_src: &'static str,
|
|
body: Html,
|
|
}
|
|
|
|
#[derive(Hemplate)]
|
|
#[hemplate = "partials"]
|
|
struct DocsContent {
|
|
message: &'static str,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
let state = Arc::new(ExampleState::default());
|
|
let app = Router::new()
|
|
.route("/", get(home).post(interact))
|
|
.route("/docs", get(docs).post(interact))
|
|
.route("/events", get(events))
|
|
.route(runtime_js_path(), get(runtime))
|
|
.with_state(state);
|
|
|
|
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
|
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
|
println!("hemx v0 examples: http://{addr}");
|
|
axum::serve(listener, app).await.unwrap();
|
|
}
|
|
|
|
// req: examples/001
|
|
async fn home(request: PageRequest) -> impl IntoResponse {
|
|
request
|
|
.page_html(all_examples(), shell)
|
|
.title("hemx v0 examples")
|
|
.fingerprint(ui::BUILD_FINGERPRINT)
|
|
}
|
|
|
|
// req: page_swap/002, req: examples/001
|
|
async fn docs(request: PageRequest) -> impl IntoResponse {
|
|
let partial = render_page_swap("Docs", "This page was swapped without a full reload.");
|
|
request
|
|
.page_html(partial, shell)
|
|
.title("Docs")
|
|
.fingerprint(ui::BUILD_FINGERPRINT)
|
|
}
|
|
|
|
async fn interact(
|
|
State(state): State<Arc<ExampleState>>,
|
|
request: InteractionRequest,
|
|
) -> Result<EffectResponse, impl IntoResponse> {
|
|
request.dispatch_async(handlers(state)).await
|
|
}
|
|
|
|
async fn runtime() -> impl IntoResponse {
|
|
runtime_js()
|
|
}
|
|
|
|
// 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 = 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 = notifications::notifications.set(format!("Server event #{count}"));
|
|
Some((
|
|
Ok::<_, Infallible>(effect.into_batch(ui::BUILD_FINGERPRINT)),
|
|
count + 1,
|
|
))
|
|
})
|
|
.boxed();
|
|
sse(batches)
|
|
}
|
|
|
|
#[hemx::app(
|
|
counter_handlers,
|
|
todo_handlers,
|
|
todo_row_handlers,
|
|
wizard_handlers,
|
|
auth_handlers
|
|
)]
|
|
fn handlers(state: Arc<ExampleState>) -> InteractionHandlers {
|
|
interactions(ui::BUILD_FINGERPRINT)
|
|
}
|
|
|
|
fn all_examples() -> Html {
|
|
// req: html_safety/001 req: html_safety/002 req: component/003
|
|
Html::join([
|
|
counter::page(&Counter),
|
|
ui::page(&todos_view(&[])),
|
|
wizard::page(&Wizard),
|
|
auth::page(&Auth),
|
|
render_page_swap("Welcome", "Welcome"),
|
|
notifications::page(&Notifications),
|
|
])
|
|
}
|
|
|
|
fn shell(body: Html) -> Html {
|
|
// req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003
|
|
ui::page(&AppShell {
|
|
runtime_src: runtime_js_path(),
|
|
body,
|
|
})
|
|
}
|
|
|
|
#[hemx::component("counter")]
|
|
mod counter_handlers {
|
|
use super::*;
|
|
|
|
#[hemx::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)
|
|
}
|
|
}
|
|
|
|
#[hemx::component("todos")]
|
|
mod todo_handlers {
|
|
use super::*;
|
|
|
|
#[hemx::handler]
|
|
async fn add_todo(
|
|
State(state): State<Arc<ExampleState>>,
|
|
Form(form): Form<NewTodo>,
|
|
) -> 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())),
|
|
todos::notice.set("Add a title to create a todo"),
|
|
));
|
|
}
|
|
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 title = form.title.into_string();
|
|
todos.push(TodoRecord {
|
|
id,
|
|
title: title.clone(),
|
|
});
|
|
let summary = todo_summary(&todos);
|
|
Ok((
|
|
todos::todo_row.append(TodoRow {
|
|
id: TodoId(id),
|
|
title: title.clone(),
|
|
}),
|
|
todos::summary.set(summary),
|
|
todos::notice.set(format!("Added {title}")),
|
|
todos::new_todo.clear(),
|
|
))
|
|
}
|
|
}
|
|
|
|
#[hemx::component("todo_row")]
|
|
mod todo_row_handlers {
|
|
use super::*;
|
|
|
|
#[hemx::handler]
|
|
async fn rename_todo(
|
|
State(state): State<Arc<ExampleState>>,
|
|
Form(form): Form<RenameTodo>,
|
|
) -> impl IntoEffect {
|
|
rename_todo_effect(state, form)
|
|
}
|
|
|
|
#[hemx::handler]
|
|
async fn delete_todo(
|
|
State(state): State<Arc<ExampleState>>,
|
|
Form(form): Form<DeleteTodo>,
|
|
) -> impl IntoEffect {
|
|
delete_todo_effect(state, form)
|
|
}
|
|
}
|
|
|
|
#[hemx::component("wizard")]
|
|
mod wizard_handlers {
|
|
use super::*;
|
|
|
|
#[hemx::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))
|
|
}
|
|
}
|
|
|
|
#[hemx::component("auth")]
|
|
mod auth_handlers {
|
|
use super::*;
|
|
|
|
#[hemx::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
|
|
if form.title.as_str().is_empty() {
|
|
return Some((
|
|
todo_row::rename_todo_form.focus("title"),
|
|
todos::notice.set("Add a title before renaming"),
|
|
));
|
|
}
|
|
|
|
let mut todos = state.todos.lock().unwrap();
|
|
todos
|
|
.iter_mut()
|
|
.find(|todo| todo.id == form.id.0)
|
|
.map(|todo| {
|
|
todo.title = form.title.into_string();
|
|
(
|
|
todos::todo_row.replace(TodoRow {
|
|
id: form.id,
|
|
title: todo.title.clone(),
|
|
}),
|
|
todos::notice.set("Todo renamed"),
|
|
)
|
|
})
|
|
}
|
|
|
|
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.0);
|
|
let deleted = todos.len() != before;
|
|
let summary = todo_summary(&todos);
|
|
(
|
|
deleted.then(|| todos::todo_row.remove(form.id)),
|
|
deleted.then(|| todos::summary.set(summary)),
|
|
deleted.then(|| todos::notice.set("Todo deleted")),
|
|
)
|
|
}
|
|
|
|
fn todos_view(todos: &[TodoRecord]) -> Todos {
|
|
// req: html_safety/002 req: view/001 req: canonical_authoring/003
|
|
Todos {
|
|
summary: todo_summary(todos),
|
|
notice: String::new(),
|
|
rows: todo_rows(todos),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn refresh_todo_rows_effect(todos: &[TodoRecord]) -> impl IntoEffect {
|
|
// req: canonical_authoring/003
|
|
let row_updates = todo_rows(todos)
|
|
.into_iter()
|
|
.map(|row| todos::todo_row.replace(row))
|
|
.collect::<Vec<_>>();
|
|
(
|
|
row_updates,
|
|
todos::summary.set(todo_summary(todos)),
|
|
todos::notice.set("Todos refreshed"),
|
|
)
|
|
}
|
|
|
|
fn todo_rows(todos: &[TodoRecord]) -> Vec<TodoRow> {
|
|
todos
|
|
.iter()
|
|
.map(|todo| TodoRow {
|
|
id: TodoId(todo.id),
|
|
title: todo.title.clone(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
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::page(&PageSwap {
|
|
content: render_docs_content(message),
|
|
title,
|
|
})
|
|
}
|
|
|
|
fn render_docs_content(message: &'static str) -> Html {
|
|
// req: html_safety/002 req: view/001 req: component/003
|
|
ui::page(&DocsContent { message })
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
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")
|
|
}
|
|
|
|
fn form<I>(handle: hemx::Handle<I>, fields: &[(&str, &str)]) -> hemx_axum::InteractionForm {
|
|
hemx_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() {
|
|
let html = shell(render_page_swap("Welcome", "Welcome"));
|
|
let document = Html::parse_document(html.as_str());
|
|
assert_eq!(
|
|
document
|
|
.select(&selector(document_title_selector()))
|
|
.next()
|
|
.map(|title| title.text().collect::<String>()),
|
|
Some("hemx v0 examples".to_owned())
|
|
);
|
|
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("{+=")),
|
|
"hemplate insertion markers must not leak into rendered text"
|
|
);
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
fn docs_content_payload_is_rendered_by_a_hemplate_view() {
|
|
let html = render_docs_content("This content came from a generated update response.");
|
|
let document = Html::parse_fragment(html.as_str());
|
|
assert_eq!(
|
|
document
|
|
.select(&selector(&heading_selector("", 1)))
|
|
.next()
|
|
.map(|heading| heading.text().collect::<String>()),
|
|
Some("Docs".to_owned())
|
|
);
|
|
assert_eq!(
|
|
document
|
|
.select(&selector(&prose_selector("")))
|
|
.next()
|
|
.map(|paragraph| paragraph.text().collect::<String>()),
|
|
Some("This content came from a generated update response.".to_owned())
|
|
);
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
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(&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())
|
|
);
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
fn todos_payload_is_rendered_by_a_hemplate_view() {
|
|
let todos = vec![TodoRecord {
|
|
id: 7,
|
|
title: "<b>Ship v0</b>".to_owned(),
|
|
}];
|
|
|
|
let html = ui::page(&todos_view(&todos));
|
|
let document = Html::parse_fragment(html.as_str());
|
|
let rows = document
|
|
.select(&selector(&keyed_items_selector("li")))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(rows.len(), 1);
|
|
let row = document
|
|
.select(&selector(&keyed_selector("li", 7)))
|
|
.next()
|
|
.expect("generated keyed row");
|
|
assert!(row.text().collect::<String>().contains("<b>Ship v0</b>"));
|
|
assert!(document
|
|
.select(&selector(&escaped_markup_selector("b")))
|
|
.next()
|
|
.is_none());
|
|
assert_eq!(
|
|
document
|
|
.select(&selector("[data-hemx-error-for='title']"))
|
|
.count(),
|
|
2,
|
|
"add and rename forms expose generated field-error targets"
|
|
);
|
|
assert_eq!(
|
|
document.select(&selector("[data-hemx-error]")).count(),
|
|
1,
|
|
"the app exposes one root-scoped transport error outlet"
|
|
);
|
|
}
|
|
|
|
// req: canonical_authoring/003 req: test/005
|
|
#[test]
|
|
fn reusable_todo_partial_supports_dynamic_replace_batches() {
|
|
let todos = vec![
|
|
TodoRecord {
|
|
id: 7,
|
|
title: "Ship v0".to_owned(),
|
|
},
|
|
TodoRecord {
|
|
id: 8,
|
|
title: "Write docs".to_owned(),
|
|
},
|
|
];
|
|
|
|
let initial = ui::page(&todos_view(&todos));
|
|
let document = Html::parse_fragment(initial.as_str());
|
|
assert_eq!(
|
|
document
|
|
.select(&selector(&keyed_items_selector("li")))
|
|
.count(),
|
|
2
|
|
);
|
|
|
|
let refresh =
|
|
inspect_batch(refresh_todo_rows_effect(&todos).into_batch(ui::BUILD_FINGERPRINT));
|
|
assert_eq!(refresh.op_count(), 4);
|
|
assert!(refresh.replaces_keyed_html_containing(todos::todo_row, "7", "Ship v0"));
|
|
assert!(refresh.replaces_keyed_html_containing(todos::todo_row, "8", "Write docs"));
|
|
assert!(refresh.updates_text(todos::summary));
|
|
assert!(refresh.updates_text(todos::notice));
|
|
}
|
|
|
|
// req: html_safety/002 req: view/001 req: test/005
|
|
#[test]
|
|
fn empty_todos_payload_is_rendered_by_a_hemplate_view() {
|
|
let html = ui::page(&todos_view(&[]));
|
|
let document = Html::parse_fragment(html.as_str());
|
|
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(handlers(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 validation = inspect_batch(
|
|
InteractionRequest::from(form(todos::add_todo, &[("title", " ")]))
|
|
.dispatch_async(handlers(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(handlers(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(handlers(state.clone()))
|
|
.await
|
|
.unwrap()
|
|
.batch,
|
|
);
|
|
assert_eq!(state.todos.lock().unwrap()[0].title, "Ship v0");
|
|
assert_eq!(add.op_count(), 4);
|
|
assert!(add.inserts_html_containing(todos::todo_row, "1", "Ship v0"));
|
|
assert!(add.updates_text(todos::summary));
|
|
assert!(add.updates_text(todos::notice));
|
|
assert!(add.payload_contains("Added Ship v0"));
|
|
assert!(add.resets_form(todos::new_todo));
|
|
|
|
let rename_validation = inspect_batch(
|
|
InteractionRequest::from(form(
|
|
todo_row::rename_todo,
|
|
&[("id", "1"), ("title", " ")],
|
|
))
|
|
.dispatch_async(handlers(state.clone()))
|
|
.await
|
|
.unwrap()
|
|
.batch,
|
|
);
|
|
assert_eq!(state.todos.lock().unwrap()[0].title, "Ship v0");
|
|
assert!(rename_validation.payload_contains("Add a title before renaming"));
|
|
assert!(rename_validation.updates_text(todos::notice));
|
|
|
|
let rename = inspect_batch(
|
|
InteractionRequest::from(form(
|
|
todo_row::rename_todo,
|
|
&[("id", "1"), ("title", "Ship 1.0")],
|
|
))
|
|
.dispatch_async(handlers(state.clone()))
|
|
.await
|
|
.unwrap()
|
|
.batch,
|
|
);
|
|
assert_eq!(state.todos.lock().unwrap()[0].title, "Ship 1.0");
|
|
assert_eq!(rename.op_count(), 2);
|
|
assert!(rename.replaces_keyed_html_containing(todos::todo_row, "1", "Ship 1.0"));
|
|
assert!(rename.updates_text(todos::notice));
|
|
|
|
let delete = inspect_batch(
|
|
InteractionRequest::from(form(todo_row::delete_todo, &[("id", "1")]))
|
|
.dispatch_async(handlers(state.clone()))
|
|
.await
|
|
.unwrap()
|
|
.batch,
|
|
);
|
|
assert!(state.todos.lock().unwrap().is_empty());
|
|
assert_eq!(delete.op_count(), 3);
|
|
assert!(delete.removes_key(todos::todo_row, "1"));
|
|
assert!(delete.updates_text(todos::summary));
|
|
assert!(delete.updates_text(todos::notice));
|
|
|
|
let missing_delete = inspect_batch(
|
|
InteractionRequest::from(form(todo_row::delete_todo, &[("id", "99")]))
|
|
.dispatch_async(handlers(state.clone()))
|
|
.await
|
|
.unwrap()
|
|
.batch,
|
|
);
|
|
assert!(missing_delete.is_empty());
|
|
|
|
let wizard = inspect_batch(
|
|
InteractionRequest::from(form(wizard::next_step, &[("step", "1")]))
|
|
.dispatch_async(handlers(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(handlers(state))
|
|
.await
|
|
.unwrap()
|
|
.batch,
|
|
);
|
|
assert!(auth.updates_text(auth::login_status));
|
|
assert!(auth.payload_contains("Signed in as demo@example.com"));
|
|
}
|
|
}
|