From 64c8dba0c78426db9c2f3a7e1c9362b16710a3f6 Mon Sep 17 00:00:00 2001 From: Hatta Zainal Date: Thu, 27 Aug 2026 23:12:34 +0800 Subject: [PATCH 1/7] feat(components): DataGrid renders a seam ResultSet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One tabular renderer for the seam's ResultSet: column headers from the result, one keyed row per RecordRow, one cell per string. Pure presentation. Every wire scalar already arrives as a display string, so the grid never parses. Sorting or typed comparison cannot be recovered from display strings like "14,820" or "—", so that waits on the model carrying raw values rather than on this component. ResultSet is now rectangular and uniquely keyed by construction. Its fields are private and ResultSet::new rejects a row whose width differs from the header, or a duplicate row id, with UnexpectedColumns, the same error the seam decoder raises for a short row. Without the width check a decoder that dropped a cell would render every later value under the wrong heading, which a user cannot distinguish from what the database holds. Without the id check a decoder keying rows off a data column would hand Dioxus duplicate sibling keys, which panic in debug and silently reuse the wrong node in release. Both checks fail their tests when removed. Deserialize is dropped from ResultSet since it would bypass the constructor and nothing deserializes a model. Headers are keyed by ordinal, not by name: SQL allows duplicate column names and the same duplicate-key panic applies. The Query workbench is the first consumer. Its inline table had hardcoded headers and an unkeyed row loop, and its toolbar and footer repeated the row count, elapsed time and scan size as literals. The rows are now a ResultSet with seam-shaped ids and the counters derive from it. The view is still a static placeholder: reading run_query through the seam also needs the loading, empty and error states this view does not render, so the placeholder carries an unwired marker naming that condition, and a render test pins it at seven rows so a ragged edit fails the suite instead of rendering a blank grid. Render tests assert whole rows verbatim and contiguous, so column order, row order and header-to-cell alignment are pinned rather than presence, plus zero rows and duplicate column names. --- nodedb-studio/src/components/data_grid.rs | 120 +++++++++++++++++++ nodedb-studio/src/components/mod.rs | 1 + nodedb-studio/src/data/mock/workbench.rs | 51 ++++---- nodedb-studio/src/models/explorer.rs | 1 - nodedb-studio/src/models/workbench.rs | 113 ++++++++++++++++- nodedb-studio/src/services/async_state.rs | 28 +++-- nodedb-studio/src/services/workbench_data.rs | 12 +- nodedb-studio/src/views/query.rs | 74 ++++++++---- 8 files changed, 334 insertions(+), 66 deletions(-) create mode 100644 nodedb-studio/src/components/data_grid.rs diff --git a/nodedb-studio/src/components/data_grid.rs b/nodedb-studio/src/components/data_grid.rs new file mode 100644 index 0000000..c3e060d --- /dev/null +++ b/nodedb-studio/src/components/data_grid.rs @@ -0,0 +1,120 @@ +//! Tabular renderer for a seam `ResultSet`: column headers plus keyed rows. +//! +//! Pure presentation. Every cell is already a display string, because every +//! wire scalar arrives as one; this component never parses. Sorting or typed +//! comparison is not possible from display strings ("14,820", "—"), so it +//! waits on the model carrying raw values, not on this component. +//! +//! Headers are keyed by ordinal: SQL allows duplicate column names +//! (`SELECT a, a`) and Dioxus panics in debug on duplicate sibling keys. +//! Rows are keyed by `RecordRow.id`, which the seam guarantees unique. + +use dioxus::prelude::*; + +use crate::models::workbench::ResultSet; + +#[component] +pub fn DataGrid(result: ResultSet) -> Element { + rsx! { + table { class: "data-grid", + thead { + tr { + for (i, col) in result.columns().iter().enumerate() { + th { key: "{i}", "{col}" } + } + } + } + tbody { + for row in result.rows() { + tr { key: "{row.id}", + for (i, cell) in row.cells.iter().enumerate() { + td { key: "{i}", "{cell}" } + } + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::explorer::RecordRow; + + fn render(app: fn() -> Element) -> String { + let mut dom = VirtualDom::new(app); + dom.rebuild_in_place(); + dioxus_ssr::render(&dom) + } + + fn row(id: &str, cells: &[&str]) -> RecordRow { + RecordRow { + id: id.into(), + cells: cells.iter().map(|c| c.to_string()).collect(), + } + } + + fn grid(columns: &[&str], rows: Vec) -> ResultSet { + ResultSet::new( + columns.iter().map(|c| c.to_string()).collect(), + rows, + 0, + String::new(), + ) + .expect("test fixtures are rectangular") + } + + #[test] + fn renders_rows_verbatim_in_column_and_row_order() { + fn app() -> Element { + let r = grid( + &["type", "n"], + vec![ + row("r1", &["page_view", "14,820"]), + row("r2", &["click", "8,041"]), + ], + ); + rsx! { DataGrid { result: r } } + } + let html = render(app); + // Whole rows verbatim: order and header/cell alignment, not just presence. + assert!( + html.contains("typen"), + "{html}" + ); + // Contiguous, so row order is pinned too, not only each row's contents. + assert!( + html.contains( + "page_view14,820click8,041" + ), + "{html}" + ); + assert_eq!( + html.matches("").count(), + 3, + "1 header row + 2 data rows" + ); + } + + #[test] + fn zero_rows_still_renders_headers() { + fn app() -> Element { + rsx! { DataGrid { result: grid(&["type", "n"], Vec::new()) } } + } + let html = render(app); + assert!(html.contains("type")); + assert!(!html.contains(""), "no cells for zero rows"); + } + + /// Duplicate column names are legal SQL and must not produce duplicate + /// sibling keys, which panic in debug builds. + #[test] + fn duplicate_column_names_render_both_headers() { + fn app() -> Element { + rsx! { DataGrid { result: grid(&["a", "a"], vec![row("r1", &["1", "2"])]) } } + } + let html = render(app); + assert_eq!(html.matches("a").count(), 2, "{html}"); + } +} diff --git a/nodedb-studio/src/components/mod.rs b/nodedb-studio/src/components/mod.rs index 7503d1b..e16adf9 100644 --- a/nodedb-studio/src/components/mod.rs +++ b/nodedb-studio/src/components/mod.rs @@ -2,6 +2,7 @@ pub mod async_view; pub mod command_palette; +pub mod data_grid; pub mod live_tail; pub mod modal; pub mod popovers; diff --git a/nodedb-studio/src/data/mock/workbench.rs b/nodedb-studio/src/data/mock/workbench.rs index 36fe64b..aaa9354 100644 --- a/nodedb-studio/src/data/mock/workbench.rs +++ b/nodedb-studio/src/data/mock/workbench.rs @@ -9,21 +9,27 @@ use crate::models::workbench::{QueryPlan, ResultSet, SchemaNode}; /// returned for `sql`. #[allow(dead_code)] // SEAM-UNWIRED pub fn result_set(sql: &str) -> ResultSet { - ResultSet { - columns: vec!["id".into(), "name".into(), "created_at".into()], - rows: (0..4) - .map(|i| RecordRow { - id: format!("row-{i}"), - cells: vec![ - format!("row-{i}"), - format!("item {i}"), - format!("2026-08-0{} 10:0{}:00", (i % 9) + 1, i), - ], - }) - .collect(), - elapsed_ms: 12, - scanned: format!("4 rows for `{sql}`"), - } + let rows = (0..4) + .map(|i| RecordRow { + id: format!("row-{i}"), + cells: vec![ + format!("row-{i}"), + format!("item {i}"), + format!("2026-08-0{} 10:0{}:00", (i % 9) + 1, i), + ], + }) + .collect(); + // The fallback keeps this infallible without an unwrap. It would swap a + // ragged fixture for a zero-row one, so `workbench_data::run_query_ready_*` + // pins 3 columns and 4 rows with independent numbers; a ragged edit + // fails there rather than reaching a screen as "no rows". + ResultSet::new( + vec!["id".into(), "name".into(), "created_at".into()], + rows, + 12, + format!("4 rows for `{sql}`"), + ) + .unwrap_or_else(|_| empty_result_set(sql)) } /// The genuinely-empty result set `MockBehavior::Empty` returns for @@ -34,12 +40,15 @@ pub fn result_set(sql: &str) -> ResultSet { /// does not exist in the current `AsyncState` design. #[allow(dead_code)] // SEAM-UNWIRED pub fn empty_result_set(sql: &str) -> ResultSet { - ResultSet { - columns: vec!["id".into(), "name".into(), "created_at".into()], - rows: Vec::new(), - elapsed_ms: 3, - scanned: format!("0 rows for `{sql}`"), - } + // Zero rows are trivially rectangular, so `new` cannot fail here; the + // fallback exists only to keep this infallible without an unwrap. + ResultSet::new( + vec!["id".into(), "name".into(), "created_at".into()], + Vec::new(), + 3, + format!("0 rows for `{sql}`"), + ) + .unwrap_or_else(|_| ResultSet::default()) } /// A short, deterministic EXPLAIN plan for `sql`. diff --git a/nodedb-studio/src/models/explorer.rs b/nodedb-studio/src/models/explorer.rs index db10634..c437bca 100644 --- a/nodedb-studio/src/models/explorer.rs +++ b/nodedb-studio/src/models/explorer.rs @@ -13,7 +13,6 @@ pub struct CollectionGroup { /// One row in a viewer's list pane. `cells` are pre-formatted for display and /// align with the viewer's own column headers. -#[allow(dead_code)] // SEAM-UNWIRED #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecordRow { pub id: String, diff --git a/nodedb-studio/src/models/workbench.rs b/nodedb-studio/src/models/workbench.rs index 042f1e0..c8369c0 100644 --- a/nodedb-studio/src/models/workbench.rs +++ b/nodedb-studio/src/models/workbench.rs @@ -3,19 +3,68 @@ use serde::{Deserialize, Serialize}; use crate::models::explorer::RecordRow; +use crate::services::error::StudioError; /// One page of query output. Pagination is the seam's responsibility: the /// client buffers whole result sets, so the real implementation emits /// LIMIT/OFFSET rather than holding a cursor. -#[allow(dead_code)] // SEAM-UNWIRED -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// +/// Rectangular and uniquely keyed by construction: every row has exactly +/// `columns.len()` cells and a distinct `id`. Build one through +/// [`ResultSet::new`], which rejects a ragged row or a duplicate id, so a +/// decoder that drops a cell surfaces as an error instead of a grid whose +/// values sit under the wrong headers. `cells` hold display strings; sorting +/// or typed comparison needs raw values the model does not carry yet. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct ResultSet { - pub columns: Vec, - pub rows: Vec, + columns: Vec, + rows: Vec, pub elapsed_ms: u32, pub scanned: String, } +impl ResultSet { + /// Build a result set, rejecting any row whose width differs from the + /// header and any duplicate row id. + pub fn new( + columns: Vec, + rows: Vec, + elapsed_ms: u32, + scanned: String, + ) -> Result { + let width = columns.len(); + if let Some(bad) = rows.iter().find(|r| r.cells.len() != width) { + return Err(StudioError::UnexpectedColumns { + expected: format!("{width} cells per row"), + got: format!("row {} has {} cells", bad.id, bad.cells.len()), + }); + } + // Rows are Dioxus sibling keys; a duplicate panics in debug and + // silently reuses the wrong node in release. + let mut seen = std::collections::HashSet::with_capacity(rows.len()); + if let Some(dup) = rows.iter().find(|r| !seen.insert(r.id.as_str())) { + return Err(StudioError::UnexpectedColumns { + expected: "unique row ids".into(), + got: format!("duplicate row id {}", dup.id), + }); + } + Ok(Self { + columns, + rows, + elapsed_ms, + scanned, + }) + } + + pub fn columns(&self) -> &[String] { + &self.columns + } + + pub fn rows(&self) -> &[RecordRow] { + &self.rows + } +} + /// The query planner's EXPLAIN output for one statement. #[allow(dead_code)] // SEAM-UNWIRED #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -32,3 +81,59 @@ pub struct SchemaNode { pub kind: String, pub children: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + + fn row(id: &str, cells: &[&str]) -> RecordRow { + RecordRow { + id: id.into(), + cells: cells.iter().map(|c| c.to_string()).collect(), + } + } + + #[test] + fn new_accepts_rectangular_rows() { + let rs = ResultSet::new( + vec!["a".into(), "b".into()], + vec![row("1", &["x", "y"]), row("2", &["p", "q"])], + 0, + String::new(), + ); + assert!(rs.is_ok()); + } + + #[test] + fn new_rejects_a_short_row() { + let rs = ResultSet::new( + vec!["a".into(), "b".into()], + vec![row("1", &["x", "y"]), row("2", &["only-one"])], + 0, + String::new(), + ); + assert!(matches!(rs, Err(StudioError::UnexpectedColumns { .. }))); + } + + #[test] + fn new_rejects_a_duplicate_row_id() { + let rs = ResultSet::new( + vec!["a".into()], + vec![row("same", &["x"]), row("same", &["y"])], + 0, + String::new(), + ); + assert!(matches!(rs, Err(StudioError::UnexpectedColumns { .. }))); + } + + #[test] + fn new_rejects_a_long_row() { + let rs = ResultSet::new( + vec!["a".into()], + vec![row("1", &["x", "extra"])], + 0, + String::new(), + ); + assert!(matches!(rs, Err(StudioError::UnexpectedColumns { .. }))); + } +} diff --git a/nodedb-studio/src/services/async_state.rs b/nodedb-studio/src/services/async_state.rs index d041846..e2f2744 100644 --- a/nodedb-studio/src/services/async_state.rs +++ b/nodedb-studio/src/services/async_state.rs @@ -62,7 +62,7 @@ impl IsEmpty for QueryPlan { // gives the mock seam methods a way to actually deliver an empty payload). impl IsEmpty for ResultSet { fn is_empty(&self) -> bool { - self.rows.is_empty() + self.rows().is_empty() } } @@ -315,26 +315,28 @@ mod tests { // list: a query that returns zero rows is the most common // non-error workbench outcome and must reach `AsyncState::Empty`, // not `Loaded` with an empty table. - let result_set = AsyncState::from_value(Some(Ok(ResultSet { - columns: vec!["id".to_string()], - rows: Vec::new(), - elapsed_ms: 3, - scanned: "0 rows".to_string(), - }))); + let result_set = AsyncState::from_value(Some(Ok(ResultSet::new( + vec!["id".to_string()], + Vec::new(), + 3, + "0 rows".to_string(), + ) + .expect("rectangular")))); assert!(matches!(result_set, AsyncState::Empty)); } #[test] fn non_empty_result_set_is_loaded() { - let result_set = AsyncState::from_value(Some(Ok(ResultSet { - columns: vec!["id".to_string()], - rows: vec![RecordRow { + let result_set = AsyncState::from_value(Some(Ok(ResultSet::new( + vec!["id".to_string()], + vec![RecordRow { id: "1".to_string(), cells: vec!["1".to_string()], }], - elapsed_ms: 3, - scanned: "1 row".to_string(), - }))); + 3, + "1 row".to_string(), + ) + .expect("rectangular")))); assert!(matches!(result_set, AsyncState::Loaded(_))); } diff --git a/nodedb-studio/src/services/workbench_data.rs b/nodedb-studio/src/services/workbench_data.rs index aa7303b..92871aa 100644 --- a/nodedb-studio/src/services/workbench_data.rs +++ b/nodedb-studio/src/services/workbench_data.rs @@ -36,11 +36,11 @@ mod tests { async fn run_query_returns_columns_and_keyed_rows() { let svc = MockConnectionService::ready(); let rs = svc.run_query("SELECT 1").await.expect("query runs"); - assert!(!rs.columns.is_empty()); - for r in &rs.rows { + assert!(!rs.columns().is_empty()); + for r in rs.rows() { assert_eq!( r.cells.len(), - rs.columns.len(), + rs.columns().len(), "row width must match header" ); assert!(!r.id.is_empty(), "rows need a stable key"); @@ -54,8 +54,8 @@ mod tests { // numbers rather than deriving them from the call under test. let svc = MockConnectionService::ready(); let rs = svc.run_query("SELECT 1").await.expect("query runs"); - assert_eq!(rs.columns.len(), 3, "fixture is documented as 3 columns"); - assert_eq!(rs.rows.len(), 4, "fixture is documented as 4 rows"); + assert_eq!(rs.columns().len(), 3, "fixture is documented as 3 columns"); + assert_eq!(rs.rows().len(), 4, "fixture is documented as 4 rows"); } #[tokio::test] @@ -70,7 +70,7 @@ mod tests { .run_query("SELECT 1") .await .expect("empty behaviour is still Ok"); - assert!(rs.rows.is_empty(), "empty behaviour must yield zero rows"); + assert!(rs.rows().is_empty(), "empty behaviour must yield zero rows"); } #[tokio::test] diff --git a/nodedb-studio/src/views/query.rs b/nodedb-studio/src/views/query.rs index 0b0470d..15ef373 100644 --- a/nodedb-studio/src/views/query.rs +++ b/nodedb-studio/src/views/query.rs @@ -5,18 +5,40 @@ use dioxus::prelude::*; +use crate::components::data_grid::DataGrid; +use crate::models::explorer::RecordRow; +use crate::models::workbench::ResultSet; + #[component] pub fn Query() -> Element { - // (type, n, avg_load) - let results = [ - ("page_view", "14,820", "312"), - ("click", "8,041", "—"), - ("scroll", "5,209", "—"), - ("form_submit", "481", "1,820"), - ("error", "104", "—"), - ("signup", "22", "2,140"), - ("purchase", "9", "1,491"), - ]; + // SEAM-UNWIRED: static placeholder. Delete when Query reads `run_query` + // through the seam, which also needs `use_resource`, `AsyncState::from_value` + // and `AsyncView` for the loading, empty and error states this view does + // not render yet (see views/explorer/sidebar.rs for the shape). + let results = ResultSet::new( + vec!["type".into(), "n".into(), "avg_load".into()], + [ + ("page_view", "14,820", "312"), + ("click", "8,041", "—"), + ("scroll", "5,209", "—"), + ("form_submit", "481", "1,820"), + ("error", "104", "—"), + ("signup", "22", "2,140"), + ("purchase", "9", "1,491"), + ] + .iter() + .enumerate() + .map(|(i, (t, n, a))| RecordRow { + id: format!("row-{i}"), + cells: vec![t.to_string(), n.to_string(), a.to_string()], + }) + .collect(), + 142, + "14M".into(), + ) + .unwrap_or_default(); + // ponytail: unwrap_or_default hides a ragged edit as a blank grid; the test + // below pins the placeholder at 7 rows so that edit fails the suite instead. rsx! { div { class: "view active", div { class: "query-view", @@ -103,7 +125,7 @@ pub fn Query() -> Element { } div { class: "query-results", div { class: "results-toolbar", - span { class: "ok", "● 7 rows · 142 ms" } + span { class: "ok", "● {results.rows().len()} rows · {results.elapsed_ms} ms" } span { "analytics · local-nodedb-dev" } div { style: "margin-left:auto; display: flex; gap: 6px;", button { class: "btn small primary", "▸ Run (⌘↵)" } @@ -112,19 +134,12 @@ pub fn Query() -> Element { } } div { class: "results-body", - table { class: "data-grid", - thead { tr { th { "type" } th { "n" } th { "avg_load" } } } - tbody { - for r in results { - tr { td { "{r.0}" } td { "{r.1}" } td { "{r.2}" } } - } - } - } + DataGrid { result: results.clone() } } div { class: "results-footer", span { "page 1 of 1" } - span { "rows: 7" } - span { "scanned: 14M" } + span { "rows: {results.rows().len()}" } + span { "scanned: {results.scanned}" } span { "plan: index_scan(events_ts_idx)" } } } @@ -147,3 +162,20 @@ fn QsCol(name: String, ty: String, #[props(default = false)] pk: bool) -> Elemen } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The placeholder is built through the checked constructor and falls back + /// to an empty set on failure. This pins it so a ragged edit to the literal + /// fails here rather than rendering a blank workbench. + #[test] + fn placeholder_renders_all_seven_rows() { + let mut dom = VirtualDom::new(Query); + dom.rebuild_in_place(); + let html = dioxus_ssr::render(&dom); + assert_eq!(html.matches("").count(), 7, "{html}"); + assert!(html.contains("avg_load"), "{html}"); + } +} From 7383ff72170eb12fd2ca6130ad388f5755906f20 Mon Sep 17 00:00:00 2001 From: Hatta Zainal Date: Mon, 28 Sep 2026 19:27:52 +0800 Subject: [PATCH 2/7] feat(components): DetailPanel shell for master-detail viewers One header/body/footer shell for the Explorer's 8 engine viewers. The header (title, freshness chip, close) is identical across the mockup; body and footer actions differ per viewer and arrive as slots, the same shape LiveTail uses. Freshness is a display string plus a stale flag: the staleness rule needs a fetch time the seam does not carry yet. The close control is a button, not the mockup's span, for keyboard and screen-reader access. --- nodedb-studio/assets/styles.css | 77 ++++++++++++ nodedb-studio/src/components/detail_panel.rs | 119 +++++++++++++++++++ nodedb-studio/src/components/mod.rs | 1 + 3 files changed, 197 insertions(+) create mode 100644 nodedb-studio/src/components/detail_panel.rs diff --git a/nodedb-studio/assets/styles.css b/nodedb-studio/assets/styles.css index 567d392..dc49947 100644 --- a/nodedb-studio/assets/styles.css +++ b/nodedb-studio/assets/styles.css @@ -1832,3 +1832,80 @@ font-size: 11px; } + + /* Detail panel (components/detail_panel.rs) */ + .list-content { + overflow: hidden; + min-height: 0; + display: grid; + grid-template-columns: 1fr; + } + .list-content.with-detail { + grid-template-columns: 1fr 440px; + } + .detail-panel { + background: var(--bg-primary); + border-left: 0.5px solid var(--border-mid); + display: flex; flex-direction: column; + overflow: hidden; + } + .detail-header { + padding: 12px 16px; + border-bottom: 0.5px solid var(--border-soft); + display: flex; align-items: center; justify-content: space-between; + background: var(--bg-secondary); + flex-shrink: 0; + } + .detail-header .title { + font-weight: 500; + font-size: 12px; + font-family: var(--font-mono); + } + .detail-header .close-x { + width: 22px; height: 22px; + display: grid; place-items: center; + border: 0; padding: 0; + background: none; + font: inherit; + border-radius: 4px; + color: var(--text-tertiary); + cursor: pointer; + } + .detail-header .close-x:hover { background: var(--bg-hover); color: var(--text-primary); } + .detail-header-right { + display: flex; align-items: center; gap: 6px; + } + .detail-body { + flex: 1; + overflow: auto; + padding: 14px 16px; + } + .detail-footer { + padding: 10px 16px; + border-top: 0.5px solid var(--border-soft); + background: var(--bg-secondary); + display: flex; gap: 6px; + flex-shrink: 0; + } + .detail-footer .right { margin-left: auto; } + .freshness { + display: inline-flex; align-items: center; gap: 5px; + padding: 2px 8px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-tertiary); + background: var(--bg-primary); + border: 0.5px solid var(--border-mid); + border-radius: 10px; + cursor: pointer; + user-select: none; + } + .freshness:hover { + border-color: var(--border-strong); + color: var(--text-primary); + } + .freshness.stale { + color: var(--text-warning); + border-color: rgba(133,79,11,0.3); + background: rgba(133,79,11,0.06); + } diff --git a/nodedb-studio/src/components/detail_panel.rs b/nodedb-studio/src/components/detail_panel.rs new file mode 100644 index 0000000..4b31353 --- /dev/null +++ b/nodedb-studio/src/components/detail_panel.rs @@ -0,0 +1,119 @@ +//! Reusable detail-panel shell (header + body/footer slots) for the Explorer's +//! master-detail viewers. Owns the `.detail-panel` structure + CSS so every +//! engine viewer composes the same chrome. The body (JSON tree, row fields, +//! vector preview, …) and the footer actions differ per viewer and are passed +//! in as slot Elements. Place it inside `.list-content.with-detail`. + +use dioxus::prelude::*; + +#[derive(Clone, PartialEq, Props)] +pub struct DetailPanelProps { + /// Record identifier shown in the header (e.g. a document `_id`). + pub title: String, + /// Age of the shown data, pre-formatted for display ("8s", "2m"). + pub freshness: String, + /// Marks the data as old enough to warn about (`.freshness.stale`). + #[props(default = false)] + pub stale: bool, + pub on_refresh: EventHandler<()>, + pub on_close: EventHandler<()>, + /// Scrolling body content. + pub body: Element, + /// Footer actions. Wrap trailing actions in `span { class: "right" }`. + pub footer: Element, +} + +#[component] +pub fn DetailPanel(props: DetailPanelProps) -> Element { + let freshness_class = if props.stale { + "freshness stale" + } else { + "freshness" + }; + rsx! { + aside { class: "detail-panel", + div { class: "detail-header", + span { class: "title", "{props.title}" } + div { class: "detail-header-right", + button { + class: freshness_class, + title: "Refreshed {props.freshness} ago · click to refresh", + onclick: move |_| props.on_refresh.call(()), + "↻ " + span { "{props.freshness}" } + } + button { + class: "close-x", + title: "Close detail", + aria_label: "Close detail", + onclick: move |_| props.on_close.call(()), + "×" + } + } + } + div { class: "detail-body", {props.body} } + div { class: "detail-footer", {props.footer} } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn render(app: fn() -> Element) -> String { + let mut dom = VirtualDom::new(app); + dom.rebuild_in_place(); + dioxus_ssr::render(&dom) + } + + #[test] + fn renders_header_and_both_slots() { + fn app() -> Element { + rsx! { + DetailPanel { + title: "evt_01H8", + freshness: "8s", + on_refresh: |_| {}, + on_close: |_| {}, + body: rsx! { div { "BODY" } }, + footer: rsx! { button { "Edit" } }, + } + } + } + let html = render(app); + assert!( + html.contains(r#"evt_01H8"#), + "{html}" + ); + assert!(html.contains(r#"class="freshness""#), "{html}"); + assert!(html.contains("8s"), "{html}"); + assert!( + html.contains(r#"
BODY
"#), + "{html}" + ); + assert!( + html.contains(r#""#), + "{html}" + ); + } + + #[test] + fn stale_marks_the_freshness_chip() { + fn app() -> Element { + rsx! { + DetailPanel { + title: "row", + freshness: "2m", + stale: true, + on_refresh: |_| {}, + on_close: |_| {}, + body: rsx! {}, + footer: rsx! {}, + } + } + } + let html = render(app); + assert!(html.contains(r#"class="freshness stale""#), "{html}"); + } +} diff --git a/nodedb-studio/src/components/mod.rs b/nodedb-studio/src/components/mod.rs index e16adf9..9342021 100644 --- a/nodedb-studio/src/components/mod.rs +++ b/nodedb-studio/src/components/mod.rs @@ -3,6 +3,7 @@ pub mod async_view; pub mod command_palette; pub mod data_grid; +pub mod detail_panel; pub mod live_tail; pub mod modal; pub mod popovers; From 606185aeb828c9486513270110c72941fb97a7aa Mon Sep 17 00:00:00 2001 From: Hatta Zainal Date: Mon, 28 Sep 2026 19:53:37 +0800 Subject: [PATCH 3/7] feat(modals): entity-form and confirm-delete modals behind ModalKind ModalKind gains the seven entity-form variants (document, strict, vector, graph node, graph edge, KV, spatial) and ConfirmDelete, each with a ModalHost arm. Bodies match the mockup and show its fixture record. Save and Delete only close the modal: no seam method writes a record yet, and nothing selects one until the Explorer's master-detail lands. The variants carry no payload until then. The Explorer header's create buttons now follow the storage mode: + Insert for document/strict/vector/KV, + Node and + Edge for graph, + Feature for spatial, none for timeseries (append-only) or FTS (writes happen on the parent). The mapping is an exhaustive match in insert_actions, so a new StorageMode must decide its create flow. Confirm-delete keeps Delete permanently disabled until the typed word is "delete"; surrounding whitespace is forgiven, case is not. Modal takes an optional width in px for the mockup's per-form widths. The edge form's From/type/To row uses a three-column form-row; the mockup's two-column row wrapped To onto its own line. --- nodedb-studio/assets/styles.css | 110 +++++++ nodedb-studio/src/components/modal.rs | 12 +- nodedb-studio/src/modals/confirm_delete.rs | 72 +++++ nodedb-studio/src/modals/entity_forms.rs | 319 +++++++++++++++++++++ nodedb-studio/src/modals/host.rs | 74 +++++ nodedb-studio/src/modals/mod.rs | 2 + nodedb-studio/src/state/ui.rs | 11 + nodedb-studio/src/views/explorer/view.rs | 49 +++- 8 files changed, 646 insertions(+), 3 deletions(-) create mode 100644 nodedb-studio/src/modals/confirm_delete.rs create mode 100644 nodedb-studio/src/modals/entity_forms.rs diff --git a/nodedb-studio/assets/styles.css b/nodedb-studio/assets/styles.css index dc49947..ead3329 100644 --- a/nodedb-studio/assets/styles.css +++ b/nodedb-studio/assets/styles.css @@ -1909,3 +1909,113 @@ border-color: rgba(133,79,11,0.3); background: rgba(133,79,11,0.06); } + + /* Entity-form + confirm-delete modals (modals/entity_forms.rs, confirm_delete.rs) */ + .modal-vtabs { + display: flex; gap: 2px; + padding: 0 18px; + border-bottom: 0.5px solid var(--border-soft); + } + .vtab { + padding: 8px 14px; + font-size: 12px; + color: var(--text-secondary); + border-bottom: 2px solid transparent; + margin-bottom: -0.5px; + cursor: pointer; + } + .vtab:hover { color: var(--text-primary); } + .vtab.active { + color: var(--text-primary); + border-bottom-color: var(--accent); + font-weight: 500; + } + .vtab.disabled { opacity: 0.5; cursor: default; } + .vtab .tbd-note { font-size: 8px; padding: 0 4px; margin-left: 4px; } + .tbd-note { + display: inline-flex; align-items: center; gap: 4px; + padding: 2px 8px; + background: rgba(133,79,11,0.10); + color: var(--text-warning); + border: 1px dashed var(--text-warning); + border-radius: 4px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + } + .tbd-note::before { content: "?"; font-weight: 700; } + .tbd-block { + margin: 12px 0; + padding: 10px 14px; + background: rgba(133,79,11,0.06); + border-left: 2px solid var(--text-warning); + border-radius: 0 6px 6px 0; + font-size: 11px; + color: var(--text-warning); + line-height: 1.5; + } + .tbd-block strong { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; } + .json-editor { + width: 100%; + min-height: 280px; + padding: 12px; + background: var(--bg-secondary); + border: 0.5px solid var(--border-mid); + border-radius: 5px; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + outline: none; + resize: vertical; + color: var(--text-primary); + tab-size: 2; + } + .field-hint { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-tertiary); + } + .field-hint.required { color: var(--text-warning); } + .form-field input:disabled { opacity: 0.6; } + .form-check { + display: flex; gap: 8px; align-items: center; + padding-top: 6px; + font-size: 11px; + color: var(--text-secondary); + } + .form-check .mono { font-family: var(--font-mono); } + .form-row.three { grid-template-columns: 1fr 1fr 1fr; } + .form-note { + padding-top: 6px; + font-size: 11px; + color: var(--text-tertiary); + } + .confirm-lead { margin: 0 0 8px; font-size: 13px; } + .confirm-detail { margin: 0; color: var(--text-secondary); font-size: 12px; } + .danger-zone { + background: rgba(156,36,36,0.08); + border: 0.5px solid rgba(156,36,36,0.3); + border-radius: 6px; + padding: 10px 12px; + font-size: 11px; + color: var(--text-danger); + font-family: var(--font-mono); + margin-top: 12px; + } + .confirm-input { + margin-top: 10px; + width: 100%; + padding: 6px 10px; + background: var(--bg-secondary); + border: 0.5px solid var(--border-mid); + border-radius: 5px; + font-family: var(--font-mono); + font-size: 13px; + } + .btn.danger { + background: var(--text-danger); + color: var(--bg-primary); + border-color: var(--text-danger); + } + .btn.danger:hover { opacity: 0.9; } + .btn.danger:disabled { opacity: 0.4; cursor: not-allowed; } diff --git a/nodedb-studio/src/components/modal.rs b/nodedb-studio/src/components/modal.rs index d6c2684..a42dc0e 100644 --- a/nodedb-studio/src/components/modal.rs +++ b/nodedb-studio/src/components/modal.rs @@ -8,17 +8,25 @@ use crate::state::ui::ModalKind; /// Reusable modal shell. Clicking the backdrop or the close button dismisses; /// clicks inside the panel do not (matches the mockup's `closeModal`). -/// `wide` selects the `.modal.wide` variant used by Preferences. +/// `wide` selects the `.modal.wide` variant used by Preferences. `width` +/// overrides the panel width in px, as the mockup's entity forms do. #[component] -pub fn Modal(title: String, #[props(default = false)] wide: bool, children: Element) -> Element { +pub fn Modal( + title: String, + #[props(default = false)] wide: bool, + #[props(default)] width: Option, + children: Element, +) -> Element { let mut modal = use_context::>>(); let panel_class = if wide { "modal wide" } else { "modal" }; + let panel_style = width.map(|w| format!("width: {w}px;")).unwrap_or_default(); rsx! { div { class: "modal-overlay open", onclick: move |_| modal.set(None), div { class: "{panel_class}", + style: "{panel_style}", onclick: move |e| e.stop_propagation(), div { class: "modal-header", h3 { "{title}" } diff --git a/nodedb-studio/src/modals/confirm_delete.rs b/nodedb-studio/src/modals/confirm_delete.rs new file mode 100644 index 0000000..8362963 --- /dev/null +++ b/nodedb-studio/src/modals/confirm_delete.rs @@ -0,0 +1,72 @@ +//! Confirm-delete modal body, shared by every engine viewer. +//! +//! The destructive button stays disabled until the typed confirmation passes +//! `delete_confirmed`. SEAM-UNWIRED: Delete only closes the modal; no seam +//! method deletes a record yet. + +use dioxus::prelude::*; + +use crate::state::ui::ModalKind; + +/// Whether `typed` confirms the delete. Surrounding whitespace is forgiven +/// (autocomplete, paste); case and spelling are not, so the friction before +/// an irreversible act stays. +fn delete_confirmed(typed: &str) -> bool { + typed.trim() == "delete" +} + +#[component] +pub fn ConfirmDelete() -> Element { + let mut modal = use_context::>>(); + let mut typed = use_signal(String::new); + let confirmed = delete_confirmed(&typed.read()); + rsx! { + div { class: "modal-body", + p { class: "confirm-lead", "Delete this record? This cannot be undone." } + p { class: "confirm-detail", + "The record will be removed from storage. If foreign keys reference it, the operation will fail unless cascade is configured." + } + div { class: "danger-zone", "Type " strong { "delete" } " to confirm." } + input { + class: "confirm-input", + placeholder: "delete", + value: "{typed}", + oninput: move |e| typed.set(e.value()), + } + } + div { class: "modal-footer", + button { class: "btn ghost", onclick: move |_| modal.set(None), "Cancel" } + button { + class: "btn danger", + disabled: !confirmed, + onclick: move |_| modal.set(None), + "Delete permanently" + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_word_confirms() { + assert!(delete_confirmed("delete")); + } + + #[test] + fn surrounding_whitespace_is_forgiven_but_case_is_not() { + assert!(delete_confirmed(" delete ")); + assert!(delete_confirmed("delete\n")); + assert!(!delete_confirmed("Delete")); + assert!(!delete_confirmed("de lete")); + } + + #[test] + fn blank_and_partial_input_do_not_confirm() { + assert!(!delete_confirmed("")); + assert!(!delete_confirmed("del")); + assert!(!delete_confirmed("deleted")); + } +} diff --git a/nodedb-studio/src/modals/entity_forms.rs b/nodedb-studio/src/modals/entity_forms.rs new file mode 100644 index 0000000..b711d43 --- /dev/null +++ b/nodedb-studio/src/modals/entity_forms.rs @@ -0,0 +1,319 @@ +//! Entity insert/edit modal bodies, one per engine: document, strict, vector, +//! graph node, graph edge, KV and spatial. +//! +//! SEAM-UNWIRED: static mockup parity. Every field shows the mockup's fixture +//! record, and Save only closes the modal. No seam method writes a record yet, +//! and nothing selects one until the Explorer's master-detail lands. Both +//! arrive together; the record then travels in `ModalKind` and Save goes +//! through `use_action`. + +use dioxus::prelude::*; + +use crate::state::ui::ModalKind; + +const DOC_JSON: &str = r#"{ + "_id": "evt_01H8QXG2K…", + "type": "page_view", + "user_id": "u_44182", + "ts": "2026-06-13T04:22:18Z", + "props": { + "path": "/dashboard", + "referrer": "google", + "ms_to_load": 348, + "viewport": { "w": 1440, "h": 900 } + }, + "tags": ["web", "mobile"] +}"#; + +const VECTOR_FLOATS: &str = "[0.041, -0.218, 0.110, 0.082, -0.044, 0.198, 0.012, -0.107, 0.295, -0.018, 0.142, -0.231, 0.088, 0.005, -0.140, 0.211, 0.038, -0.092, 0.167, -0.024, 0.103, … 748 more]"; + +const EDGE_PROPS: &str = r#"{ + "since": "2026-06-13T04:22:18Z" +}"#; + +const KV_JSON: &str = r#"{ + "user": "u_44182", + "since": "2026-06-13T04:00:18Z", + "device": "macbook", + "ip": "103.42.18.x", + "last_seen": "2026-06-13T04:22:18Z" +}"#; + +const GEOJSON: &str = r#"{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-2.241, 53.481] + }, + "properties": { + "name": "Manchester Office", + "category": "office", + "headcount": 142, + "opened": "2022-04-01" + } +}"#; + +/// Cancel + one primary action. Both close the modal until writes exist. +#[component] +fn SaveFooter(#[props(default = "Save".to_string())] label: String) -> Element { + let mut modal = use_context::>>(); + rsx! { + div { class: "modal-footer", + button { class: "btn ghost", onclick: move |_| modal.set(None), "Cancel" } + button { class: "btn primary", onclick: move |_| modal.set(None), "{label}" } + } + } +} + +/// Status line under a form: an ok pill plus optional mono detail. +#[component] +fn CheckLine(pill: String, #[props(default)] detail: String) -> Element { + rsx! { + div { class: "form-check", + span { class: "pill ok", span { class: "dot" } "{pill}" } + if !detail.is_empty() { + span { class: "mono", "{detail}" } + } + } + } +} + +#[component] +pub fn DocForm() -> Element { + let mut modal = use_context::>>(); + rsx! { + div { class: "modal-vtabs", + div { class: "vtab active", "JSON" } + div { class: "vtab", "Form" } + } + div { class: "modal-body", + div { class: "form-field", + label { "Document JSON" } + textarea { class: "json-editor", value: DOC_JSON } + } + CheckLine { pill: "valid JSON", detail: "412 bytes · 14 fields · 1 nested object · 1 array" } + div { class: "tbd-block", + strong { "TBD" } + " · Schema validation feedback. If NodeDB Document supports optional validation rules, this modal should surface validation errors inline (\"field " + code { "props.ms_to_load" } + " must be a non-negative integer\"). Currently no schema = always accepts." + } + } + div { class: "modal-footer", + button { class: "btn ghost", onclick: move |_| modal.set(None), "Cancel" } + button { class: "btn", "Save as draft" } + button { class: "btn primary", onclick: move |_| modal.set(None), "Save" } + } + } +} + +#[component] +pub fn StrictForm() -> Element { + rsx! { + div { class: "modal-body", + div { class: "form-field", + label { "id " span { class: "field-hint", "int8 · pk · readonly" } } + input { value: "442003", disabled: true } + } + div { class: "form-row", + div { class: "form-field", + label { "customer_id " span { class: "field-hint", "string · fk" } } + input { value: "u_44182" } + } + div { class: "form-field", + label { "status " span { class: "field-hint", "enum" } } + select { + option { "pending" } + option { "processing" } + option { selected: true, "shipped" } + option { "cancelled" } + option { "refunded" } + } + } + } + div { class: "form-row", + div { class: "form-field", + label { "total " span { class: "field-hint", "decimal(10,2)" } } + input { r#type: "number", step: "0.01", value: "129.40" } + } + div { class: "form-field", + label { "currency " span { class: "field-hint", "char(3)" } } + input { maxlength: "3", value: "USD" } + } + } + div { class: "form-row", + div { class: "form-field", + label { "placed_at " span { class: "field-hint", "timestamptz" } } + input { value: "2026-06-12 18:04:21+08" } + } + div { class: "form-field", + label { "shipped_at " span { class: "field-hint", "timestamptz · nullable" } } + input { value: "2026-06-13 09:12:00+08" } + } + } + CheckLine { pill: "all checks pass", detail: "total ≥ 0 ✓ · shipped_at ≥ placed_at ✓" } + } + SaveFooter {} + } +} + +#[component] +pub fn VectorForm() -> Element { + rsx! { + div { class: "modal-vtabs", + div { class: "vtab active", "Paste vector" } + div { class: "vtab disabled", "Embed from text " span { class: "tbd-note", "TBD" } } + } + div { class: "modal-body", + div { class: "form-field", + label { "id" } + input { value: "e_001", disabled: true } + } + div { class: "form-field", + label { "Vector " span { class: "field-hint", "768 floats, comma-separated" } } + textarea { class: "json-editor", style: "min-height: 140px;", value: VECTOR_FLOATS } + } + div { class: "form-row", + div { class: "form-field", label { "source_doc" } input { value: "handbook.md#42" } } + div { class: "form-field", label { "model" } input { value: "all-mpnet-base-v2" } } + } + div { class: "form-field", + label { "chunk text" } + textarea { + style: "min-height: 60px;", + value: "Engine-aware viewers open a purpose-built UI per database engine type.", + } + } + CheckLine { pill: "vector valid · 768d · norm 1.0" } + } + SaveFooter {} + } +} + +#[component] +pub fn GraphNodeForm() -> Element { + rsx! { + div { class: "modal-body", + div { class: "form-row", + div { class: "form-field", + label { "_id " span { class: "field-hint", "readonly" } } + input { value: "u_44182", disabled: true } + } + div { class: "form-field", + label { "label" } + select { option { selected: true, "User" } option { "Post" } option { "Topic" } } + } + } + div { class: "form-field", + label { "name " span { class: "field-hint required", "required" } } + input { value: "Aisha Tan" } + } + div { class: "form-row", + div { class: "form-field", label { "country" } input { value: "MY" } } + div { class: "form-field", + label { "joined " span { class: "field-hint required", "required" } } + input { r#type: "date", value: "2024-03-11" } + } + } + div { class: "form-field", + label { "verified " span { class: "field-hint", "bool" } } + select { option { selected: true, "true" } option { "false" } } + } + div { class: "form-note", + "Property fields are derived from the " + strong { "User" } + " label schema. To add a property to all User nodes, edit the schema in Designer." + } + } + SaveFooter {} + } +} + +#[component] +pub fn GraphEdgeForm() -> Element { + rsx! { + div { class: "modal-body", + div { class: "form-row three", + div { class: "form-field", label { "From (node _id)" } input { placeholder: "u_44182" } } + div { class: "form-field", + label { "type" } + select { + option { "POSTED" } + option { selected: true, "FOLLOWS" } + option { "LIKES" } + option { "TAGGED" } + } + } + div { class: "form-field", label { "To (node _id)" } input { placeholder: "u_77103" } } + } + div { class: "form-field", + label { "Properties " span { class: "field-hint", "FOLLOWS edges accept: since (timestamp)" } } + textarea { class: "json-editor", style: "min-height: 100px;", value: EDGE_PROPS } + } + div { class: "form-note", + "Edge property schema is derived from the " + strong { "FOLLOWS" } + " edge type." + } + } + SaveFooter { label: "Create edge" } + } +} + +#[component] +pub fn KvForm() -> Element { + rsx! { + div { class: "modal-body", + div { class: "form-field", label { "key" } input { value: "session:u_44182" } } + div { class: "form-row", + div { class: "form-field", + label { "type" } + select { + option { selected: true, "json" } + option { "string" } + option { "int" } + option { "bool" } + option { "bytes" } + option { "counter" } + } + } + div { class: "form-field", + label { "TTL " span { class: "field-hint", "none = no expiry" } } + input { value: "14m", placeholder: "e.g. 30s, 5m, 2h, 1d" } + } + } + div { class: "form-field", + label { "value" } + textarea { class: "json-editor", style: "min-height: 200px;", value: KV_JSON } + } + CheckLine { pill: "valid JSON · 2.1 KB" } + } + SaveFooter {} + } +} + +#[component] +pub fn SpatialForm() -> Element { + rsx! { + div { class: "modal-vtabs", + div { class: "vtab active", "Paste GeoJSON" } + div { class: "vtab disabled", "Draw on map " span { class: "tbd-note", "TBD" } } + } + div { class: "modal-body", + div { class: "form-field", + label { "id" } + input { value: "feature_8281", disabled: true } + } + div { class: "form-field", + label { "GeoJSON " span { class: "field-hint", "SRID 4326 (WGS84)" } } + textarea { class: "json-editor", style: "min-height: 240px;", value: GEOJSON } + } + CheckLine { pill: "valid GeoJSON · Point · in bbox" } + div { class: "tbd-block", + strong { "TBD" } + " · Drawing library for the \"Draw on map\" tab. Affects whether Leaflet.draw, Mapbox GL Draw, or a custom Rust drawing layer is chosen." + } + } + SaveFooter {} + } +} diff --git a/nodedb-studio/src/modals/host.rs b/nodedb-studio/src/modals/host.rs index d868433..3a7ee71 100644 --- a/nodedb-studio/src/modals/host.rs +++ b/nodedb-studio/src/modals/host.rs @@ -4,6 +4,10 @@ use dioxus::prelude::*; use crate::components::modal::Modal; +use crate::modals::confirm_delete::ConfirmDelete; +use crate::modals::entity_forms::{ + DocForm, GraphEdgeForm, GraphNodeForm, KvForm, SpatialForm, StrictForm, VectorForm, +}; use crate::modals::new_connection::NewConnectionForm; use crate::modals::preferences::PreferencesPanes; use crate::state::ui::ModalKind; @@ -21,5 +25,75 @@ pub fn ModalHost() -> Element { Some(ModalKind::Preferences) => rsx! { Modal { title: "Preferences", wide: true, PreferencesPanes {} } }, + Some(ModalKind::DocForm) => rsx! { + Modal { title: "Edit document · evt_01H8QXG2K…", width: 640, DocForm {} } + }, + Some(ModalKind::StrictForm) => rsx! { + Modal { title: "Edit row · orders / 442003", width: 560, StrictForm {} } + }, + Some(ModalKind::VectorForm) => rsx! { + Modal { title: "Edit vector · e_001", width: 640, VectorForm {} } + }, + Some(ModalKind::GraphNodeForm) => rsx! { + Modal { title: "Edit node · u_44182", width: 540, GraphNodeForm {} } + }, + Some(ModalKind::GraphEdgeForm) => rsx! { + Modal { title: "New edge", width: 540, GraphEdgeForm {} } + }, + Some(ModalKind::KvForm) => rsx! { + Modal { title: "Edit · session:u_44182", width: 540, KvForm {} } + }, + Some(ModalKind::SpatialForm) => rsx! { + Modal { title: "Edit feature · feature_8281", width: 640, SpatialForm {} } + }, + Some(ModalKind::ConfirmDelete) => rsx! { + Modal { title: "Delete record", width: 420, ConfirmDelete {} } + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app(kind: ModalKind) -> Element { + use_context_provider(|| Signal::new(Some(kind))); + rsx! { ModalHost {} } + } + + fn render(kind: ModalKind) -> String { + let mut dom = VirtualDom::new_with_props(app, kind); + dom.rebuild_in_place(); + dioxus_ssr::render(&dom) + } + + /// Each entity/confirm variant opens its own body, not a neighbour's. + #[test] + fn every_entity_variant_renders_its_own_body() { + let cases = [ + (ModalKind::DocForm, "Edit document", "Document JSON"), + (ModalKind::StrictForm, "Edit row", "decimal(10,2)"), + (ModalKind::VectorForm, "Edit vector", "768 floats"), + (ModalKind::GraphNodeForm, "Edit node", "label schema"), + (ModalKind::GraphEdgeForm, "New edge", "Create edge"), + (ModalKind::KvForm, "session:u_44182", "no expiry"), + (ModalKind::SpatialForm, "Edit feature", "SRID 4326"), + ( + ModalKind::ConfirmDelete, + "Delete record", + "Delete permanently", + ), + ]; + for (kind, title, body) in cases { + let html = render(kind); + assert!(html.contains(title), "{kind:?} title: {html}"); + assert!(html.contains(body), "{kind:?} body: {html}"); + } + } + + #[test] + fn delete_button_starts_disabled() { + let html = render(ModalKind::ConfirmDelete); + assert!(html.contains("disabled"), "{html}"); } } diff --git a/nodedb-studio/src/modals/mod.rs b/nodedb-studio/src/modals/mod.rs index 8821c4c..2896106 100644 --- a/nodedb-studio/src/modals/mod.rs +++ b/nodedb-studio/src/modals/mod.rs @@ -1,5 +1,7 @@ //! Modal content + the host that decides which modal is open. +pub mod confirm_delete; +pub mod entity_forms; pub mod host; pub mod new_connection; pub mod preferences; diff --git a/nodedb-studio/src/state/ui.rs b/nodedb-studio/src/state/ui.rs index 73e1f80..8cc5db6 100644 --- a/nodedb-studio/src/state/ui.rs +++ b/nodedb-studio/src/state/ui.rs @@ -15,8 +15,19 @@ pub enum Popover { /// Which modal is currently open. Preferences is reachable in either app state; /// New connection only while disconnected/connected via the relevant trigger. +/// The entity-form and confirm-delete variants carry no record yet: nothing +/// selects one until the Explorer's master-detail lands. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModalKind { NewConnection, Preferences, + DocForm, + StrictForm, + VectorForm, + GraphNodeForm, + GraphEdgeForm, + KvForm, + SpatialForm, + #[allow(dead_code)] // SEAM-UNWIRED: opened from the detail-panel footer (Phase 4) + ConfirmDelete, } diff --git a/nodedb-studio/src/views/explorer/view.rs b/nodedb-studio/src/views/explorer/view.rs index 70a7efa..ccdad56 100644 --- a/nodedb-studio/src/views/explorer/view.rs +++ b/nodedb-studio/src/views/explorer/view.rs @@ -15,6 +15,7 @@ use dioxus::prelude::*; use crate::models::collection::StorageMode; use crate::models::explorer::CollectionGroup; +use crate::state::ui::ModalKind; use crate::views::explorer::sidebar::ExplorerSidebar; use crate::views::explorer::viewers::document::DocumentViewer; use crate::views::explorer::viewers::fts::FtsViewer; @@ -62,9 +63,29 @@ pub fn selection_still_present(groups: &[CollectionGroup], selected: Option<&Sel .any(|g| g.collections.iter().any(|c| c.name == sel.name)) } +/// The header's create buttons for a storage mode, primary first, as the +/// mockup labels them. Timeseries is append-only and FTS writes happen on the +/// parent collection, so neither offers one. +pub fn insert_actions(mode: StorageMode) -> &'static [(&'static str, ModalKind)] { + match mode { + StorageMode::Document => &[("+ Insert", ModalKind::DocForm)], + StorageMode::Strict => &[("+ Insert", ModalKind::StrictForm)], + StorageMode::Vector => &[("+ Insert", ModalKind::VectorForm)], + StorageMode::Graph => &[ + ("+ Node", ModalKind::GraphNodeForm), + ("+ Edge", ModalKind::GraphEdgeForm), + ], + StorageMode::Timeseries => &[], + StorageMode::Kv => &[("+ Insert", ModalKind::KvForm)], + StorageMode::Spatial => &[("+ Feature", ModalKind::SpatialForm)], + StorageMode::Fts => &[], + } +} + #[component] pub fn Explorer() -> Element { let selected = use_signal(|| None::); + let mut modal = use_context::>>(); let sel = selected.read().clone(); rsx! { @@ -82,7 +103,14 @@ pub fn Explorer() -> Element { button { class: "btn small", "Schema" } button { class: "btn small", "Indexes" } button { class: "btn small", "Export" } - button { class: "btn small primary", "+ Insert" } + for (i, (label, kind)) in insert_actions(sel.mode).iter().copied().enumerate() { + button { + key: "{label}", + class: if i == 0 { "btn small primary" } else { "btn small" }, + onclick: move |_| modal.set(Some(kind)), + "{label}" + } + } } } div { class: "viewer-body", @@ -131,6 +159,25 @@ mod tests { /// A pick that survived a reload must still exist in the new set. Against /// an implementation that keeps any non-None selection, the second case /// fails and the viewer header names a collection no sidebar row matches. + /// Insert-capable modes open their own engine's form; the two without a + /// create flow render no button at all. + #[test] + fn insert_actions_match_the_mockup_per_mode() { + let first = |m| insert_actions(m).first().map(|(_, k)| *k); + assert_eq!(first(StorageMode::Document), Some(ModalKind::DocForm)); + assert_eq!(first(StorageMode::Strict), Some(ModalKind::StrictForm)); + assert_eq!(first(StorageMode::Vector), Some(ModalKind::VectorForm)); + assert_eq!(first(StorageMode::Graph), Some(ModalKind::GraphNodeForm)); + assert_eq!(first(StorageMode::Kv), Some(ModalKind::KvForm)); + assert_eq!(first(StorageMode::Spatial), Some(ModalKind::SpatialForm)); + assert_eq!( + insert_actions(StorageMode::Graph)[1].1, + ModalKind::GraphEdgeForm + ); + assert!(insert_actions(StorageMode::Timeseries).is_empty()); + assert!(insert_actions(StorageMode::Fts).is_empty()); + } + #[test] fn selection_is_kept_only_while_the_collection_exists() { let groups = groups_with(&["events", "orders"]); From c944187e3aec3e54c69d711c9c5b15bb6346211a Mon Sep 17 00:00:00 2001 From: Hatta Zainal Date: Mon, 28 Sep 2026 19:55:59 +0800 Subject: [PATCH 4/7] feat(modals): open Preferences at a specific pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModalKind::Preferences now carries a PrefsPane, so a trigger picks the pane it opens. The avatar popover's "Keyboard shortcuts" and "About" items land on those panes instead of Appearance; every other trigger (⌘, palette, connection manager) opens Appearance as before. The open pane lives in the modal signal alone. A sidebar click re-sets ModalKind::Preferences(pane) rather than updating a second, local signal, so a deep-link and a click cannot disagree. The string keys and their _ => fallback give way to exhaustive matches on PrefsPane. --- .../src/components/command_palette.rs | 6 +-- .../src/components/popovers/avatar_popover.rs | 8 ++-- nodedb-studio/src/modals/host.rs | 39 ++++++++++++++- nodedb-studio/src/modals/preferences.rs | 48 +++++++------------ nodedb-studio/src/routes.rs | 4 +- nodedb-studio/src/state/ui.rs | 39 ++++++++++++++- nodedb-studio/src/views/connection_manager.rs | 4 +- 7 files changed, 103 insertions(+), 45 deletions(-) diff --git a/nodedb-studio/src/components/command_palette.rs b/nodedb-studio/src/components/command_palette.rs index 8f45340..19b596f 100644 --- a/nodedb-studio/src/components/command_palette.rs +++ b/nodedb-studio/src/components/command_palette.rs @@ -10,7 +10,7 @@ use crate::routes::Route; use crate::services::backend::Backend; use crate::state::connection::{ActiveConnection, ConnectError, apply_connect}; use crate::state::connections_registry::{Credentials, SavedConnection}; -use crate::state::ui::ModalKind; +use crate::state::ui::{ModalKind, PrefsPane}; #[component] pub fn CommandPalette() -> Element { @@ -86,10 +86,10 @@ pub fn CommandPalette() -> Element { div { class: "palette-item", "Run current query" span { class: "meta", "⌘↵" } } - div { class: "palette-item", onclick: move |_| { modal.set(Some(ModalKind::Preferences)); open.set(false); }, + div { class: "palette-item", onclick: move |_| { modal.set(Some(ModalKind::Preferences(PrefsPane::Appearance))); open.set(false); }, "Open preferences" span { class: "meta", "⌘," } } - div { class: "palette-item", onclick: move |_| { modal.set(Some(ModalKind::Preferences)); open.set(false); }, + div { class: "palette-item", onclick: move |_| { modal.set(Some(ModalKind::Preferences(PrefsPane::Appearance))); open.set(false); }, "Toggle theme" span { class: "meta", "⌘⇧L" } } diff --git a/nodedb-studio/src/components/popovers/avatar_popover.rs b/nodedb-studio/src/components/popovers/avatar_popover.rs index 64e162f..04290e5 100644 --- a/nodedb-studio/src/components/popovers/avatar_popover.rs +++ b/nodedb-studio/src/components/popovers/avatar_popover.rs @@ -4,7 +4,7 @@ use dioxus::prelude::*; use crate::state::connection::ActiveConnection; -use crate::state::ui::{ModalKind, Popover}; +use crate::state::ui::{ModalKind, Popover, PrefsPane}; #[component] pub fn AvatarPopover() -> Element { @@ -47,17 +47,17 @@ pub fn AvatarPopover() -> Element { div { class: "ap-section-label", "App" } div { class: "ap-item", - onclick: move |_| { popover.set(None); modal.set(Some(ModalKind::Preferences)); }, + onclick: move |_| { popover.set(None); modal.set(Some(ModalKind::Preferences(PrefsPane::Appearance))); }, "Preferences… " span { class: "kbd", "⌘," } } div { class: "ap-item", - onclick: move |_| { popover.set(None); modal.set(Some(ModalKind::Preferences)); }, + onclick: move |_| { popover.set(None); modal.set(Some(ModalKind::Preferences(PrefsPane::Keyboard))); }, "Keyboard shortcuts" } div { class: "ap-item", - onclick: move |_| { popover.set(None); modal.set(Some(ModalKind::Preferences)); }, + onclick: move |_| { popover.set(None); modal.set(Some(ModalKind::Preferences(PrefsPane::About))); }, "About NodeDB-Studio" } } diff --git a/nodedb-studio/src/modals/host.rs b/nodedb-studio/src/modals/host.rs index 3a7ee71..105938e 100644 --- a/nodedb-studio/src/modals/host.rs +++ b/nodedb-studio/src/modals/host.rs @@ -22,8 +22,8 @@ pub fn ModalHost() -> Element { Some(ModalKind::NewConnection) => rsx! { Modal { title: "New connection", NewConnectionForm {} } }, - Some(ModalKind::Preferences) => rsx! { - Modal { title: "Preferences", wide: true, PreferencesPanes {} } + Some(ModalKind::Preferences(pane)) => rsx! { + Modal { title: "Preferences", wide: true, PreferencesPanes { pane } } }, Some(ModalKind::DocForm) => rsx! { Modal { title: "Edit document · evt_01H8QXG2K…", width: 640, DocForm {} } @@ -91,6 +91,41 @@ mod tests { } } + /// A deep-link lands on its pane, with the sidebar marking that pane + /// active, instead of always opening at Appearance. + #[test] + fn preferences_opens_at_the_requested_pane() { + use crate::state::preferences::Preferences; + use crate::state::ui::PrefsPane; + fn app(pane: PrefsPane) -> Element { + use_context_provider(|| Signal::new(Some(ModalKind::Preferences(pane)))); + use_context_provider(|| Signal::new(Preferences::default())); + rsx! { ModalHost {} } + } + let headings = [ + (PrefsPane::Appearance, "Appearance"), + (PrefsPane::Editor, "Editor"), + (PrefsPane::Keyboard, "Keyboard shortcuts"), + (PrefsPane::Security, "Security"), + (PrefsPane::Telemetry, "Telemetry"), + (PrefsPane::About, "About NodeDB-Studio"), + ]; + for (pane, heading) in headings { + let mut dom = VirtualDom::new_with_props(app, pane); + dom.rebuild_in_place(); + let html = dioxus_ssr::render(&dom); + let label = pane.label(); + assert!( + html.contains(&format!(r#"
{label}
"#)), + "{pane:?} sidebar: {html}" + ); + assert!( + html.contains(&format!("

