Refactor: split ImportWizard into pure ImportPipeline and UI wrapper
ImportPipeline holds all data and logic (raw JSON, records, proposals, model_name, build_model). ImportWizard wraps it with UI-only state (step, cursor, message). Rename WizardState → WizardStep throughout. Headless import in dispatch.rs now constructs ImportPipeline directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@ -196,20 +196,17 @@ fn import_json_headless(
|
|||||||
|
|
||||||
let proposals = analyze_records(&records);
|
let proposals = analyze_records(&records);
|
||||||
|
|
||||||
// Auto-accept all and build
|
// Auto-accept all and build via ImportPipeline
|
||||||
let wizard = crate::import::wizard::ImportWizard {
|
let pipeline = crate::import::wizard::ImportPipeline {
|
||||||
state: crate::import::wizard::WizardState::NameModel,
|
|
||||||
raw: value,
|
raw: value,
|
||||||
array_paths: vec![],
|
array_paths: vec![],
|
||||||
selected_path: array_path.unwrap_or("").to_string(),
|
selected_path: array_path.unwrap_or("").to_string(),
|
||||||
records,
|
records,
|
||||||
proposals: proposals.into_iter().map(|mut p| { p.accepted = p.kind != FieldKind::Label; p }).collect(),
|
proposals: proposals.into_iter().map(|mut p| { p.accepted = p.kind != FieldKind::Label; p }).collect(),
|
||||||
model_name: model_name.unwrap_or("Imported Model").to_string(),
|
model_name: model_name.unwrap_or("Imported Model").to_string(),
|
||||||
cursor: 0,
|
|
||||||
message: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
match wizard.build_model() {
|
match pipeline.build_model() {
|
||||||
Ok(new_model) => {
|
Ok(new_model) => {
|
||||||
*model = new_model;
|
*model = new_model;
|
||||||
CommandResult::ok_msg("JSON imported successfully")
|
CommandResult::ok_msg("JSON imported successfully")
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
pub mod wizard;
|
pub mod wizard;
|
||||||
pub mod analyzer;
|
pub mod analyzer;
|
||||||
|
|
||||||
pub use wizard::{ImportWizard, WizardState};
|
pub use wizard::{ImportWizard, ImportPipeline, WizardStep};
|
||||||
pub use analyzer::{FieldKind, FieldProposal, analyze_records};
|
pub use analyzer::{FieldKind, FieldProposal, analyze_records};
|
||||||
|
|||||||
@ -5,70 +5,44 @@ use super::analyzer::{FieldKind, FieldProposal, analyze_records, extract_array_a
|
|||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
// ── Pipeline (no UI state) ────────────────────────────────────────────────────
|
||||||
pub enum WizardState {
|
|
||||||
Preview,
|
|
||||||
SelectArrayPath,
|
|
||||||
ReviewProposals,
|
|
||||||
NameModel,
|
|
||||||
Done,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// Pure data + logic for turning a JSON value into a Model.
|
||||||
|
/// No cursor, no display messages — those live in [`ImportWizard`].
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ImportWizard {
|
pub struct ImportPipeline {
|
||||||
pub state: WizardState,
|
|
||||||
pub raw: Value,
|
pub raw: Value,
|
||||||
pub array_paths: Vec<String>,
|
pub array_paths: Vec<String>,
|
||||||
pub selected_path: String,
|
pub selected_path: String,
|
||||||
pub records: Vec<Value>,
|
pub records: Vec<Value>,
|
||||||
pub proposals: Vec<FieldProposal>,
|
pub proposals: Vec<FieldProposal>,
|
||||||
pub model_name: String,
|
pub model_name: String,
|
||||||
pub cursor: usize,
|
|
||||||
/// Message to display
|
|
||||||
pub message: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ImportWizard {
|
impl ImportPipeline {
|
||||||
pub fn new(raw: Value) -> Self {
|
pub fn new(raw: Value) -> Self {
|
||||||
let array_paths = find_array_paths(&raw);
|
let array_paths = find_array_paths(&raw);
|
||||||
let state = if raw.is_array() {
|
let mut pipeline = Self {
|
||||||
WizardState::ReviewProposals
|
|
||||||
} else if array_paths.len() == 1 {
|
|
||||||
WizardState::ReviewProposals
|
|
||||||
} else {
|
|
||||||
WizardState::SelectArrayPath
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut wizard = Self {
|
|
||||||
state: WizardState::Preview,
|
|
||||||
raw: raw.clone(),
|
raw: raw.clone(),
|
||||||
array_paths: array_paths.clone(),
|
array_paths,
|
||||||
selected_path: String::new(),
|
selected_path: String::new(),
|
||||||
records: vec![],
|
records: vec![],
|
||||||
proposals: vec![],
|
proposals: vec![],
|
||||||
model_name: "Imported Model".to_string(),
|
model_name: "Imported Model".to_string(),
|
||||||
cursor: 0,
|
|
||||||
message: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-select if array at root or single path
|
// Auto-select if root is an array or there is exactly one candidate path.
|
||||||
if raw.is_array() {
|
if raw.is_array() {
|
||||||
wizard.select_path("");
|
pipeline.select_path("");
|
||||||
} else if array_paths.len() == 1 {
|
} else if pipeline.array_paths.len() == 1 {
|
||||||
let path = array_paths[0].clone();
|
let path = pipeline.array_paths[0].clone();
|
||||||
wizard.select_path(&path);
|
pipeline.select_path(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
wizard.state = if wizard.records.is_empty() && raw.is_object() {
|
pipeline
|
||||||
WizardState::SelectArrayPath
|
|
||||||
} else {
|
|
||||||
wizard.advance();
|
|
||||||
return wizard;
|
|
||||||
};
|
|
||||||
wizard
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_path(&mut self, path: &str) {
|
pub fn select_path(&mut self, path: &str) {
|
||||||
self.selected_path = path.to_string();
|
self.selected_path = path.to_string();
|
||||||
if let Some(arr) = extract_array_at_path(&self.raw, path) {
|
if let Some(arr) = extract_array_at_path(&self.raw, path) {
|
||||||
self.records = arr.clone();
|
self.records = arr.clone();
|
||||||
@ -76,76 +50,31 @@ impl ImportWizard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn advance(&mut self) {
|
pub fn needs_path_selection(&self) -> bool {
|
||||||
self.state = match self.state {
|
self.records.is_empty() && self.raw.is_object() && self.array_paths.len() != 1
|
||||||
WizardState::Preview => {
|
|
||||||
if self.array_paths.len() <= 1 {
|
|
||||||
WizardState::ReviewProposals
|
|
||||||
} else {
|
|
||||||
WizardState::SelectArrayPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WizardState::SelectArrayPath => WizardState::ReviewProposals,
|
|
||||||
WizardState::ReviewProposals => WizardState::NameModel,
|
|
||||||
WizardState::NameModel => WizardState::Done,
|
|
||||||
WizardState::Done => WizardState::Done,
|
|
||||||
};
|
|
||||||
self.cursor = 0;
|
|
||||||
self.message = None;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn confirm_path(&mut self) {
|
pub fn preview_summary(&self) -> String {
|
||||||
if self.cursor < self.array_paths.len() {
|
match &self.raw {
|
||||||
let path = self.array_paths[self.cursor].clone();
|
Value::Array(arr) => format!(
|
||||||
self.select_path(&path);
|
"Array of {} records. Sample keys: {}",
|
||||||
self.advance();
|
arr.len(),
|
||||||
|
arr.first()
|
||||||
|
.and_then(|r| r.as_object())
|
||||||
|
.map(|m| m.keys().take(5).cloned().collect::<Vec<_>>().join(", "))
|
||||||
|
.unwrap_or_default()
|
||||||
|
),
|
||||||
|
Value::Object(map) => format!(
|
||||||
|
"Object with {} top-level keys: {}",
|
||||||
|
map.len(),
|
||||||
|
map.keys().take(10).cloned().collect::<Vec<_>>().join(", ")
|
||||||
|
),
|
||||||
|
_ => "Unknown JSON structure".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn toggle_proposal(&mut self) {
|
/// Build a Model from the current proposals. Pure — no side effects.
|
||||||
if self.cursor < self.proposals.len() {
|
|
||||||
self.proposals[self.cursor].accepted = !self.proposals[self.cursor].accepted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cycle_proposal_kind(&mut self) {
|
|
||||||
if self.cursor < self.proposals.len() {
|
|
||||||
let p = &mut self.proposals[self.cursor];
|
|
||||||
p.kind = match p.kind {
|
|
||||||
FieldKind::Category => FieldKind::Measure,
|
|
||||||
FieldKind::Measure => FieldKind::TimeCategory,
|
|
||||||
FieldKind::TimeCategory => FieldKind::Label,
|
|
||||||
FieldKind::Label => FieldKind::Category,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn move_cursor(&mut self, delta: i32) {
|
|
||||||
let len = match self.state {
|
|
||||||
WizardState::SelectArrayPath => self.array_paths.len(),
|
|
||||||
WizardState::ReviewProposals => self.proposals.len(),
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
if len == 0 { return; }
|
|
||||||
if delta > 0 {
|
|
||||||
self.cursor = (self.cursor + 1).min(len - 1);
|
|
||||||
} else if self.cursor > 0 {
|
|
||||||
self.cursor -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_name_char(&mut self, c: char) {
|
|
||||||
self.model_name.push(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pop_name_char(&mut self) {
|
|
||||||
self.model_name.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_model(&self) -> Result<Model> {
|
pub fn build_model(&self) -> Result<Model> {
|
||||||
let mut model = Model::new(self.model_name.clone());
|
|
||||||
|
|
||||||
// Collect categories and measures from accepted proposals
|
|
||||||
let categories: Vec<&FieldProposal> = self.proposals.iter()
|
let categories: Vec<&FieldProposal> = self.proposals.iter()
|
||||||
.filter(|p| p.accepted && matches!(p.kind, FieldKind::Category | FieldKind::TimeCategory))
|
.filter(|p| p.accepted && matches!(p.kind, FieldKind::Category | FieldKind::TimeCategory))
|
||||||
.collect();
|
.collect();
|
||||||
@ -157,7 +86,8 @@ impl ImportWizard {
|
|||||||
return Err(anyhow!("At least one category must be accepted"));
|
return Err(anyhow!("At least one category must be accepted"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add categories
|
let mut model = Model::new(&self.model_name);
|
||||||
|
|
||||||
for cat_proposal in &categories {
|
for cat_proposal in &categories {
|
||||||
model.add_category(&cat_proposal.field)?;
|
model.add_category(&cat_proposal.field)?;
|
||||||
if let Some(cat) = model.category_mut(&cat_proposal.field) {
|
if let Some(cat) = model.category_mut(&cat_proposal.field) {
|
||||||
@ -167,7 +97,6 @@ impl ImportWizard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there are measures, add a "Measure" category
|
|
||||||
if !measures.is_empty() {
|
if !measures.is_empty() {
|
||||||
model.add_category("Measure")?;
|
model.add_category("Measure")?;
|
||||||
if let Some(cat) = model.category_mut("Measure") {
|
if let Some(cat) = model.category_mut("Measure") {
|
||||||
@ -177,10 +106,8 @@ impl ImportWizard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import records as cells
|
|
||||||
for record in &self.records {
|
for record in &self.records {
|
||||||
if let Value::Object(map) = record {
|
if let Value::Object(map) = record {
|
||||||
// Build base coordinate from category fields
|
|
||||||
let mut coords: Vec<(String, String)> = vec![];
|
let mut coords: Vec<(String, String)> = vec![];
|
||||||
let mut valid = true;
|
let mut valid = true;
|
||||||
|
|
||||||
@ -191,7 +118,6 @@ impl ImportWizard {
|
|||||||
.or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string()));
|
.or_else(|| map.get(&cat_proposal.field).map(|v| v.to_string()));
|
||||||
|
|
||||||
if let Some(v) = val {
|
if let Some(v) = val {
|
||||||
// Ensure item exists
|
|
||||||
if let Some(cat) = model.category_mut(&cat_proposal.field) {
|
if let Some(cat) = model.category_mut(&cat_proposal.field) {
|
||||||
cat.add_item(&v);
|
cat.add_item(&v);
|
||||||
}
|
}
|
||||||
@ -204,15 +130,11 @@ impl ImportWizard {
|
|||||||
|
|
||||||
if !valid { continue; }
|
if !valid { continue; }
|
||||||
|
|
||||||
// Add each measure as a cell
|
|
||||||
for measure in &measures {
|
for measure in &measures {
|
||||||
if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) {
|
if let Some(val) = map.get(&measure.field).and_then(|v| v.as_f64()) {
|
||||||
let mut cell_coords = coords.clone();
|
let mut cell_coords = coords.clone();
|
||||||
if !measures.is_empty() {
|
|
||||||
cell_coords.push(("Measure".to_string(), measure.field.clone()));
|
cell_coords.push(("Measure".to_string(), measure.field.clone()));
|
||||||
}
|
model.set_cell(CellKey::new(cell_coords), CellValue::Number(val));
|
||||||
let key = CellKey::new(cell_coords);
|
|
||||||
model.set_cell(key, CellValue::Number(val));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -220,27 +142,242 @@ impl ImportWizard {
|
|||||||
|
|
||||||
Ok(model)
|
Ok(model)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn preview_summary(&self) -> String {
|
// ── Wizard (UI state wrapped around the pipeline) ─────────────────────────────
|
||||||
match &self.raw {
|
|
||||||
Value::Array(arr) => {
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
format!(
|
pub enum WizardStep {
|
||||||
"Array of {} records. Sample keys: {}",
|
Preview,
|
||||||
arr.len(),
|
SelectArrayPath,
|
||||||
arr.first()
|
ReviewProposals,
|
||||||
.and_then(|r| r.as_object())
|
NameModel,
|
||||||
.map(|m| m.keys().take(5).cloned().collect::<Vec<_>>().join(", "))
|
Done,
|
||||||
.unwrap_or_default()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Value::Object(map) => {
|
|
||||||
format!(
|
/// Interactive state layered on top of [`ImportPipeline`] for the TUI wizard.
|
||||||
"Object with {} top-level keys: {}",
|
/// The pipeline holds all data; the wizard holds only what the UI needs to
|
||||||
map.len(),
|
/// drive the multi-step interaction (current step, list cursor, error message).
|
||||||
map.keys().take(10).cloned().collect::<Vec<_>>().join(", ")
|
#[derive(Debug)]
|
||||||
)
|
pub struct ImportWizard {
|
||||||
|
pub pipeline: ImportPipeline,
|
||||||
|
pub step: WizardStep,
|
||||||
|
/// Cursor within the current list (array paths or proposals).
|
||||||
|
pub cursor: usize,
|
||||||
|
/// One-line message to display at the bottom of the wizard panel.
|
||||||
|
pub message: Option<String>,
|
||||||
}
|
}
|
||||||
_ => "Unknown JSON structure".to_string(),
|
|
||||||
|
impl ImportWizard {
|
||||||
|
pub fn new(raw: Value) -> Self {
|
||||||
|
let pipeline = ImportPipeline::new(raw);
|
||||||
|
|
||||||
|
let step = if pipeline.needs_path_selection() {
|
||||||
|
WizardStep::SelectArrayPath
|
||||||
|
} else if pipeline.records.is_empty() {
|
||||||
|
WizardStep::Preview
|
||||||
|
} else {
|
||||||
|
WizardStep::ReviewProposals
|
||||||
|
};
|
||||||
|
|
||||||
|
Self { pipeline, step, cursor: 0, message: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step transitions ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn advance(&mut self) {
|
||||||
|
self.step = match self.step {
|
||||||
|
WizardStep::Preview => {
|
||||||
|
if self.pipeline.array_paths.len() > 1 && self.pipeline.needs_path_selection() {
|
||||||
|
WizardStep::SelectArrayPath
|
||||||
|
} else {
|
||||||
|
WizardStep::ReviewProposals
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
WizardStep::SelectArrayPath => WizardStep::ReviewProposals,
|
||||||
|
WizardStep::ReviewProposals => WizardStep::NameModel,
|
||||||
|
WizardStep::NameModel => WizardStep::Done,
|
||||||
|
WizardStep::Done => WizardStep::Done,
|
||||||
|
};
|
||||||
|
self.cursor = 0;
|
||||||
|
self.message = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn confirm_path(&mut self) {
|
||||||
|
if self.cursor < self.pipeline.array_paths.len() {
|
||||||
|
let path = self.pipeline.array_paths[self.cursor].clone();
|
||||||
|
self.pipeline.select_path(&path);
|
||||||
|
self.advance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cursor movement ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn move_cursor(&mut self, delta: i32) {
|
||||||
|
let len = match self.step {
|
||||||
|
WizardStep::SelectArrayPath => self.pipeline.array_paths.len(),
|
||||||
|
WizardStep::ReviewProposals => self.pipeline.proposals.len(),
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
if len == 0 { return; }
|
||||||
|
if delta > 0 {
|
||||||
|
self.cursor = (self.cursor + 1).min(len - 1);
|
||||||
|
} else if self.cursor > 0 {
|
||||||
|
self.cursor -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Proposal editing (delegates to pipeline) ──────────────────────────────
|
||||||
|
|
||||||
|
pub fn toggle_proposal(&mut self) {
|
||||||
|
if self.cursor < self.pipeline.proposals.len() {
|
||||||
|
self.pipeline.proposals[self.cursor].accepted =
|
||||||
|
!self.pipeline.proposals[self.cursor].accepted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cycle_proposal_kind(&mut self) {
|
||||||
|
if self.cursor < self.pipeline.proposals.len() {
|
||||||
|
let p = &mut self.pipeline.proposals[self.cursor];
|
||||||
|
p.kind = match p.kind {
|
||||||
|
FieldKind::Category => FieldKind::Measure,
|
||||||
|
FieldKind::Measure => FieldKind::TimeCategory,
|
||||||
|
FieldKind::TimeCategory => FieldKind::Label,
|
||||||
|
FieldKind::Label => FieldKind::Category,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Model name input ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn push_name_char(&mut self, c: char) { self.pipeline.model_name.push(c); }
|
||||||
|
pub fn pop_name_char(&mut self) { self.pipeline.model_name.pop(); }
|
||||||
|
|
||||||
|
// ── Delegate build to pipeline ────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn build_model(&self) -> Result<Model> {
|
||||||
|
self.pipeline.build_model()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use serde_json::json;
|
||||||
|
use super::ImportPipeline;
|
||||||
|
use crate::import::analyzer::FieldKind;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flat_array_auto_selected() {
|
||||||
|
let raw = json!([
|
||||||
|
{"region": "East", "product": "Shirts", "revenue": 100.0},
|
||||||
|
{"region": "West", "product": "Shirts", "revenue": 200.0},
|
||||||
|
]);
|
||||||
|
let p = ImportPipeline::new(raw);
|
||||||
|
assert!(!p.records.is_empty());
|
||||||
|
assert!(!p.proposals.is_empty());
|
||||||
|
assert!(!p.needs_path_selection());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numeric_field_proposed_as_measure() {
|
||||||
|
let raw = json!([{"region": "East", "revenue": 100.0, "cost": 50.0}]);
|
||||||
|
let p = ImportPipeline::new(raw);
|
||||||
|
let revenue = p.proposals.iter().find(|p| p.field == "revenue").unwrap();
|
||||||
|
assert_eq!(revenue.kind, FieldKind::Measure);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn low_cardinality_string_field_proposed_as_category() {
|
||||||
|
let raw = json!([
|
||||||
|
{"region": "East", "revenue": 100.0},
|
||||||
|
{"region": "West", "revenue": 200.0},
|
||||||
|
{"region": "East", "revenue": 150.0},
|
||||||
|
]);
|
||||||
|
let p = ImportPipeline::new(raw);
|
||||||
|
let region = p.proposals.iter().find(|p| p.field == "region").unwrap();
|
||||||
|
assert_eq!(region.kind, FieldKind::Category);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_json_needs_path_selection_when_multiple_arrays() {
|
||||||
|
let raw = json!({
|
||||||
|
"orders": [{"id": 1}, {"id": 2}],
|
||||||
|
"products": [{"name": "A"}, {"name": "B"}],
|
||||||
|
});
|
||||||
|
let p = ImportPipeline::new(raw);
|
||||||
|
assert!(p.needs_path_selection());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_nested_array_auto_selected() {
|
||||||
|
let raw = json!({
|
||||||
|
"data": [
|
||||||
|
{"region": "East", "revenue": 100.0},
|
||||||
|
{"region": "West", "revenue": 200.0},
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let p = ImportPipeline::new(raw);
|
||||||
|
assert!(!p.records.is_empty());
|
||||||
|
assert!(!p.needs_path_selection());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_path_populates_records_and_proposals() {
|
||||||
|
let raw = json!({
|
||||||
|
"a": [{"x": 1}, {"x": 2}],
|
||||||
|
"b": [{"y": "foo"}, {"y": "bar"}],
|
||||||
|
});
|
||||||
|
let mut p = ImportPipeline::new(raw);
|
||||||
|
p.select_path("b");
|
||||||
|
assert!(!p.records.is_empty());
|
||||||
|
assert!(!p.proposals.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_model_fails_with_no_accepted_categories() {
|
||||||
|
let raw = json!([{"revenue": 100.0, "cost": 50.0}]);
|
||||||
|
let mut p = ImportPipeline::new(raw);
|
||||||
|
for prop in &mut p.proposals { prop.accepted = false; }
|
||||||
|
assert!(p.build_model().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_model_creates_categories_and_measure_category() {
|
||||||
|
let raw = json!([
|
||||||
|
{"region": "East", "revenue": 100.0},
|
||||||
|
{"region": "West", "revenue": 200.0},
|
||||||
|
]);
|
||||||
|
let p = ImportPipeline::new(raw);
|
||||||
|
let model = p.build_model().unwrap();
|
||||||
|
assert!(model.category("region").is_some());
|
||||||
|
assert!(model.category("Measure").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_model_cells_match_source_data() {
|
||||||
|
let raw = json!([
|
||||||
|
{"region": "East", "revenue": 100.0},
|
||||||
|
{"region": "West", "revenue": 200.0},
|
||||||
|
]);
|
||||||
|
let p = ImportPipeline::new(raw);
|
||||||
|
let model = p.build_model().unwrap();
|
||||||
|
use crate::model::cell::CellKey;
|
||||||
|
let k_east = CellKey::new(vec![
|
||||||
|
("Measure".to_string(), "revenue".to_string()),
|
||||||
|
("region".to_string(), "East".to_string()),
|
||||||
|
]);
|
||||||
|
let k_west = CellKey::new(vec![
|
||||||
|
("Measure".to_string(), "revenue".to_string()),
|
||||||
|
("region".to_string(), "West".to_string()),
|
||||||
|
]);
|
||||||
|
assert_eq!(model.get_cell(&k_east).as_f64(), Some(100.0));
|
||||||
|
assert_eq!(model.get_cell(&k_west).as_f64(), Some(200.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_name_defaults_to_imported_model() {
|
||||||
|
let raw = json!([{"x": 1.0}]);
|
||||||
|
let p = ImportPipeline::new(raw);
|
||||||
|
assert_eq!(p.model_name, "Imported Model");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|||||||
use crate::model::Model;
|
use crate::model::Model;
|
||||||
use crate::model::cell::{CellKey, CellValue};
|
use crate::model::cell::{CellKey, CellValue};
|
||||||
use crate::formula::parse_formula;
|
use crate::formula::parse_formula;
|
||||||
use crate::import::wizard::{ImportWizard, WizardState};
|
use crate::import::wizard::{ImportWizard, WizardStep};
|
||||||
use crate::persistence;
|
use crate::persistence;
|
||||||
use crate::view::Axis;
|
use crate::view::Axis;
|
||||||
use crate::command::{self, Command};
|
use crate::command::{self, Command};
|
||||||
@ -846,20 +846,20 @@ impl App {
|
|||||||
|
|
||||||
fn handle_wizard_key(&mut self, key: KeyEvent) -> Result<()> {
|
fn handle_wizard_key(&mut self, key: KeyEvent) -> Result<()> {
|
||||||
if let Some(wizard) = &mut self.wizard {
|
if let Some(wizard) = &mut self.wizard {
|
||||||
match &wizard.state.clone() {
|
match &wizard.step.clone() {
|
||||||
WizardState::Preview => match key.code {
|
WizardStep::Preview => match key.code {
|
||||||
KeyCode::Enter | KeyCode::Char(' ') => wizard.advance(),
|
KeyCode::Enter | KeyCode::Char(' ') => wizard.advance(),
|
||||||
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
|
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
WizardState::SelectArrayPath => match key.code {
|
WizardStep::SelectArrayPath => match key.code {
|
||||||
KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1),
|
KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1),
|
||||||
KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1),
|
KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1),
|
||||||
KeyCode::Enter => wizard.confirm_path(),
|
KeyCode::Enter => wizard.confirm_path(),
|
||||||
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
|
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
WizardState::ReviewProposals => match key.code {
|
WizardStep::ReviewProposals => match key.code {
|
||||||
KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1),
|
KeyCode::Up | KeyCode::Char('k') => wizard.move_cursor(-1),
|
||||||
KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1),
|
KeyCode::Down | KeyCode::Char('j') => wizard.move_cursor(1),
|
||||||
KeyCode::Char(' ') => wizard.toggle_proposal(),
|
KeyCode::Char(' ') => wizard.toggle_proposal(),
|
||||||
@ -868,7 +868,7 @@ impl App {
|
|||||||
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
|
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
WizardState::NameModel => match key.code {
|
WizardStep::NameModel => match key.code {
|
||||||
KeyCode::Char(c) => wizard.push_name_char(c),
|
KeyCode::Char(c) => wizard.push_name_char(c),
|
||||||
KeyCode::Backspace => wizard.pop_name_char(),
|
KeyCode::Backspace => wizard.pop_name_char(),
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
@ -890,7 +890,7 @@ impl App {
|
|||||||
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
|
KeyCode::Esc => { self.mode = AppMode::Normal; self.wizard = None; }
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
WizardState::Done => { self.mode = AppMode::Normal; self.wizard = None; }
|
WizardStep::Done => { self.mode = AppMode::Normal; self.wizard = None; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@ -5,7 +5,7 @@ use ratatui::{
|
|||||||
widgets::{Block, Borders, Clear, Widget},
|
widgets::{Block, Borders, Clear, Widget},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::import::wizard::{ImportWizard, WizardState};
|
use crate::import::wizard::{ImportWizard, WizardStep};
|
||||||
use crate::import::analyzer::FieldKind;
|
use crate::import::analyzer::FieldKind;
|
||||||
|
|
||||||
pub struct ImportWizardWidget<'a> {
|
pub struct ImportWizardWidget<'a> {
|
||||||
@ -28,12 +28,12 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
|||||||
|
|
||||||
Clear.render(popup_area, buf);
|
Clear.render(popup_area, buf);
|
||||||
|
|
||||||
let title = match self.wizard.state {
|
let title = match self.wizard.step {
|
||||||
WizardState::Preview => " Import Wizard — Step 1: Preview ",
|
WizardStep::Preview => " Import Wizard — Step 1: Preview ",
|
||||||
WizardState::SelectArrayPath => " Import Wizard — Step 2: Select Array ",
|
WizardStep::SelectArrayPath => " Import Wizard — Step 2: Select Array ",
|
||||||
WizardState::ReviewProposals => " Import Wizard — Step 3: Review Fields ",
|
WizardStep::ReviewProposals => " Import Wizard — Step 3: Review Fields ",
|
||||||
WizardState::NameModel => " Import Wizard — Step 4: Name Model ",
|
WizardStep::NameModel => " Import Wizard — Step 4: Name Model ",
|
||||||
WizardState::Done => " Import Wizard — Done ",
|
WizardStep::Done => " Import Wizard — Done ",
|
||||||
};
|
};
|
||||||
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
@ -47,21 +47,21 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
|||||||
let x = inner.x;
|
let x = inner.x;
|
||||||
let w = inner.width as usize;
|
let w = inner.width as usize;
|
||||||
|
|
||||||
match &self.wizard.state {
|
match &self.wizard.step {
|
||||||
WizardState::Preview => {
|
WizardStep::Preview => {
|
||||||
let summary = self.wizard.preview_summary();
|
let summary = self.wizard.pipeline.preview_summary();
|
||||||
buf.set_string(x, y, truncate(&summary, w), Style::default());
|
buf.set_string(x, y, truncate(&summary, w), Style::default());
|
||||||
y += 2;
|
y += 2;
|
||||||
buf.set_string(x, y,
|
buf.set_string(x, y,
|
||||||
"Press Enter to continue…",
|
"Press Enter to continue\u{2026}",
|
||||||
Style::default().fg(Color::Yellow));
|
Style::default().fg(Color::Yellow));
|
||||||
}
|
}
|
||||||
WizardState::SelectArrayPath => {
|
WizardStep::SelectArrayPath => {
|
||||||
buf.set_string(x, y,
|
buf.set_string(x, y,
|
||||||
"Select the path containing records:",
|
"Select the path containing records:",
|
||||||
Style::default().fg(Color::Yellow));
|
Style::default().fg(Color::Yellow));
|
||||||
y += 1;
|
y += 1;
|
||||||
for (i, path) in self.wizard.array_paths.iter().enumerate() {
|
for (i, path) in self.wizard.pipeline.array_paths.iter().enumerate() {
|
||||||
if y >= inner.y + inner.height { break; }
|
if y >= inner.y + inner.height { break; }
|
||||||
let is_sel = i == self.wizard.cursor;
|
let is_sel = i == self.wizard.cursor;
|
||||||
let style = if is_sel {
|
let style = if is_sel {
|
||||||
@ -74,9 +74,9 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
|||||||
y += 1;
|
y += 1;
|
||||||
}
|
}
|
||||||
y += 1;
|
y += 1;
|
||||||
buf.set_string(x, y, "↑↓ select Enter confirm", Style::default().fg(Color::DarkGray));
|
buf.set_string(x, y, "\u{2191}\u{2193} select Enter confirm", Style::default().fg(Color::DarkGray));
|
||||||
}
|
}
|
||||||
WizardState::ReviewProposals => {
|
WizardStep::ReviewProposals => {
|
||||||
buf.set_string(x, y,
|
buf.set_string(x, y,
|
||||||
"Review field proposals (Space toggle, c cycle kind):",
|
"Review field proposals (Space toggle, c cycle kind):",
|
||||||
Style::default().fg(Color::Yellow));
|
Style::default().fg(Color::Yellow));
|
||||||
@ -85,7 +85,7 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
|||||||
buf.set_string(x, y, truncate(&header, w), Style::default().fg(Color::Gray).add_modifier(Modifier::UNDERLINED));
|
buf.set_string(x, y, truncate(&header, w), Style::default().fg(Color::Gray).add_modifier(Modifier::UNDERLINED));
|
||||||
y += 1;
|
y += 1;
|
||||||
|
|
||||||
for (i, proposal) in self.wizard.proposals.iter().enumerate() {
|
for (i, proposal) in self.wizard.pipeline.proposals.iter().enumerate() {
|
||||||
if y >= inner.y + inner.height - 2 { break; }
|
if y >= inner.y + inner.height - 2 { break; }
|
||||||
let is_sel = i == self.wizard.cursor;
|
let is_sel = i == self.wizard.cursor;
|
||||||
|
|
||||||
@ -96,7 +96,7 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
|||||||
FieldKind::Label => Color::DarkGray,
|
FieldKind::Label => Color::DarkGray,
|
||||||
};
|
};
|
||||||
|
|
||||||
let accept_str = if proposal.accepted { "[✓]" } else { "[ ]" };
|
let accept_str = if proposal.accepted { "[\u{2713}]" } else { "[ ]" };
|
||||||
let row = format!(" {:<20} {:<22} {}",
|
let row = format!(" {:<20} {:<22} {}",
|
||||||
truncate(&proposal.field, 20),
|
truncate(&proposal.field, 20),
|
||||||
truncate(proposal.kind_label(), 22),
|
truncate(proposal.kind_label(), 22),
|
||||||
@ -117,10 +117,10 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
|||||||
buf.set_string(x, hint_y, "Enter: next Space: toggle c: cycle kind Esc: cancel",
|
buf.set_string(x, hint_y, "Enter: next Space: toggle c: cycle kind Esc: cancel",
|
||||||
Style::default().fg(Color::DarkGray));
|
Style::default().fg(Color::DarkGray));
|
||||||
}
|
}
|
||||||
WizardState::NameModel => {
|
WizardStep::NameModel => {
|
||||||
buf.set_string(x, y, "Model name:", Style::default().fg(Color::Yellow));
|
buf.set_string(x, y, "Model name:", Style::default().fg(Color::Yellow));
|
||||||
y += 1;
|
y += 1;
|
||||||
let name_str = format!("> {}█", self.wizard.model_name);
|
let name_str = format!("> {}\u{2588}", self.wizard.pipeline.model_name);
|
||||||
buf.set_string(x, y, truncate(&name_str, w),
|
buf.set_string(x, y, truncate(&name_str, w),
|
||||||
Style::default().fg(Color::Green));
|
Style::default().fg(Color::Green));
|
||||||
y += 2;
|
y += 2;
|
||||||
@ -133,7 +133,7 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
|||||||
Style::default().fg(Color::Red));
|
Style::default().fg(Color::Red));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WizardState::Done => {
|
WizardStep::Done => {
|
||||||
buf.set_string(x, y, "Import complete!", Style::default().fg(Color::Green));
|
buf.set_string(x, y, "Import complete!", Style::default().fg(Color::Green));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -142,6 +142,6 @@ impl<'a> Widget for ImportWizardWidget<'a> {
|
|||||||
|
|
||||||
fn truncate(s: &str, max: usize) -> String {
|
fn truncate(s: &str, max: usize) -> String {
|
||||||
if s.len() <= max { s.to_string() }
|
if s.len() <= max { s.to_string() }
|
||||||
else if max > 1 { format!("{}…", &s[..max-1]) }
|
else if max > 1 { format!("{}\u{2026}", &s[..max-1]) }
|
||||||
else { s[..max].to_string() }
|
else { s[..max].to_string() }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user