refactor(saas): make navigation and status idiomatic

Use a real enhanceable Settings URL, remove response-simulated navigation, and replace the one-shot public SSE endpoint with an initial canonical event plus ongoing server-owned updates. Keep a bounded one-event probe for production-reference tests.

req: examples/014

req: nav/001

req: nav/002

req: nav/004

req: push/003
This commit is contained in:
slhx agent
2026-07-22 18:18:48 +02:00
parent c4a6edfed1
commit 276f40ee59
7 changed files with 59 additions and 73 deletions
+4 -26
View File
@@ -2,7 +2,7 @@
pub mod ui {}
use hemplate::Hemplate;
use hemx::{push, Html, IntoEffect};
use hemx::{Html, IntoEffect};
use hemx_axum::{
interactions, runtime_js_path, Form, HandlerErrorContext, HandlerFailure, IntoHandlerFailure,
Registry, State,
@@ -565,15 +565,6 @@ mod dashboard_handlers {
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::production_boundaries()),
dashboard::nav.set("Settings"),
push("/settings"),
)
}
}
pub fn live_status(projects: usize) -> impl IntoEffect {
@@ -740,23 +731,10 @@ mod tests {
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
#[test]
fn live_status_uses_the_generated_dashboard_target() {
// req: push/003 req: examples/014
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"));
+15 -9
View File
@@ -5,14 +5,14 @@ use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::Router;
use futures_util::stream;
use futures_util::{stream, StreamExt};
use hemx::IntoEffect;
use hemx_axum::{runtime_js, runtime_js_path, sse, EffectResponse, InteractionRequest};
use hemx_saas_example::{home_page, live_status, registry, settings_page, ui, AppContext};
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::path::PathBuf;
use std::time::Instant;
use std::time::{Duration, Instant};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -81,16 +81,22 @@ async fn events(
Query(params): Query<BTreeMap<String, String>>,
State(ctx): State<AppContext>,
) -> impl IntoResponse {
let count = ctx.projects().len();
// The production reference exposes an ongoing server-owned stream; `once`
// keeps a bounded probe for package tests without changing the public path.
// req: examples/014
let event = |ctx: &AppContext| {
Ok::<_, Infallible>(live_status(ctx.projects().len()).into_batch(ui::BUILD_FINGERPRINT))
};
let initial = stream::once(std::future::ready(event(&ctx)));
if params.contains_key("once") {
return sse(stream::iter([Ok::<_, Infallible>(
live_status(count).into_batch(ui::BUILD_FINGERPRINT),
)]));
return sse(initial.left_stream());
}
sse(stream::iter([Ok::<_, Infallible>(
live_status(count).into_batch(ui::BUILD_FINGERPRINT),
)]))
let updates = stream::unfold(ctx, move |ctx| async move {
tokio::time::sleep(Duration::from_secs(15)).await;
Some((event(&ctx), ctx))
});
sse(initial.chain(updates).right_stream())
}
// req: auth/001 req: auth/002 req: auth/004
+1 -1
View File
@@ -7,7 +7,7 @@
<nav class="tabs" data-hemx-slot="nav">
<a href="/" data-hemx-nav="">Projects</a>
<button type="button" data-hemx-handle="open_settings">Settings</button>
<a href="/settings" data-hemx-nav>Settings</a>
</nav>
<section class="panel" data-hemx-slot="page_panel">
@@ -125,6 +125,25 @@ fn authenticated_project_mutation_is_atomic_and_survives_restart() {
);
assert!(!home.contains("<script>"));
assert!(!home.contains("javascript:"));
assert!(
home.contains("href=\"/settings\" data-hemx-nav"),
"settings must remain a real, enhanceable link"
);
let settings = request(&address, "GET", "/settings", &[], "");
assert!(settings.starts_with("HTTP/1.1 200"), "{settings}");
assert!(settings.contains("<h2>Settings</h2>"), "{settings}");
assert!(settings.contains("explicit app integrations"), "{settings}");
let events = request(&address, "GET", "/events?once=1", &[], "");
assert!(events.starts_with("HTTP/1.1 200"), "{events}");
assert_eq!(
response_header(&events, "content-type"),
"text/event-stream"
);
assert!(events.contains("event: hemx"), "{events}");
assert!(events.contains("data:"), "{events}");
// test req: nav/001 req: nav/002 req: push/003 req: examples/014
let live = request(&address, "GET", "/health/live", &[], "");
assert!(live.starts_with("HTTP/1.1 200"), "{live}");
assert!(live.contains("{\"status\":\"live\"}"), "{live}");