{heading}

")), + "{pane:?} pane: {html}" + ); + } + } + #[test] fn delete_button_starts_disabled() { let html = render(ModalKind::ConfirmDelete); diff --git a/nodedb-studio/src/modals/preferences.rs b/nodedb-studio/src/modals/preferences.rs index 5541ef7..cbd72b7 100644 --- a/nodedb-studio/src/modals/preferences.rs +++ b/nodedb-studio/src/modals/preferences.rs @@ -9,48 +9,34 @@ use dioxus::prelude::*; use crate::state::preferences::{Preferences, Theme}; -use crate::state::ui::ModalKind; - -const CATS: [(&str, &str); 6] = [ - ("appearance", "Appearance"), - ("editor", "Editor"), - ("keyboard", "Keyboard"), - ("security", "Security"), - ("telemetry", "Telemetry"), - ("about", "About"), -]; +use crate::state::ui::{ModalKind, PrefsPane}; +/// `pane` is the open pane, owned by the modal signal: a sidebar click +/// re-sets `ModalKind::Preferences` rather than keeping a second signal. #[component] -pub fn PreferencesPanes() -> Element { +pub fn PreferencesPanes(pane: PrefsPane) -> Element { let mut modal = use_context::>>(); - let mut pane = use_signal(|| "appearance".to_string()); - let current = pane.read().clone(); rsx! { div { class: "prefs-layout", div { class: "prefs-sidebar", - for (key, label) in CATS { - { - let is_active = current == key; - let k = key.to_string(); - rsx! { - div { - class: if is_active { "prefs-cat active" } else { "prefs-cat" }, - onclick: move |_| pane.set(k.clone()), - "{label}" - } - } + for p in PrefsPane::ALL { + div { + key: "{p.label()}", + class: if p == pane { "prefs-cat active" } else { "prefs-cat" }, + onclick: move |_| modal.set(Some(ModalKind::Preferences(p))), + "{p.label()}" } } } div { class: "prefs-content", - match current.as_str() { - "editor" => rsx! { EditorPane {} }, - "keyboard" => rsx! { KeyboardPane {} }, - "security" => rsx! { SecurityPane {} }, - "telemetry" => rsx! { TelemetryPane {} }, - "about" => rsx! { AboutPane {} }, - _ => rsx! { AppearancePane {} }, + match pane { + PrefsPane::Appearance => rsx! { AppearancePane {} }, + PrefsPane::Editor => rsx! { EditorPane {} }, + PrefsPane::Keyboard => rsx! { KeyboardPane {} }, + PrefsPane::Security => rsx! { SecurityPane {} }, + PrefsPane::Telemetry => rsx! { TelemetryPane {} }, + PrefsPane::About => rsx! { AboutPane {} }, } } } diff --git a/nodedb-studio/src/routes.rs b/nodedb-studio/src/routes.rs index 2b8c619..a019bd7 100644 --- a/nodedb-studio/src/routes.rs +++ b/nodedb-studio/src/routes.rs @@ -13,7 +13,7 @@ use crate::components::rail::Rail; use crate::components::statusbar::Statusbar; use crate::components::topbar::Topbar; use crate::state::connection::{ActiveConnection, Capability}; -use crate::state::ui::{ModalKind, Popover}; +use crate::state::ui::{ModalKind, Popover, PrefsPane}; use crate::views::admin::Admin; use crate::views::console::Console; @@ -139,7 +139,7 @@ fn StudioLayout() -> Element { } Key::Character(c) if meta && c == "," => { e.prevent_default(); - modal.set(Some(ModalKind::Preferences)); + modal.set(Some(ModalKind::Preferences(PrefsPane::Appearance))); } Key::Escape => { palette.set(false); diff --git a/nodedb-studio/src/state/ui.rs b/nodedb-studio/src/state/ui.rs index 8cc5db6..a3332fa 100644 --- a/nodedb-studio/src/state/ui.rs +++ b/nodedb-studio/src/state/ui.rs @@ -13,6 +13,43 @@ pub enum Popover { Avatar, } +/// A Preferences pane. Carried by `ModalKind::Preferences` so a trigger can +/// open the modal at a specific pane (the avatar popover's "Keyboard +/// shortcuts" and "About" items) and the sidebar switches panes through the +/// same signal. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PrefsPane { + Appearance, + Editor, + Keyboard, + Security, + Telemetry, + About, +} + +impl PrefsPane { + /// Sidebar order. + pub const ALL: [PrefsPane; 6] = [ + PrefsPane::Appearance, + PrefsPane::Editor, + PrefsPane::Keyboard, + PrefsPane::Security, + PrefsPane::Telemetry, + PrefsPane::About, + ]; + + pub fn label(self) -> &'static str { + match self { + PrefsPane::Appearance => "Appearance", + PrefsPane::Editor => "Editor", + PrefsPane::Keyboard => "Keyboard", + PrefsPane::Security => "Security", + PrefsPane::Telemetry => "Telemetry", + PrefsPane::About => "About", + } + } +} + /// Which modal is currently open. Preferences is reachable in either app state; /// New connection only while disconnected/connected via the relevant trigger. /// The entity-form and confirm-delete variants carry no record yet: nothing @@ -20,7 +57,7 @@ pub enum Popover { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModalKind { NewConnection, - Preferences, + Preferences(PrefsPane), DocForm, StrictForm, VectorForm, diff --git a/nodedb-studio/src/views/connection_manager.rs b/nodedb-studio/src/views/connection_manager.rs index 3c1b8e1..c36aa4e 100644 --- a/nodedb-studio/src/views/connection_manager.rs +++ b/nodedb-studio/src/views/connection_manager.rs @@ -9,7 +9,7 @@ use dioxus::prelude::*; use crate::services::backend::Backend; use crate::state::connection::{ActiveConnection, ConnectError, apply_connect}; use crate::state::connections_registry::{ConnStatus, Credentials, SavedConnection}; -use crate::state::ui::ModalKind; +use crate::state::ui::{ModalKind, PrefsPane}; #[component] pub fn ConnectionManager() -> Element { @@ -27,7 +27,7 @@ pub fn ConnectionManager() -> Element { div { "NodeDB " span { "Studio" } } } div { class: "cm-topbar-actions", - a { onclick: move |_| modal.set(Some(ModalKind::Preferences)), "Preferences" } + a { onclick: move |_| modal.set(Some(ModalKind::Preferences(PrefsPane::Appearance))), "Preferences" } a { "Docs" } span { class: "version", "dev" } } From 7943419defc47f8f370bd73e1ded8ac6599f9778 Mon Sep 17 00:00:00 2001 From: Hatta Zainal Date: Mon, 28 Sep 2026 19:56:52 +0800 Subject: [PATCH 5/7] feat(components): StatCard and StatSection for viewer stats panes The mockup's engine viewers share one stats pane: a grid of stat cards (label, value, sub-line) and titled sections that frame a chart or a breakdown. StatCard and StatSection own that markup and CSS once. The chart inside a section differs per viewer and arrives as its body; the viewers adopt these when their stats vtab lands. --- nodedb-studio/assets/styles.css | 47 +++++++++++++++ nodedb-studio/src/components/mod.rs | 1 + nodedb-studio/src/components/stat_card.rs | 69 +++++++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 nodedb-studio/src/components/stat_card.rs diff --git a/nodedb-studio/assets/styles.css b/nodedb-studio/assets/styles.css index ead3329..75ebd8d 100644 --- a/nodedb-studio/assets/styles.css +++ b/nodedb-studio/assets/styles.css @@ -2019,3 +2019,50 @@ } .btn.danger:hover { opacity: 0.9; } .btn.danger:disabled { opacity: 0.4; cursor: not-allowed; } + + /* Stats pane (components/stat_card.rs) */ + .stats-pane { + padding: 20px; + overflow: auto; + } + .stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 22px; + } + .stat-card { + background: var(--bg-primary); + border: 0.5px solid var(--border-mid); + border-radius: 8px; + padding: 14px 16px; + } + .stat-card .lbl { + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-tertiary); + } + .stat-card .val { + font-family: var(--font-mono); + font-size: 22px; + font-weight: 500; + margin-top: 4px; + } + .stat-card .sub { + font-size: 11px; + color: var(--text-secondary); + margin-top: 2px; + } + .stat-section + .stat-section { margin-top: 22px; } + .stat-section h3 { + font-size: 13px; + font-weight: 600; + margin: 0 0 10px; + } + .stat-spark { + background: var(--bg-primary); + border: 0.5px solid var(--border-mid); + border-radius: 8px; + padding: 14px 16px; + } diff --git a/nodedb-studio/src/components/mod.rs b/nodedb-studio/src/components/mod.rs index 9342021..520ea7f 100644 --- a/nodedb-studio/src/components/mod.rs +++ b/nodedb-studio/src/components/mod.rs @@ -10,6 +10,7 @@ pub mod popovers; pub mod rail; pub mod snav; pub mod sparkline; +pub mod stat_card; pub mod statusbar; pub mod subnav; pub mod topbar; diff --git a/nodedb-studio/src/components/stat_card.rs b/nodedb-studio/src/components/stat_card.rs new file mode 100644 index 0000000..652be26 --- /dev/null +++ b/nodedb-studio/src/components/stat_card.rs @@ -0,0 +1,69 @@ +//! Stats-pane building blocks shared by the Explorer's engine viewers: a +//! `StatCard` tile (label, value, sub-line) for the `.stats-grid`, and a +//! `StatSection` that frames one chart or breakdown under a heading. The +//! chart itself (area sparkline, cardinality bars, …) differs per viewer and +//! is passed in as the section's body. + +use dioxus::prelude::*; + +/// One figure in a `.stats-grid`. Values arrive pre-formatted for display. +#[component] +pub fn StatCard(label: String, value: String, sub: String) -> Element { + rsx! { + div { class: "stat-card", + div { class: "lbl", "{label}" } + div { class: "val", "{value}" } + div { class: "sub", "{sub}" } + } + } +} + +/// A titled `.stat-spark` frame around one chart or breakdown. +#[component] +pub fn StatSection(title: String, body: Element) -> Element { + rsx! { + div { class: "stat-section", + h3 { "{title}" } + div { class: "stat-spark", {body} } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn render(app: fn() -> Element) -> String { + let mut dom = VirtualDom::new(app); + dom.rebuild_in_place(); + dioxus_ssr::render(&dom) + } + + #[test] + fn card_renders_label_value_sub_in_order() { + fn app() -> Element { + rsx! { StatCard { label: "Documents", value: "2,400,182", sub: "+820/min" } } + } + let html = render(app); + assert!( + html.contains( + r#"
Documents
2,400,182
+820/min
"# + ), + "{html}" + ); + } + + #[test] + fn section_frames_its_body_under_the_title() { + fn app() -> Element { + rsx! { StatSection { title: "Write throughput · last 1h", body: rsx! { svg {} } } } + } + let html = render(app); + assert!( + html.contains( + r#"

