feat(examples): add saas tutorial skeleton

Add a compile-tested v1 tutorial skeleton that proves the production-shaped app boundary without provider-heavy scope: auth/session context, CSRF-checked mutation, local persistence adapter, generated form/slot/keyed row/page/push effects, plain CSS, SSE shape, and one explicit metrics island.

req: examples/001

req: auth/001

req: auth/002

req: auth/004

req: form/001

req: failure/004

req: page_swap/002

req: push/003
This commit is contained in:
slhx agent
2026-06-05 09:15:57 +02:00
parent 3dfb3b684f
commit 38cfb263aa
15 changed files with 716 additions and 1 deletions
+463
View File
@@ -0,0 +1,463 @@
#[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, Write as _};
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<Self, Self::Err> {
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<Self, Self::Err> {
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<Mutex<Vec<ProjectRecord>>>,
}
impl LocalProjectStore {
pub fn insert(&self, name: ProjectName, session: &Session) -> Result<ProjectRecord, AppError> {
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<ProjectRecord> {
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<ProjectRecord> {
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),
}
}
}
pub struct Dashboard {
csrf: CsrfToken,
flash: String,
summary: String,
rows: Vec<ProjectRow>,
project_count: usize,
}
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(),
rows: projects.into_iter().map(ProjectRow::from).collect(),
}
}
}
#[derive(Hemplate)]
#[hemplate = "partials"]
pub struct ProjectRow {
id: ProjectId,
name: String,
owner: String,
}
impl Hemplate for Dashboard {
fn render_into(&self, buf: &mut String) -> Result<(), hemplate::error::HemplateError> {
buf.push_str("<section class=\"dashboard\" data-hemx-root=\"dashboard\" data-hemx-sse=\"/events\">\n");
buf.push_str(" <header class=\"hero\"><p class=\"eyebrow\">Production-shaped SaaS path</p><h1>Projects</h1><p class=\"lede\">Auth-gated mutations, CSRF checks, local persistence, typed forms, generated swaps, page swaps, live status, plain CSS, and one explicit island.</p></header>\n");
buf.push_str(" <nav class=\"tabs\" data-hemx-slot=\"nav\"><a href=\"/\" data-hemx-nav=\"\">Projects</a><button type=\"button\" data-hemx-handle=\"open_settings\">Settings</button></nav>\n");
buf.push_str(" <section class=\"panel\" data-hemx-slot=\"page_panel\">\n");
buf.push_str(" <form class=\"project-form\" data-hemx-handle=\"create_project\" data-hemx-form=\"new_project\" data-hemx-disable-while-pending>\n");
write!(
buf,
" <input type=\"hidden\" name=\"csrf\" value=\"{}\">\n",
hemplate::HtmlEscape(&self.csrf.to_string())
)?;
buf.push_str(" <label>Project name <input name=\"name\" required=\"required\" maxlength=\"64\" value=\"Launch checklist\"></label><button type=\"submit\">Create project</button><p class=\"field-error\" data-hemx-error-for=\"name\"></p>\n </form>\n");
write!(
buf,
" <p class=\"flash\" data-hemx-slot=\"flash\">{}</p>\n",
hemplate::HtmlEscape(&self.flash)
)?;
write!(
buf,
" <p class=\"summary\" data-hemx-slot=\"summary\">{}</p>\n",
hemplate::HtmlEscape(&self.summary)
)?;
buf.push_str(" <ul class=\"project-list\" data-hemx-slot=\"project_row\">\n");
for row in &self.rows {
buf.push_str(" ");
row.render_into(buf)?;
buf.push('\n');
}
buf.push_str(" </ul>\n </section>\n <section class=\"status-row\"><p data-hemx-slot=\"live_status\">Waiting for status…</p>");
write!(buf, "<article class=\"metrics-island\" data-hemx-island=\"metrics\" data-project-count=\"{}\"><h2>Metrics island</h2><canvas width=\"320\" height=\"140\" aria-label=\"Project metrics chart\"></canvas><p data-island-readout=\"\">Waiting for island script…</p></article>", self.project_count)?;
buf.push_str("</section>\n</section>\n");
Ok(())
}
}
impl From<ProjectRecord> 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,
}
#[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)),
})
}
#[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<AppContext>,
Form(form): Form<NewProject>,
) -> Result<impl IntoEffect, AppError> {
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<AppContext>) -> impl IntoEffect {
(
dashboard::page_panel.put(&SettingsPage {
message:
"Auth, CSRF, persistence, metrics, and deploy stay explicit app integrations.",
}),
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<I>(handle: hemx::Handle<I>, 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_skeleton_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"));
}
#[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"));
}
}