diff --git a/nodedb-studio/assets/styles.css b/nodedb-studio/assets/styles.css index 567d392..75ebd8d 100644 --- a/nodedb-studio/assets/styles.css +++ b/nodedb-studio/assets/styles.css @@ -1832,3 +1832,237 @@ 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); + } + + /* 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; } + + /* 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/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/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/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 7503d1b..520ea7f 100644 --- a/nodedb-studio/src/components/mod.rs +++ b/nodedb-studio/src/components/mod.rs @@ -2,12 +2,15 @@ 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; 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/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/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/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}" + ); + } +} 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/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..105938e 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; @@ -18,8 +22,113 @@ 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 {} } + }, + 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}"); + } + } + + /// 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); + 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/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/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..39a2bf5 100644 --- a/nodedb-studio/src/models/workbench.rs +++ b/nodedb-studio/src/models/workbench.rs @@ -3,19 +3,70 @@ 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 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 { - 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::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::MalformedRows { + reason: 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 +83,76 @@ 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::MalformedRows { .. }))); + } + + #[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::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] + 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::MalformedRows { .. }))); + } +} 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/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/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) 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/state/ui.rs b/nodedb-studio/src/state/ui.rs index 73e1f80..a3332fa 100644 --- a/nodedb-studio/src/state/ui.rs +++ b/nodedb-studio/src/state/ui.rs @@ -13,10 +13,58 @@ 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 +/// selects one until the Explorer's master-detail lands. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModalKind { NewConnection, - Preferences, + Preferences(PrefsPane), + 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/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" } } diff --git a/nodedb-studio/src/views/explorer/view.rs b/nodedb-studio/src/views/explorer/view.rs index 70a7efa..e8992af 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", @@ -128,6 +156,25 @@ mod tests { }] } + /// 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()); + } + /// 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. 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}"); + } +}