Skip to content

Commit e3c56fd

Browse files
authored
Merge pull request #2 from habibtalib/phase-1-upstream
feat: async ConnectionService seam, typed errors & AsyncState foundation
2 parents 6e33520 + 49415d3 commit e3c56fd

18 files changed

Lines changed: 975 additions & 47 deletions

Cargo.lock

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,20 @@ repository = "https://github.com/NodeDB-Lab/nodedb-studio"
1313
homepage = "https://nodedb.dev"
1414

1515
[workspace.dependencies]
16-
# Internal NodeDB crates.
16+
# Internal NodeDB crates. The `native` feature (real client transport) is
17+
# enabled in the phase that first constructs a NativeClient — it drags in a
18+
# TLS + C-toolchain build tree (tokio-rustls, aws-lc-rs, cmake), so it is not
19+
# carried while the seam impl is an inert stub.
1720
nodedb-client = "0.3.0"
1821
nodedb-types = "0.3.0"
1922
# Async
23+
async-trait = "0.1"
2024
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
2125
# UI. The `router` feature re-exports the router through dioxus::prelude.
2226
# Desktop-only for the skeleton.
2327
dioxus = { version = "0.7", features = ["desktop", "router"] }
28+
# Test-only: render components to a string for render-level assertions.
29+
dioxus-ssr = "0.7"
2430

2531
# Serialization. Kept for derive on models/preferences; no runtime parsing
2632
# happens in the skeleton (mock data is hardcoded Rust).

nodedb-studio/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,8 @@ tokio = { workspace = true }
2020
serde = { workspace = true }
2121
sonic-rs = { workspace = true }
2222
thiserror = { workspace = true }
23+
async-trait = { workspace = true }
2324
tracing = { workspace = true }
25+
26+
[dev-dependencies]
27+
dioxus-ssr = { workspace = true }

nodedb-studio/assets/styles.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,6 +1459,35 @@
14591459
}
14601460
.notif-footer a:hover { color: var(--text-primary); }
14611461

1462+
/* ============================================================
1463+
Async render states (shared AsyncView: loading / empty / error)
1464+
============================================================ */
1465+
.async-loading {
1466+
padding: 40px 20px;
1467+
text-align: center;
1468+
color: var(--text-tertiary);
1469+
font-size: 12px;
1470+
}
1471+
.async-empty {
1472+
padding: 40px 20px;
1473+
text-align: center;
1474+
color: var(--text-tertiary);
1475+
font-size: 12px;
1476+
}
1477+
.async-error {
1478+
padding: 32px 20px;
1479+
text-align: center;
1480+
font-size: 12px;
1481+
display: flex;
1482+
flex-direction: column;
1483+
align-items: center;
1484+
gap: 10px;
1485+
}
1486+
.async-error-msg {
1487+
color: var(--text-secondary);
1488+
max-width: 280px;
1489+
}
1490+
14621491
/* ============================================================
14631492
Avatar popover (identity menu)
14641493
============================================================ */

nodedb-studio/src/app.rs

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ use std::rc::Rc;
1313
use dioxus::prelude::*;
1414

