diff --git a/client/src/components/common/autocomplete.rs b/client/src/components/common/autocomplete.rs index 30ff537..b9cf65c 100644 --- a/client/src/components/common/autocomplete.rs +++ b/client/src/components/common/autocomplete.rs @@ -1,30 +1,18 @@ // src/components/common/autocomplete.rs -use common::proto::multieko2::search::search_response::Hit; use crate::config::colors::themes::Theme; +use crate::state::pages::form::FormState; +use common::proto::multieko2::search::search_response::Hit; use ratatui::{ layout::Rect, style::{Color, Modifier, Style}, widgets::{Block, List, ListItem, ListState}, Frame, }; -use std::collections::HashMap; use unicode_width::UnicodeWidthStr; -/// Converts a serde_json::Value into a displayable String. -/// Handles String, Number, and Bool variants. Returns an empty string for Null and others. -fn json_value_to_string(value: &serde_json::Value) -> String { - match value { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Bool(b) => b.to_string(), - // Return an empty string for Null, Array, or Object so we can filter them out. - _ => String::new(), - } -} - /// Renders an opaque dropdown list for simple string-based suggestions. -/// This function remains unchanged. +/// THIS IS THE RESTORED FUNCTION. pub fn render_autocomplete_dropdown( f: &mut Frame, input_rect: Rect, @@ -84,22 +72,22 @@ pub fn render_autocomplete_dropdown( .collect(); let list = List::new(items); - let mut profile_list_state = ListState::default(); - profile_list_state.select(selected_index); + let mut list_state = ListState::default(); + list_state.select(selected_index); - f.render_stateful_widget(list, dropdown_area, &mut profile_list_state); + f.render_stateful_widget(list, dropdown_area, &mut list_state); } -// --- MODIFIED FUNCTION FOR RICH SUGGESTIONS --- /// Renders an opaque dropdown list for rich `Hit`-based suggestions. -/// Displays the value of the first meaningful column, followed by the Hit ID. -pub fn render_rich_autocomplete_dropdown( +/// RENAMED from render_rich_autocomplete_dropdown +pub fn render_hit_autocomplete_dropdown( f: &mut Frame, input_rect: Rect, frame_area: Rect, theme: &Theme, suggestions: &[Hit], selected_index: Option, + form_state: &FormState, ) { if suggestions.is_empty() { return; @@ -107,50 +95,9 @@ pub fn render_rich_autocomplete_dropdown( let display_names: Vec = suggestions .iter() - .map(|hit| { - // Use serde_json::Value to handle mixed types (string, null, etc.) - if let Ok(content_map) = - serde_json::from_str::>( - &hit.content_json, - ) - { - // Define keys to ignore for a cleaner display - const IGNORED_KEYS: &[&str] = &["id", "deleted", "created_at"]; - - // Get keys, filter out ignored ones, and sort for consistency - let mut keys: Vec<_> = content_map - .keys() - .filter(|k| !IGNORED_KEYS.contains(&k.as_str())) - .cloned() - .collect(); - keys.sort(); - - // Get only the first non-empty value from the sorted keys - let values: Vec<_> = keys - .iter() - .map(|key| { - content_map - .get(key) - .map(json_value_to_string) - .unwrap_or_default() - }) - .filter(|s| !s.is_empty()) // Filter out null/empty values - .take(1) // Changed from take(2) to take(1) - .collect(); - - let display_part = values.first().cloned().unwrap_or_default(); // Get the first value - if display_part.is_empty() { - format!("{}", hit.id) - } else { - format!("{} | {}", display_part, hit.id) // ID at the end - } - } else { - format!("{} (parse error)", hit.id) - } - }) + .map(|hit| form_state.get_display_name_for_hit(hit)) .collect(); - // --- Calculate Dropdown Size & Position --- let max_suggestion_width = display_names.iter().map(|s| s.width()).max().unwrap_or(0) as u16; let horizontal_padding: u16 = 2; @@ -164,7 +111,6 @@ pub fn render_rich_autocomplete_dropdown( height: dropdown_height, }; - // --- Clamping Logic --- if dropdown_area.bottom() > frame_area.height { dropdown_area.y = input_rect.y.saturating_sub(dropdown_height); } @@ -174,7 +120,6 @@ pub fn render_rich_autocomplete_dropdown( dropdown_area.x = dropdown_area.x.max(0); dropdown_area.y = dropdown_area.y.max(0); - // --- Rendering Logic --- let background_block = Block::default().style(Style::default().bg(Color::DarkGray)); f.render_widget(background_block, dropdown_area); diff --git a/client/src/components/form/form.rs b/client/src/components/form/form.rs index ebf8c7f..78f4d73 100644 --- a/client/src/components/form/form.rs +++ b/client/src/components/form/form.rs @@ -78,25 +78,25 @@ pub fn render_form( // --- NEW: RENDER AUTOCOMPLETE --- if form_state.autocomplete_active { - // Use the Rect of the active field that render_canvas found for us. if let Some(active_rect) = active_field_rect { let selected_index = form_state.get_selected_suggestion_index(); - // THE DECIDER LOGIC: - // 1. Check for rich suggestions first. if let Some(rich_suggestions) = form_state.get_rich_suggestions() { if !rich_suggestions.is_empty() { - autocomplete::render_rich_autocomplete_dropdown( + // CHANGE THIS to call the renamed function + autocomplete::render_hit_autocomplete_dropdown( f, active_rect, - f.area(), // Use f.area() for clamping, not f.size() + f.area(), theme, rich_suggestions, selected_index, + form_state, ); } } - // 2. Fallback to simple suggestions if rich ones aren't available. + // The fallback to simple suggestions is now correctly handled + // because the original render_autocomplete_dropdown exists again. else if let Some(simple_suggestions) = form_state.get_suggestions() { if !simple_suggestions.is_empty() { autocomplete::render_autocomplete_dropdown( @@ -112,3 +112,4 @@ pub fn render_form( } } } + diff --git a/client/src/services/ui_service.rs b/client/src/services/ui_service.rs index bfb9f04..84a6e4a 100644 --- a/client/src/services/ui_service.rs +++ b/client/src/services/ui_service.rs @@ -1,17 +1,100 @@ // src/services/ui_service.rs use crate::services::grpc_client::GrpcClient; -use crate::state::pages::form::FormState; -use crate::tui::functions::common::form::SaveOutcome; -use crate::state::pages::add_logic::AddLogicState; use crate::state::app::state::AppState; +use crate::state::pages::add_logic::AddLogicState; +use crate::state::pages::form::{FieldDefinition, FormState}; +use crate::tui::functions::common::form::SaveOutcome; use crate::utils::columns::filter_user_columns; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use std::sync::Arc; pub struct UiService; impl UiService { + pub async fn load_table_view( + grpc_client: &mut GrpcClient, + app_state: &mut AppState, + profile_name: &str, + table_name: &str, + ) -> Result { + // 1. & 2. Fetch and Cache Schema - UNCHANGED + let table_structure = grpc_client + .get_table_structure(profile_name.to_string(), table_name.to_string()) + .await + .context(format!( + "Failed to get table structure for {}.{}", + profile_name, table_name + ))?; + let cache_key = format!("{}.{}", profile_name, table_name); + app_state + .schema_cache + .insert(cache_key, Arc::new(table_structure.clone())); + tracing::info!("Schema for '{}.{}' cached.", profile_name, table_name); + + // --- START: FINAL, SIMPLIFIED, CORRECT LOGIC --- + + // 3a. Create definitions for REGULAR fields first. + let mut fields: Vec = table_structure + .columns + .iter() + .filter(|col| { + !col.is_primary_key + && col.name != "deleted" + && col.name != "created_at" + && !col.name.ends_with("_id") // Filter out ALL potential links + }) + .map(|col| FieldDefinition { + display_name: col.name.clone(), + data_key: col.name.clone(), + is_link: false, + link_target_table: None, + }) + .collect(); + + // 3b. Now, find and APPEND definitions for LINK fields based on the `_id` convention. + let link_fields: Vec = table_structure + .columns + .iter() + .filter(|col| col.name.ends_with("_id")) // Find all foreign key columns + .map(|col| { + // The table we link to is derived from the column name. + // e.g., "test_diacritics_id" -> "test_diacritics" + let target_table_base = col + .name + .strip_suffix("_id") + .unwrap_or(&col.name); + + // Find the full table name from the profile tree for display. + // e.g., "test_diacritics" -> "2025_test_diacritics" + let full_target_table_name = app_state + .profile_tree + .profiles + .iter() + .find(|p| p.name == profile_name) + .and_then(|p| p.tables.iter().find(|t| t.name.ends_with(target_table_base))) + .map_or(target_table_base.to_string(), |t| t.name.clone()); + + FieldDefinition { + display_name: full_target_table_name.clone(), + data_key: col.name.clone(), // The actual FK column name + is_link: true, + link_target_table: Some(full_target_table_name), + } + }) + .collect(); + + fields.extend(link_fields); // Append the link fields to the end + + // --- END: FINAL, SIMPLIFIED, CORRECT LOGIC --- + + Ok(FormState::new( + profile_name.to_string(), + table_name.to_string(), + fields, + )) + } + pub async fn initialize_add_logic_table_data( grpc_client: &mut GrpcClient, add_logic_state: &mut AddLogicState, @@ -93,6 +176,7 @@ impl UiService { } } + // REFACTOR THIS FUNCTION pub async fn initialize_app_state_and_form( grpc_client: &mut GrpcClient, app_state: &mut AppState, @@ -122,35 +206,19 @@ impl UiService { initial_table_name.clone(), ); - let table_structure = grpc_client - .get_table_structure( - initial_profile_name.clone(), - initial_table_name.clone(), - ) - .await - .context(format!( - "Failed to get initial table structure for {}.{}", - initial_profile_name, initial_table_name - ))?; + // NOW, just call our new central function. This avoids code duplication. + let form_state = Self::load_table_view( + grpc_client, + app_state, + &initial_profile_name, + &initial_table_name, + ) + .await?; - // NEW: Populate the "Rulebook" cache - let cache_key = format!( - "{}.{}", - initial_profile_name, initial_table_name - ); - app_state - .schema_cache - .insert(cache_key, Arc::new(table_structure.clone())); + // The field names for the UI are derived from the new form_state + let field_names = form_state.fields.iter().map(|f| f.display_name.clone()).collect(); - let column_names: Vec = table_structure - .columns - .iter() - .map(|col| col.name.clone()) - .collect(); - - let filtered_columns = filter_user_columns(column_names); - - Ok((initial_profile_name, initial_table_name, filtered_columns)) + Ok((initial_profile_name, initial_table_name, field_names)) } pub async fn fetch_and_set_table_count( diff --git a/client/src/ui/handlers/ui.rs b/client/src/ui/handlers/ui.rs index 770d393..30b1c60 100644 --- a/client/src/ui/handlers/ui.rs +++ b/client/src/ui/handlers/ui.rs @@ -350,123 +350,91 @@ pub async fn run_ui() -> Result<()> { let current_view_profile = app_state.current_view_profile_name.clone(); let current_view_table = app_state.current_view_table_name.clone(); + // This condition correctly detects a table switch. if prev_view_profile_name != current_view_profile || prev_view_table_name != current_view_table { if let (Some(prof_name), Some(tbl_name)) = (current_view_profile.as_ref(), current_view_table.as_ref()) { + // --- START OF REFACTORED LOGIC --- app_state.show_loading_dialog( "Loading Table", &format!("Fetching data for {}.{}...", prof_name, tbl_name), ); needs_redraw = true; - match grpc_client - .get_table_structure(prof_name.clone(), tbl_name.clone()) - .await + // 1. Call our new, central function. It handles fetching AND caching. + match UiService::load_table_view( + &mut grpc_client, + &mut app_state, + prof_name, + tbl_name, + ) + .await { - Ok(structure_response) => { - // --- START OF MODIFIED LOGIC --- - let all_columns: Vec = structure_response - .columns - .iter() - .map(|c| c.name.clone()) - .collect(); - - let mut field_definitions: Vec = - filter_user_columns(all_columns) - .into_iter() - .filter(|col_name| !col_name.ends_with("_id")) - .map(|col_name| FieldDefinition { - display_name: col_name.clone(), - data_key: col_name, - is_link: false, - link_target_table: None, // Regular fields have no target - }) - .collect(); - - let linked_tables: Vec = app_state - .profile_tree - .profiles - .iter() - .find(|p| p.name == *prof_name) - .and_then(|profile| { - profile.tables.iter().find(|t| t.name == *tbl_name) - }) - .map_or(vec![], |table| table.depends_on.clone()); - - for linked_table_name in linked_tables { - let base_name = linked_table_name - .split_once('_') - .map_or(linked_table_name.as_str(), |(_, rest)| rest); - let data_key = format!("{}_id", base_name); - let display_name = linked_table_name.clone(); // Clone for use below - - field_definitions.push(FieldDefinition { - display_name, - data_key, - is_link: true, - // --- POPULATE THE NEW FIELD --- - link_target_table: Some(linked_table_name), - }); - } - // --- END OF MODIFIED LOGIC --- - - form_state = FormState::new( - prof_name.clone(), - tbl_name.clone(), - field_definitions, // This now contains the complete definitions - ); - + Ok(mut new_form_state) => { + // 2. The function succeeded, we have a new FormState. + // Now, fetch its data. if let Err(e) = UiService::fetch_and_set_table_count( &mut grpc_client, - &mut form_state, + &mut new_form_state, ) .await { + // Handle count fetching error app_state.update_dialog_content( &format!("Error fetching count: {}", e), vec!["OK".to_string()], - DialogPurpose::LoginFailed, + DialogPurpose::LoginFailed, // Or a more appropriate purpose ); - } else if form_state.total_count > 0 { + } else if new_form_state.total_count > 0 { + // If there are records, load the first/last one if let Err(e) = UiService::load_table_data_by_position( &mut grpc_client, - &mut form_state, + &mut new_form_state, ) .await { + // Handle data loading error app_state.update_dialog_content( &format!("Error loading data: {}", e), vec!["OK".to_string()], - DialogPurpose::LoginFailed, + DialogPurpose::LoginFailed, // Or a more appropriate purpose ); } else { + // Success! Hide the loading dialog. app_state.hide_dialog(); } } else { - form_state.reset_to_empty(); + // No records, so just reset to an empty form. + new_form_state.reset_to_empty(); app_state.hide_dialog(); } + // 3. CRITICAL: Replace the old form_state with the new one. + form_state = new_form_state; + + // 4. Update our tracking variables. prev_view_profile_name = current_view_profile; prev_view_table_name = current_view_table; table_just_switched = true; } - Err(e) => { + // This handles errors from load_table_view (e.g., schema fetch failed) app_state.update_dialog_content( - &format!("Error fetching table structure: {}", e), + &format!("Error loading table: {}", e), vec!["OK".to_string()], - DialogPurpose::LoginFailed, + DialogPurpose::LoginFailed, // Or a more appropriate purpose ); + // Revert the view change in app_state to avoid a loop app_state.current_view_profile_name = prev_view_profile_name.clone(); app_state.current_view_table_name = prev_view_table_name.clone(); } } + // --- END OF REFACTORED LOGIC --- } needs_redraw = true; }