Write throughput · last 1h

"# + ), + "{html}" + ); + } +} From 3ad51d5e9c2234b3f471540046f499db339358fb Mon Sep 17 00:00:00 2001 From: Hatta Zainal Date: Mon, 28 Sep 2026 20:13:19 +0800 Subject: [PATCH 6/7] test(views): restore the selection test's doc comment Adding insert_actions_match_the_mockup_per_mode split the doc comment from selection_is_kept_only_while_the_collection_exists, leaving the new test with both explanations and the old one with none. --- nodedb-studio/src/views/explorer/view.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nodedb-studio/src/views/explorer/view.rs b/nodedb-studio/src/views/explorer/view.rs index ccdad56..e8992af 100644 --- a/nodedb-studio/src/views/explorer/view.rs +++ b/nodedb-studio/src/views/explorer/view.rs @@ -156,9 +156,6 @@ mod tests { }] } - /// A pick that survived a reload must still exist in the new set. Against - /// an implementation that keeps any non-None selection, the second case - /// fails and the viewer header names a collection no sidebar row matches. /// Insert-capable modes open their own engine's form; the two without a /// create flow render no button at all. #[test] @@ -178,6 +175,9 @@ mod tests { assert!(insert_actions(StorageMode::Fts).is_empty()); } + /// A pick that survived a reload must still exist in the new set. Against + /// an implementation that keeps any non-None selection, the second case + /// fails and the viewer header names a collection no sidebar row matches. #[test] fn selection_is_kept_only_while_the_collection_exists() { let groups = groups_with(&["events", "orders"]); From 16907a4000b175255f6506ca98bae3e0c5679607 Mon Sep 17 00:00:00 2001 From: Hatta Zainal Date: Mon, 28 Sep 2026 20:14:22 +0800 Subject: [PATCH 7/7] fix(errors): report row-shape faults as MalformedRows ResultSet::new and the decoder's Row::field raised UnexpectedColumns for a ragged row or a duplicate row id. That variant describes a missing column (the SHOW session-variable fallback), so the message pointed the user at the columns when the fault was in the rows: "unexpected result columns: expected [unique row ids], got [...]". MalformedRows { reason } now carries those cases, non-retriable, with a message that names the row: "result rows do not match their header: row r2 has 1 cells, expected 2". Missing-column checks keep UnexpectedColumns. Its dead_code allow is dropped: ResultSet no longer constructs it, but clippy passes without the allow. --- nodedb-studio/src/models/workbench.rs | 43 +++++++++++++++++++-------- nodedb-studio/src/services/decode.rs | 13 ++++---- nodedb-studio/src/services/error.rs | 7 ++++- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/nodedb-studio/src/models/workbench.rs b/nodedb-studio/src/models/workbench.rs index c8369c0..39a2bf5 100644 --- a/nodedb-studio/src/models/workbench.rs +++ b/nodedb-studio/src/models/workbench.rs @@ -11,9 +11,9 @@ use crate::services::error::StudioError; /// /// Rectangular and uniquely keyed by construction: every row has exactly /// `columns.len()` cells and a distinct `id`. Build one through -/// [`ResultSet::new`], which rejects a ragged row or a duplicate id, so a -/// decoder that drops a cell surfaces as an error instead of a grid whose -/// values sit under the wrong headers. `cells` hold display strings; sorting +/// [`ResultSet::new`], which rejects a ragged row or a duplicate id with +/// `MalformedRows`, so a decoder that drops a cell surfaces as an error +/// instead of a grid whose values sit under the wrong headers. `cells` hold display strings; sorting /// or typed comparison needs raw values the model does not carry yet. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] pub struct ResultSet { @@ -34,18 +34,20 @@ impl ResultSet { ) -> Result { let width = columns.len(); if let Some(bad) = rows.iter().find(|r| r.cells.len() != width) { - return Err(StudioError::UnexpectedColumns { - expected: format!("{width} cells per row"), - got: format!("row {} has {} cells", bad.id, bad.cells.len()), + return Err(StudioError::MalformedRows { + reason: format!( + "row {} has {} cells, expected {width}", + bad.id, + bad.cells.len() + ), }); } // Rows are Dioxus sibling keys; a duplicate panics in debug and // silently reuses the wrong node in release. let mut seen = std::collections::HashSet::with_capacity(rows.len()); if let Some(dup) = rows.iter().find(|r| !seen.insert(r.id.as_str())) { - return Err(StudioError::UnexpectedColumns { - expected: "unique row ids".into(), - got: format!("duplicate row id {}", dup.id), + return Err(StudioError::MalformedRows { + reason: format!("duplicate row id {}", dup.id), }); } Ok(Self { @@ -112,7 +114,7 @@ mod tests { 0, String::new(), ); - assert!(matches!(rs, Err(StudioError::UnexpectedColumns { .. }))); + assert!(matches!(rs, Err(StudioError::MalformedRows { .. }))); } #[test] @@ -123,7 +125,24 @@ mod tests { 0, String::new(), ); - assert!(matches!(rs, Err(StudioError::UnexpectedColumns { .. }))); + assert!(matches!(rs, Err(StudioError::MalformedRows { .. }))); + } + + /// The message names the rows, not the columns, so the user looks at + /// the right fault. + #[test] + fn rejection_message_names_the_offending_row() { + let err = ResultSet::new( + vec!["a".into(), "b".into()], + vec![row("r2", &["only-one"])], + 0, + String::new(), + ) + .expect_err("ragged row"); + assert_eq!( + err.to_string(), + "result rows do not match their header: row r2 has 1 cells, expected 2" + ); } #[test] @@ -134,6 +153,6 @@ mod tests { 0, String::new(), ); - assert!(matches!(rs, Err(StudioError::UnexpectedColumns { .. }))); + assert!(matches!(rs, Err(StudioError::MalformedRows { .. }))); } } diff --git a/nodedb-studio/src/services/decode.rs b/nodedb-studio/src/services/decode.rs index bbd3de1..5120b71 100644 --- a/nodedb-studio/src/services/decode.rs +++ b/nodedb-studio/src/services/decode.rs @@ -41,9 +41,12 @@ impl<'a> Row<'a> { self.cells .get(idx) .map(String::as_str) - .ok_or_else(|| StudioError::UnexpectedColumns { - expected: format!("row to have at least {} cells", idx + 1), - got: format!("row has {} cells", self.cells.len()), + .ok_or_else(|| StudioError::MalformedRows { + reason: format!( + "row has {} cells, column {name} is cell {}", + self.cells.len(), + idx + 1 + ), }) } } @@ -151,8 +154,8 @@ mod tests { Ok(r.field("id")?.to_string()) }); assert!( - matches!(out, Err(StudioError::UnexpectedColumns { .. })), - "expected UnexpectedColumns for short row, got {out:?}" + matches!(out, Err(StudioError::MalformedRows { .. })), + "expected MalformedRows for short row, got {out:?}" ); } } diff --git a/nodedb-studio/src/services/error.rs b/nodedb-studio/src/services/error.rs index 4d246c6..fa6b152 100644 --- a/nodedb-studio/src/services/error.rs +++ b/nodedb-studio/src/services/error.rs @@ -34,8 +34,12 @@ pub enum StudioError { /// server answered a `SHOW` with its session-variable fallback /// (`cols=["setting"]`), which would otherwise read as an empty screen. #[error("unexpected result columns: expected [{expected}], got [{got}]")] - #[allow(dead_code)] // SEAM-UNWIRED UnexpectedColumns { expected: String, got: String }, + /// A result's rows disagree with its header: a row whose width differs + /// from the column count, or two rows sharing a key. Rendered as-is, the + /// values would sit under the wrong headings or reuse the wrong row node. + #[error("result rows do not match their header: {reason}")] + MalformedRows { reason: String }, /// Connect was attempted without an explicit username. #[error("a username is required to connect")] MissingUsername, @@ -48,6 +52,7 @@ impl StudioError { match self { StudioError::NotConnected | StudioError::UnexpectedColumns { .. } + | StudioError::MalformedRows { .. } | StudioError::MissingUsername => false, StudioError::Connection(e) | StudioError::Auth(e)