diff --git a/crates/typedialog-core/Cargo.toml b/crates/typedialog-core/Cargo.toml index 5e14bbb..ab07e33 100644 --- a/crates/typedialog-core/Cargo.toml +++ b/crates/typedialog-core/Cargo.toml @@ -56,6 +56,7 @@ futures = { workspace = true, optional = true } [dev-dependencies] serde_json.workspace = true +tokio = { workspace = true, features = ["full"] } [features] default = ["cli", "i18n", "templates"] diff --git a/crates/typedialog-core/src/backends/mod.rs b/crates/typedialog-core/src/backends/mod.rs index 71881fe..8ff77d1 100644 --- a/crates/typedialog-core/src/backends/mod.rs +++ b/crates/typedialog-core/src/backends/mod.rs @@ -6,6 +6,7 @@ use async_trait::async_trait; use serde_json::Value; use std::collections::HashMap; +use std::path::Path; use crate::error::Result; use crate::form_parser::{FieldDefinition, DisplayItem}; @@ -34,12 +35,17 @@ pub trait FormBackend: Send + Sync { async fn execute_field(&self, field: &FieldDefinition, context: &RenderContext) -> Result; /// Execute complete form with all fields at once (complete mode) + /// + /// Takes owned items/fields for mutable rendering operations. + /// Form and base_dir references are provided for reactive fragment loading. + /// /// Returns all field values as a map async fn execute_form_complete( &mut self, form: &crate::form_parser::FormDefinition, - items: &[DisplayItem], - fields: &[FieldDefinition], + _base_dir: &Path, + items: Vec, + fields: Vec, ) -> Result> { // Default implementation: fall back to field-by-field mode let mut results = std::collections::HashMap::new(); @@ -48,11 +54,11 @@ pub trait FormBackend: Send + Sync { locale: form.locale.clone(), }; - for item in items { + for item in &items { self.render_display_item(item, &context).await?; } - for field in fields { + for field in &fields { context.results = results.clone(); let value = self.execute_field(field, &context).await?; results.insert(field.name.clone(), value); diff --git a/crates/typedialog-core/src/backends/tui.rs b/crates/typedialog-core/src/backends/tui.rs index 0c47e5f..0541f7a 100644 --- a/crates/typedialog-core/src/backends/tui.rs +++ b/crates/typedialog-core/src/backends/tui.rs @@ -23,7 +23,6 @@ use crossterm::{ }; use ratatui::{ backend::CrosstermBackend, - layout::Alignment, style::{Color, Modifier, Style}, widgets::{Block, Borders, Paragraph}, Terminal, @@ -57,6 +56,41 @@ enum ButtonFocus { Submit, } +/// Helper to update form elements after a field is saved (reactive re-rendering) +/// +/// When a field value changes, conditional sections may appear/disappear. +/// This function recomputes the visible items and fields based on current results. +fn recompute_form_view( + form: &crate::form_parser::FormDefinition, + base_dir: &std::path::Path, + results: &std::collections::HashMap, + items: &mut Vec, + fields: &mut Vec, + selected_index: &mut usize, + field_buffer: &mut String, +) -> crate::error::Result<()> { + // Recompute visible items and fields based on current results + let (new_items, new_fields) = crate::form_parser::recompute_visible_elements(form, base_dir, results)?; + + // Update items and fields + *items = new_items; + *fields = new_fields; + + // Reset to first visible field + let visible_indices = get_visible_field_indices(fields, results); + if !visible_indices.is_empty() { + *selected_index = visible_indices[0]; + field_buffer.clear(); + load_field_buffer(field_buffer, &fields[*selected_index], results); + } else { + // No visible fields, shouldn't happen but handle gracefully + *selected_index = 0; + field_buffer.clear(); + } + + Ok(()) +} + /// TUI Backend implementation using ratatui /// /// Full ratatui implementation with custom widget state machines @@ -113,7 +147,8 @@ impl FormBackend for RatatuiBackend { } /// Render a display item with borders, margins, and conditional logic - /// Implements R-RESPONSIVE-LAYOUT with ratatui constraints and custom borders + /// In TUI interactive mode, display items are not rendered since form layout is continuous + /// in the event loop. Display items are CLI-only constructs for output formatting. async fn render_display_item(&self, item: &DisplayItem, context: &RenderContext) -> Result<()> { // Check conditional rendering if let Some(condition) = &item.when { @@ -121,47 +156,7 @@ impl FormBackend for RatatuiBackend { return Ok(()); } } - - // Render display item with custom border support - { - let mut terminal_ref = self.terminal.write().unwrap(); - if let Some(terminal) = terminal_ref.as_mut() { - terminal - .draw(|frame| { - let area = frame.area(); - - // Render custom borders if specified - if item.border_top.unwrap_or(false) { - render_top_border(frame, area, item); - } - - // Render title/content in center - let mut block = Block::default() - .borders(Borders::ALL) - .border_type(ratatui::widgets::BorderType::Rounded); - - if let Some(title) = &item.title { - block = block.title(title.as_str()); - } - - let text = item.content.as_deref().unwrap_or(""); - let paragraph = Paragraph::new(text) - .block(block) - .alignment(Alignment::Center); - - frame.render_widget(paragraph, area); - - // Render bottom border if specified - if item.border_bottom.unwrap_or(false) { - render_bottom_border(frame, area, item); - } - }) - .map_err(|e| Error::validation_failed(format!("Failed to render: {}", e)))?; - } else { - return Err(Error::validation_failed("Terminal not initialized")); - } - } - + // Display items not rendered in TUI mode Ok(()) } @@ -187,13 +182,14 @@ impl FormBackend for RatatuiBackend { async fn execute_form_complete( &mut self, form: &crate::form_parser::FormDefinition, - items: &[DisplayItem], - fields: &[FieldDefinition], + base_dir: &std::path::Path, + mut items: Vec, + mut fields: Vec, ) -> Result> { let mut results = std::collections::HashMap::new(); // Render display items first - for item in items { + for item in &items { self.render_display_item( item, &super::RenderContext { @@ -210,7 +206,7 @@ impl FormBackend for RatatuiBackend { let mut field_buffer = String::new(); // Set selected_index to first visible field - let visible_indices = get_visible_field_indices(fields, &results); + let visible_indices = get_visible_field_indices(&fields, &results); if !visible_indices.is_empty() { selected_index = visible_indices[0]; } @@ -249,6 +245,10 @@ impl FormBackend for RatatuiBackend { if let Event::Key(key) = event::read()? { // Global hotkeys (work in any panel) match key.code { + KeyCode::Esc => { + // Cancel form with ESC key + return Err(Error::cancelled()); + } KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { // Exit and submit form with CTRL+E if focus_panel == FormPanel::InputField { @@ -268,7 +268,7 @@ impl FormBackend for RatatuiBackend { } } // Finalize results with all fields and defaults - finalize_results(&mut results, fields); + finalize_results(&mut results, &fields); return Ok(results); } KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -282,7 +282,7 @@ impl FormBackend for RatatuiBackend { FormPanel::FieldList => match key.code { KeyCode::Up => { // Navigate to previous visible field - let visible_indices = get_visible_field_indices(fields, &results); + let visible_indices = get_visible_field_indices(&fields, &results); if let Some(current_pos) = visible_indices.iter().position(|&idx| idx == selected_index) { if current_pos > 0 { selected_index = visible_indices[current_pos - 1]; @@ -296,7 +296,7 @@ impl FormBackend for RatatuiBackend { } KeyCode::Down => { // Navigate to next visible field - let visible_indices = get_visible_field_indices(fields, &results); + let visible_indices = get_visible_field_indices(&fields, &results); if let Some(current_pos) = visible_indices.iter().position(|&idx| idx == selected_index) { if current_pos < visible_indices.len() - 1 { selected_index = visible_indices[current_pos + 1]; @@ -368,6 +368,10 @@ impl FormBackend for RatatuiBackend { field.name.clone(), Value::String(field_buffer.clone()), ); + + // Reactive re-render: update visible items/fields based on new results + recompute_form_view(form, base_dir, &results, &mut items, &mut fields, &mut selected_index, &mut field_buffer)?; + focus_panel = FormPanel::FieldList; } KeyCode::Esc => { @@ -402,6 +406,10 @@ impl FormBackend for RatatuiBackend { field.name.clone(), Value::String(field_buffer.clone()), ); + + // Reactive re-render: update visible items/fields based on new results + recompute_form_view(form, base_dir, &results, &mut items, &mut fields, &mut selected_index, &mut field_buffer)?; + focus_panel = FormPanel::FieldList; } KeyCode::Esc => { @@ -444,6 +452,10 @@ impl FormBackend for RatatuiBackend { field.name.clone(), Value::Bool(bool_value), ); + + // Reactive re-render: update visible items/fields based on new results + recompute_form_view(form, base_dir, &results, &mut items, &mut fields, &mut selected_index, &mut field_buffer)?; + focus_panel = FormPanel::FieldList; } KeyCode::Esc => { @@ -477,6 +489,10 @@ impl FormBackend for RatatuiBackend { field.name.clone(), Value::String(field_buffer.clone()), ); + + // Reactive re-render: update visible items/fields based on new results + recompute_form_view(form, base_dir, &results, &mut items, &mut fields, &mut selected_index, &mut field_buffer)?; + focus_panel = FormPanel::FieldList; } KeyCode::Esc => { @@ -509,7 +525,7 @@ impl FormBackend for RatatuiBackend { } KeyCode::Enter => match button_focus { ButtonFocus::Submit => { - finalize_results(&mut results, fields); + finalize_results(&mut results, &fields); return Ok(results); } ButtonFocus::Cancel => return Err(Error::cancelled()), @@ -1462,7 +1478,7 @@ fn render_form_layout( Style::default() }; - let title_text = format!("Edit: {}", fields[selected_index].prompt); + let title_text = fields[selected_index].prompt.clone(); let block = Block::default() .title(title_text) .borders(Borders::ALL) @@ -1763,51 +1779,6 @@ impl DateCursor { } } -/// Render top border for display item using custom characters -fn render_top_border(frame: &mut ratatui::Frame, area: ratatui::layout::Rect, item: &DisplayItem) { - let border_left = item.border_top_l.as_deref().unwrap_or("╭"); - let border_char = item.border_top_char.as_deref().unwrap_or("─"); - let border_len = item.border_top_len.unwrap_or(40); - let border_right = item.border_top_r.as_deref().unwrap_or("╮"); - let margin_left = item.border_margin_left.unwrap_or(0); - - let border_line = format!( - "{}{}{}", - border_left, - border_char.repeat(border_len), - border_right - ); - let margin_str = " ".repeat(margin_left); - let border_with_margin = format!("{}{}", margin_str, border_line); - - let paragraph = Paragraph::new(border_with_margin); - frame.render_widget(paragraph, area); -} - -/// Render bottom border for display item using custom characters -fn render_bottom_border( - frame: &mut ratatui::Frame, - area: ratatui::layout::Rect, - item: &DisplayItem, -) { - let border_left = item.border_bottom_l.as_deref().unwrap_or("╰"); - let border_char = item.border_bottom_char.as_deref().unwrap_or("─"); - let border_len = item.border_bottom_len.unwrap_or(40); - let border_right = item.border_bottom_r.as_deref().unwrap_or("╯"); - let margin_left = item.border_margin_left.unwrap_or(0); - - let border_line = format!( - "{}{}{}", - border_left, - border_char.repeat(border_len), - border_right - ); - let margin_str = " ".repeat(margin_left); - let border_with_margin = format!("{}{}", margin_str, border_line); - - let paragraph = Paragraph::new(border_with_margin); - frame.render_widget(paragraph, area); -} /// Helper to evaluate conditional display logic fn evaluate_condition( @@ -2251,4 +2222,45 @@ mod tests { // January 1, 2024 is a Monday (0) assert_eq!(first_day_of_month(2024, 1), 0); } + + // ========================================================================= + // TUI REACTIVE RENDERING TESTS (FASE 3) + // ========================================================================= + + /// Test that recompute_form_view successfully handles form recomputation + /// This validates that the reactive re-rendering infrastructure works correctly + /// by updating visible items and fields based on current results + #[test] + fn test_recompute_form_view_reactive_rendering() { + let mut items: Vec = vec![]; + let mut fields: Vec = vec![]; + let results = std::collections::HashMap::new(); + + let mut selected_index = 0; + let mut field_buffer = String::new(); + + // Create minimal form for testing + let form = crate::form_parser::FormDefinition { + name: "test".to_string(), + description: None, + locale: None, + template: None, + output_template: None, + i18n_prefix: None, + display_mode: crate::form_parser::DisplayMode::Complete, + items: vec![], + fields: vec![], + }; + + let temp_dir = std::path::Path::new("/tmp"); + + // Test with empty vectors - validates recompute_form_view returns success + let result = recompute_form_view(&form, temp_dir, &results, &mut items, &mut fields, &mut selected_index, &mut field_buffer); + assert!(result.is_ok(), "recompute_form_view should handle empty items and fields"); + + // Navigation should be reset to 0 + assert_eq!(selected_index, 0); + // Field buffer should be empty when no fields exist + assert!(field_buffer.is_empty()); + } } diff --git a/crates/typedialog-core/src/backends/web/mod.rs b/crates/typedialog-core/src/backends/web/mod.rs index ab80c97..4504536 100644 --- a/crates/typedialog-core/src/backends/web/mod.rs +++ b/crates/typedialog-core/src/backends/web/mod.rs @@ -7,6 +7,7 @@ use async_trait::async_trait; use serde_json::{Value, json}; use serde::Deserialize; use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use tokio::sync::{RwLock, oneshot}; @@ -34,6 +35,8 @@ pub struct WebFormState { is_complete_mode: Arc>, /// Cached fields for complete mode rendering complete_fields: Arc>>, + /// Base directory for lazy loading fragments (FASE 4) + base_dir: Arc>>, } impl WebFormState { @@ -47,6 +50,7 @@ impl WebFormState { complete_form_tx: Arc::new(RwLock::new(None)), is_complete_mode: Arc::new(RwLock::new(false)), complete_fields: Arc::new(RwLock::new(Vec::new())), + base_dir: Arc::new(RwLock::new(None)), } } } @@ -82,7 +86,7 @@ impl WebBackend { #[cfg(feature = "web")] use axum::{ - extract::{State, Path}, + extract::{State, Path, Query}, http::{StatusCode, HeaderMap}, response::IntoResponse, routing::{get, post}, @@ -108,6 +112,7 @@ impl FormBackend for WebBackend { let app = Router::new() .route("/", get(index_handler)) .route("/api/form", get(get_form_handler)) + .route("/api/form/dynamic", get(get_dynamic_form_handler)) .route("/api/field/{name}", post(submit_field_handler)) .route("/api/form/complete", post(submit_complete_form_handler)) .with_state(form_state); @@ -267,23 +272,30 @@ impl FormBackend for WebBackend { async fn execute_form_complete( &mut self, form: &crate::form_parser::FormDefinition, - items: &[DisplayItem], - fields: &[FieldDefinition], + base_dir: &std::path::Path, + items: Vec, + fields: Vec, ) -> Result> { let state = self.state.as_ref() .ok_or_else(|| Error::validation_failed("Server not initialized"))?; - // Set complete mode flag and cache fields for get_form_handler + // Set complete mode flag, cache fields, store base_dir, and store form for lazy loading { let mut is_complete = state.is_complete_mode.write().await; *is_complete = true; let mut cached_fields = state.complete_fields.write().await; - *cached_fields = fields.to_vec(); + *cached_fields = fields.clone(); + + let mut stored_base_dir = state.base_dir.write().await; + *stored_base_dir = Some(base_dir.to_path_buf()); + + let mut stored_form = state.form.write().await; + *stored_form = Some(form.clone()); } // Render all display items - for item in items { + for item in &items { self.render_display_item(item, &RenderContext { results: HashMap::new(), locale: form.locale.clone(), @@ -310,7 +322,7 @@ impl FormBackend for WebBackend { match tokio::time::timeout(Duration::from_secs(300), rx).await { Ok(Ok(mut all_results)) => { // Validate required fields - for field in fields { + for field in &fields { if field.required.unwrap_or(false) { if let Some(value) = all_results.get(&field.name) { if value.is_null() || (value.is_string() && value.as_str().unwrap_or("").is_empty()) { @@ -406,11 +418,38 @@ async fn index_handler(State(_state): State>) -> impl IntoResp -
+

Form Inquire

-
+ "# ) @@ -446,6 +485,59 @@ async fn get_form_handler(State(state): State>) -> impl IntoRe )) } +/// Dynamic form endpoint for vanilla JS polling +/// Returns updated field HTML based on current results (reactive rendering) +/// Accepts query parameters containing current form field values +#[cfg(feature = "web")] +async fn get_dynamic_form_handler( + State(state): State>, + Query(params): Query>, +) -> (StatusCode, Html) { + let is_complete = *state.is_complete_mode.read().await; + + if !is_complete { + // Only support dynamic rendering in complete form mode + return (StatusCode::BAD_REQUEST, Html("
Dynamic form only available in complete mode
".to_string())); + } + + // Convert query parameters to results HashMap for condition evaluation + // JavaScript sends current field values as query parameters + let mut results: HashMap = HashMap::new(); + for (key, value) in params { + results.insert(key, Value::String(value)); + } + + // Get base_dir and form for recomputation + let base_dir_opt = state.base_dir.read().await.clone(); + let form_opt = state.form.read().await.clone(); + + match (base_dir_opt, form_opt) { + (Some(base_dir), Some(form)) => { + // Recompute visible elements based on current results + match crate::form_parser::recompute_visible_elements(&form, &base_dir, &results) { + Ok((_visible_items, visible_fields)) => { + // Return ONLY field HTML (no wrapper div) + // Wrapper div#form-fields already exists in DOM, JavaScript replaces innerHTML + let fields_html = render_fields_only(&visible_fields, &results); + (StatusCode::OK, Html(fields_html)) + } + Err(_) => { + // Fallback: return all fields + let fields = state.complete_fields.read().await.clone(); + let fields_html = render_fields_only(&fields, &results); + (StatusCode::OK, Html(fields_html)) + } + } + } + _ => { + // Fallback if state not properly initialized + let fields = state.complete_fields.read().await.clone(); + let fields_html = render_fields_only(&fields, &HashMap::new()); + (StatusCode::OK, Html(fields_html)) + } + } +} + #[cfg(feature = "web")] async fn submit_field_handler( State(state): State>, @@ -593,14 +685,12 @@ use axum::response::Html; /// Render all fields in a single form for complete mode fn render_complete_form(fields: &[FieldDefinition]) -> String { - let mut fields_html = String::new(); - - for field in fields { - fields_html.push_str(&render_field_for_complete_form(field)); - } + let fields_html = render_form_fields(fields); + // FASE 4: HTMX polling for reactive rendering + // Form wrapper stays static, only fields div is updated dynamically format!( - r##"
+ r##"
{}