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:
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
|
||||
Reference in New Issue
Block a user