#[hemx::surface] pub mod ui {} use hemplate::Hemplate; use hemx::{push, Html, IntoEffect}; use hemx_axum::{ interactions, Form, HandlerErrorContext, HandlerFailure, IntoHandlerFailure, Registry, State, }; use std::convert::Infallible; use std::fmt::Display; use std::str::FromStr; use std::sync::{Arc, Mutex}; use ui::dashboard; #[derive(Clone, Debug, PartialEq, Eq)] pub struct Session { user_id: UserId, email: String, csrf: CsrfToken, } impl Session { pub fn demo() -> Self { Self { user_id: UserId(42), email: "founder@example.com".to_owned(), csrf: CsrfToken("demo-csrf".to_owned()), } } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct UserId(u64); #[derive(Clone, Debug, PartialEq, Eq)] pub struct CsrfToken(String); impl FromStr for CsrfToken { type Err = Infallible; fn from_str(value: &str) -> Result { Ok(Self(value.to_owned())) } } impl Display for CsrfToken { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProjectName(String); impl ProjectName { fn as_str(&self) -> &str { &self.0 } } impl FromStr for ProjectName { type Err = Infallible; fn from_str(value: &str) -> Result { Ok(Self(value.trim().to_owned())) } } #[derive(Clone, Debug)] #[hemx::form("new_project")] pub struct NewProject { csrf: CsrfToken, name: ProjectName, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ProjectId(u64); impl Display for ProjectId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProjectRecord { id: ProjectId, name: String, owner: String, } #[derive(Clone, Default)] pub struct LocalProjectStore { projects: Arc>>, } impl LocalProjectStore { pub fn insert(&self, name: ProjectName, session: &Session) -> Result { if name.as_str() == "fail-store" { return Err(AppError::StoreUnavailable); } let mut projects = self.projects.lock().unwrap(); let id = ProjectId(projects.last().map_or(1, |project| project.id.0 + 1)); let record = ProjectRecord { id, name: name.as_str().to_owned(), owner: session.email.clone(), }; projects.push(record.clone()); Ok(record) } pub fn list(&self) -> Vec { self.projects.lock().unwrap().clone() } } #[derive(Clone)] pub struct AppContext { session: Session, store: LocalProjectStore, } impl AppContext { pub fn demo() -> Self { Self { session: Session::demo(), store: LocalProjectStore::default(), } } pub fn csrf(&self) -> &CsrfToken { &self.session.csrf } pub fn projects(&self) -> Vec { self.store.list() } } #[derive(Debug)] pub enum AppError { MissingSession, CsrfRejected, StoreUnavailable, Validation(&'static str), } impl AppError { fn message(&self) -> &'static str { match self { Self::MissingSession => "Sign in to continue", Self::CsrfRejected => "Refresh the page before creating another project", Self::StoreUnavailable => "Project storage is temporarily unavailable", Self::Validation(message) => message, } } } impl IntoHandlerFailure for AppError { fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure { match self { Self::Validation(message) => HandlerFailure::effects( ( dashboard::new_project.error("name", message), dashboard::new_project.focus("name"), ), context, ), other => HandlerFailure::effects(dashboard::flash.set(other.message()), context), } } } #[derive(Hemplate)] pub struct Dashboard { csrf: CsrfToken, flash: String, summary: String, rows: Vec, project_count: usize, show_projects: bool, settings: SettingsPage, } impl Dashboard { pub fn from_context(ctx: &AppContext) -> Self { let projects = ctx.projects(); Self { csrf: ctx.csrf().clone(), flash: "Signed in with a demo session".to_owned(), summary: project_summary(projects.len()), project_count: projects.len(), show_projects: true, settings: SettingsPage::production_boundaries(), rows: projects.into_iter().map(ProjectRow::from).collect(), } } pub fn settings(ctx: &AppContext) -> Self { let mut dashboard = Self::from_context(ctx); dashboard.show_projects = false; dashboard.flash.clear(); dashboard } } #[derive(Hemplate)] #[hemplate = "partials"] pub struct ProjectRow { id: ProjectId, name: String, owner: String, } impl From for ProjectRow { fn from(record: ProjectRecord) -> Self { Self { id: record.id, name: record.name, owner: record.owner, } } } impl hemx::KeyedPartial for ProjectRow { fn hemx_key(&self) -> String { self.id.to_string() } } #[derive(Hemplate)] #[hemplate = "partials"] pub struct SettingsPage { message: &'static str, } impl SettingsPage { fn production_boundaries() -> Self { Self { message: "Auth, CSRF, persistence, metrics, and deploy stay explicit app integrations.", } } } #[derive(Hemplate)] pub struct AppShell { title: &'static str, body: Html, } pub fn home_page(ctx: &AppContext) -> Html { hemx::page(&AppShell { title: "hemx SaaS tutorial", body: hemx::page(&Dashboard::from_context(ctx)), }) } pub fn settings_page(ctx: &AppContext) -> Html { hemx::page(&AppShell { title: "hemx SaaS tutorial settings", body: hemx::page(&Dashboard::settings(ctx)), }) } #[hemx::app(dashboard_handlers)] pub fn registry(ctx: AppContext) -> Registry { interactions(ui::BUILD_FINGERPRINT) } #[hemx::component("dashboard")] mod dashboard_handlers { use super::*; #[hemx::handler] pub async fn create_project( State(ctx): State, Form(form): Form, ) -> Result { if ctx.session.email.is_empty() { return Err(AppError::MissingSession); } if form.csrf != ctx.session.csrf { return Err(AppError::CsrfRejected); } if form.name.as_str().is_empty() { return Err(AppError::Validation("Project name required")); } let project = ctx.store.insert(form.name, &ctx.session)?; let total = ctx.projects().len(); Ok(( dashboard::project_row.append(ProjectRow::from(project)), dashboard::summary.set(project_summary(total)), dashboard::new_project.clear(), dashboard::flash.set("Project created"), dashboard::live_status.set(format!("{total} projects persisted locally")), )) } #[hemx::handler] pub async fn open_settings(State(_ctx): State) -> impl IntoEffect { ( dashboard::page_panel.put(&SettingsPage::production_boundaries()), dashboard::nav.set("Settings"), push("/settings"), ) } } pub fn live_status(projects: usize) -> impl IntoEffect { dashboard::live_status.set(format!("heartbeat: {projects} projects")) } fn project_summary(total: usize) -> String { match total { 0 => "No projects yet".to_owned(), 1 => "1 project".to_owned(), total => format!("{total} projects"), } } #[cfg(test)] mod tests { use super::*; use hemx_axum::{InteractionForm, InteractionRequest}; use hemx_test::{inspect, inspect_batch}; use scraper::{Html as ParsedHtml, Selector}; fn form(handle: hemx::Handle, fields: &[(&str, &str)]) -> InteractionForm { InteractionForm::for_handle( handle, fields .iter() .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())), ) } fn selector(value: &str) -> Selector { Selector::parse(value).expect("test selector parses") } #[test] fn home_page_documents_the_production_app_boundaries() { // req: examples/001 req: auth/001 req: auth/004 req: interop/003 let ctx = AppContext::demo(); let html = home_page(&ctx); let document = ParsedHtml::parse_document(html.as_str()); assert_eq!( document .select(&selector("[data-hemx-root='dashboard']")) .count(), 1 ); assert_eq!( document .select(&selector("form[data-hemx-form='new_project']")) .count(), 1 ); assert_eq!(document.select(&selector("input[name='csrf']")).count(), 1); assert_eq!( document .select(&selector("[data-hemx-sse='/events']")) .count(), 1 ); assert_eq!( document .select(&selector("[data-hemx-island='metrics']")) .count(), 1 ); assert!(html.as_str().contains("/app.css")); assert!(html.as_str().contains("/metrics.js")); } #[test] fn settings_page_renders_the_full_page_fallback() { // req: examples/001 req: page_swap/002 let ctx = AppContext::demo(); let html = settings_page(&ctx); let document = ParsedHtml::parse_document(html.as_str()); assert_eq!( document .select(&selector("[data-hemx-root='dashboard']")) .count(), 1 ); assert_eq!( document .select(&selector("[data-hemx-slot='page_panel'] .settings-page")) .count(), 1 ); assert!(html.as_str().contains("explicit app integrations")); assert!(!html .as_str() .contains("form data-hemx-handle=\"create_project\"")); } #[tokio::test] async fn create_project_is_auth_csrf_checked_and_persisted_locally() { // req: examples/001 req: auth/002 req: auth/004 req: form/001 req: failure/004 let ctx = AppContext::demo(); let rejected = inspect_batch( InteractionRequest::from(form( dashboard::create_project, &[("csrf", "stale"), ("name", "Launch checklist")], )) .dispatch_async(registry(ctx.clone())) .await .unwrap() .batch, ); assert!(ctx.projects().is_empty()); assert!(rejected.updates_text(dashboard::flash)); assert!(rejected.payload_contains("Refresh the page")); let validation = inspect_batch( InteractionRequest::from(form( dashboard::create_project, &[("csrf", "demo-csrf"), ("name", " ")], )) .dispatch_async(registry(ctx.clone())) .await .unwrap() .batch, ); assert!(ctx.projects().is_empty()); assert!(validation.payload_contains("Project name required")); let created = inspect_batch( InteractionRequest::from(form( dashboard::create_project, &[("csrf", "demo-csrf"), ("name", "Launch checklist")], )) .dispatch_async(registry(ctx.clone())) .await .unwrap() .batch, ); assert_eq!(ctx.projects()[0].name, "Launch checklist"); assert!(created.inserts_html_containing(dashboard::project_row, "1", "Launch checklist")); assert!(created.updates_text(dashboard::summary)); assert!(created.resets_form(dashboard::new_project)); assert!(created.updates_text(dashboard::live_status)); } #[tokio::test] async fn page_swap_and_push_shape_use_generated_targets() { // req: page_swap/002 req: push/003 req: examples/001 let ctx = AppContext::demo(); let settings = inspect_batch( InteractionRequest::from(form(dashboard::open_settings, &[])) .dispatch_async(registry(ctx.clone())) .await .unwrap() .batch, ); assert!( settings.updates_html_containing(dashboard::page_panel, "explicit app integrations") ); assert!(settings.updates_text(dashboard::nav)); assert!(settings.pushes_to("/settings")); let heartbeat = inspect(live_status(ctx.projects().len())); assert!(heartbeat.updates_text(dashboard::live_status)); assert!(heartbeat.payload_contains("heartbeat")); } }