1515
use crate::modals::ModalHost;
16+
use crate::models::notification::Notification;
17+
use crate::services::async_state::AsyncState;
1618
use crate::services::connection_service::{ConnectionService, MockConnectionService};
1719
use crate::state::connection::ActiveConnection;
20+
use crate::state::connections_registry::SavedConnection;
1821
use crate::state::preferences::Preferences;
1922
use crate::state::ui::ModalKind;
2023
use crate::views::connection_manager::ConnectionManager;
@@ -28,20 +31,47 @@ pub fn App() -> Element {
2831
// The single seam to the outside world. Provided as a trait object so a
2932
// real client can replace the mock without touching consumers.
3033
let service: Rc<dyn ConnectionService> = Rc::new(MockConnectionService);
31-
32-
// Seed the registry and notification feed once, from the service.
33-
let registry = service.list_connections();
34-
let notifications = service.notifications();
34+
// The real-client stub's instantiability + object-safety behind the seam is
35+
// proven by `nodedb_service::tests::stub_is_object_safe_behind_rc`, so it is
36+
// not constructed here on every render.
3537

3638
use_context_provider(|| service.clone());
3739
use_context_provider(|| Signal::new(None::<ActiveConnection>));
38-
use_context_provider(|| Signal::new(registry));
39-
use_context_provider(|| Signal::new(notifications));
40+
let mut registry = use_context_provider(|| Signal::new(Vec::<SavedConnection>::new()));
41+
// Single source of truth for notifications: the bell badge AND the popover
42+
// read this one store; user actions (mark-all-read / click) mutate it.
43+
let mut notifications =
44+
use_context_provider(|| Signal::new(AsyncState::<Vec<Notification>>::Loading));
4045
use_context_provider(|| Signal::new(Preferences::default()));
4146
// Modal state is provided here (not in Studio) because Preferences is
4247
// reachable while disconnected and via Cmd+, in either state.
4348
use_context_provider(|| Signal::new(None::<ModalKind>));
4449

50+
// Seed the registry + notification feed asynchronously, at the seam. The
51+
// mock resolves instantly; the real client awaits the network. The guard
52+
// rule: clone the Rc before the async block and `.set()` the signals only
53+
// AFTER the await resolves — never hold a read/write guard across `.await`.
54+
// The Resource handle is shared via context so a view (the notification
55+
// popover's Retry) can reload the feed.
56+
let reload_feed = {
57+
let svc = service.clone();
58+
use_resource(move || {
59+
let svc = svc.clone();
60+
async move {
61+
// Reset to Loading at the start of each (re)load so a Retry shows
62+
// the spinner. `.set()` holds no guard across the awaits below.
63+
notifications.set(AsyncState::Loading);
64+
if let Ok(conns) = svc.list_connections().await {
65+
registry.set(conns);
66+
}
67+
// Map the seam result straight into the store: Ok(empty)->Empty,
68+
// Ok(data)->Loaded, Err->Error.
69+
notifications.set(AsyncState::from_value(Some(svc.notifications().await)));
70+
}
71+
})
72+
};
73+
use_context_provider(|| reload_feed);
74+
4575
let active = use_context::<Signal<Option<ActiveConnection>>>();
4676

4777
rsx! {
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
//! Shared loading/empty/error renderer for any seam-backed read.
2+
//!
3+
//! The caller maps its `use_resource` result to `AsyncState<T>` (plain Rust),
4+
//! renders the `Loaded(T)` case itself, and delegates the three non-loaded
5+
//! states to this component. Keeps every wired view from re-implementing the
6+
//! spinner / empty / error+retry markup.
7+
//!
8+
//! Calling convention: build one `AsyncState<T>` via `from_value` and drive both
9+
//! this component and the loaded markup from it — no inline match arms:
10+
//! ```ignore
11+
//! let state = AsyncState::from_value(resource.read().clone());
12+
//! rsx! {
13+
//! AsyncView {
14+
//! loading: state.is_loading(),
15+
//! empty: state.is_empty(),
16+
//! error: state.error_message(),
17+
//! retriable: state.is_retriable(),
18+
//! on_retry: move |_| resource.restart(),
19+
//! }
20+
//! if let Some(data) = state.loaded() { /* caller's own markup for `data` */ }
21+
//! }
22+
//! ```
23+
//! Discrete props (rather than the generic `AsyncState<T>`) are used because
24+
//! Dioxus props must be `Clone + PartialEq`; `StudioError`/`T` need not be — so
25+
//! the caller decodes the state via the accessors and passes plain flags.
26+
27+
use dioxus::prelude::*;
28+
29+
#[derive(Clone, PartialEq, Props)]
30+
pub struct AsyncViewProps {
31+
/// True while the first fetch is pending.
32+
pub loading: bool,
33+
/// True when the fetch returned an empty result.
34+
pub empty: bool,
35+
/// Some(message) when the fetch failed; None otherwise.
36+
pub error: Option<String>,
37+
/// Whether the failed fetch is retriable (gates the Retry button).
38+
#[props(default = false)]
39+
pub retriable: bool,
40+
/// Called when the user clicks Retry (wire to Resource::restart()).
41+
#[props(default)]
42+
pub on_retry: EventHandler<()>,
43+
/// Message shown in the empty state.
44+
#[props(default = "No records.".to_string())]
45+
pub empty_message: String,
46+
}
47+
48+
// Consumed by the notification popover and later wired views.
49+
#[component]
50+
pub fn AsyncView(props: AsyncViewProps) -> Element {
51+
if props.loading {
52+
return rsx! { div { class: "async-loading", "Loading…" } };
53+
}
54+
if let Some(msg) = props.error {
55+
return rsx! {
56+
div { class: "async-error",
57+
div { class: "async-error-msg", "{msg}" }
58+
if props.retriable {
59+
button {
60+
class: "btn",
61+
onclick: move |_| props.on_retry.call(()),
62+
"Retry"
63+
}
64+
}
65+
}
66+
};
67+
}
68+
if props.empty {
69+
return rsx! { div { class: "async-empty", "{props.empty_message}" } };
70+
}
71+
// Loaded: the caller renders its own markup; AsyncView renders nothing.
72+
rsx! {}
73+
}
74+
75+
#[cfg(test)]
76+
mod tests {
77+
use super::*;
78+
79+
// SSR the given root component to HTML. The rsx is built INSIDE the component
80+
// (i.e. within the VirtualDom runtime) because `AsyncView`'s default
81+
// `EventHandler` prop can only be constructed while a runtime is in scope.
82+
fn render(app: fn() -> Element) -> String {
83+
let mut dom = VirtualDom::new(app);
84+
dom.rebuild_in_place();
85+
dioxus_ssr::render(&dom)
86+
}
87+
88+
#[test]
89+
fn loading_renders_spinner() {
90+
fn app() -> Element {
91+
rsx! { AsyncView { loading: true, empty: false, error: None } }
92+
}
93+
let html = render(app);
94+
assert!(html.contains("async-loading"));
95+
assert!(html.contains("Loading"));
96+
}
97+
98+
#[test]
99+
fn empty_renders_message() {
100+
fn app() -> Element {
101+
rsx! {
102+
AsyncView {
103+
loading: false,
104+
empty: true,
105+
error: None,
106+
empty_message: "Nothing here".to_string(),
107+
}
108+
}
109+
}
110+
let html = render(app);
111+
assert!(html.contains("async-empty"));
112+
assert!(html.contains("Nothing here"));
113+
}
114+
115+
#[test]
116+
fn retriable_error_shows_retry() {
117+
fn app() -> Element {
118+
rsx! {
119+
AsyncView {
120+
loading: false,
121+
empty: false,
122+
error: Some("boom".to_string()),
123+
retriable: true,
124+
}
125+
}
126+
}
127+
let html = render(app);
128+
assert!(html.contains("async-error"));
129+
assert!(html.contains("boom"));
130+
assert!(html.contains("Retry"));
131+
}
132+
133+
#[test]
134+
fn non_retriable_error_hides_retry() {
135+
fn app() -> Element {
136+
rsx! {
137+
AsyncView {
138+
loading: false,
139+
empty: false,
140+
error: Some("boom".to_string()),
141+
retriable: false,
142+
}
143+
}
144+
}
145+
let html = render(app);
146+
assert!(html.contains("async-error"));
147+
assert!(html.contains("boom"));
148+
assert!(!html.contains("Retry"));
149+
}
150+
151+
#[test]
152+
fn loaded_renders_nothing() {
153+
fn app() -> Element {
154+
rsx! { AsyncView { loading: false, empty: false, error: None } }
155+
}
156+
let html = render(app);
157+
assert!(!html.contains("async-loading"));
158+
assert!(!html.contains("async-empty"));
159+
assert!(!html.contains("async-error"));
160+
}
161+
}

nodedb-studio/src/components/command_palette.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ pub fn CommandPalette() -> Element {
6767
div { class: "palette-item", onclick: {
6868
let svc = switch_svc.clone();
6969
move |_| {
70-
if let Some(s) = svc.connect("staging-cluster") { active.set(Some(s)); }
70+
let svc = svc.clone();
71+
spawn(async move {
72+
if let Ok(s) = svc.connect("staging-cluster").await { active.set(Some(s)); }
73+
});
7174
open.set(false);
7275
}
7376
},
@@ -76,7 +79,10 @@ pub fn CommandPalette() -> Element {
7679
div { class: "palette-item", onclick: {
7780
let svc = switch_svc.clone();
7881
move |_| {
79-
if let Some(s) = svc.connect("prod-replica-eu") { active.set(Some(s)); }
82+
let svc = svc.clone();
83+
spawn(async move {
84+
if let Ok(s) = svc.connect("prod-replica-eu").await { active.set(Some(s)); }
85+
});
8086
open.set(false);
8187
}
8288
},

nodedb-studio/src/components/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! Reusable studio chrome components.
22
3+
pub mod async_view;
34
pub mod command_palette;
45
pub mod modal;
56
pub mod popovers;

nodedb-studio/src/components/popovers/connection_popover.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,13 @@ pub fn ConnectionPopover() -> Element {
5555
class: "{item_class}",
5656
onclick: move |_| {
5757
if !disabled {
58-
if let Some(s) = svc.connect(&name) { active.set(Some(s)); }
58+
// Async at the seam: clone svc + name into the task,
59+
// set `active` (Copy) only after the await resolves.
60+
let svc = svc.clone();
61+
let name = name.clone();
62+
spawn(async move {
63+
if let Ok(s) = svc.connect(&name).await { active.set(Some(s)); }
64+
});
5965
popover.set(None);
6066
}
6167
},

0 commit comments

Comments
 (0)