diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..865a004 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,57 @@ +# Changes + +## [Unreleased] + +### Added + +**RepeatingGroup Duplicate Detection** +- Implemented duplicate item validation across all backends (CLI, TUI, Web) +- Added `unique = true` attribute support to prevent duplicate items +- Added item counter display updates in Web backend +- New `is_duplicate()` function in CLI and TUI backends + +**Web Backend Improvements** +- Refactored JavaScript: Replaced global constants with closure-based state management +- Added `render_global_repeating_group_script()` for generic repeating group handling +- Configuration stored in `data-*` attributes: `data-min-items`, `data-max-items`, `data-has-unique`, `data-unique-key`, `data-field-names` +- Live item counter updates after add/delete operations +- Single document-level event delegation for all repeating group interactions + +**Documentation** +- Updated `docs/FIELD_TYPES.md` with unique validation details +- Updated `examples/README.md` with RepeatingGroup testing commands +- Added `docs/FIELD_TYPES.md` (new file) +- Updated example forms with `unique = true` attribute + +### Fixed + +- **CLI Backend**: Fixed validation of duplicate items in add/edit operations +- **TUI Backend**: Fixed validation of duplicate items with error overlay feedback +- **Web Backend**: Fixed `has_unique` flag reading from field definition (was incorrectly scanning fragment fields) +- **Web Backend**: Fixed item counter not updating when adding/deleting items + +### Changed + +**Architecture** +- Each repeating group now maintains independent state via closure +- Removed ~600 lines of dead code in Web JavaScript +- Event handling now context-aware (finds correct repeating group controller) + +**Examples** +- `examples/05-fragments/array-trackers.toml`: Added `unique = true` to UDP and HTTP tracker arrays +- `examples/07-nickel-generation/arrays-form.toml`: Added `unique = true` to all RepeatingGroup fields + +### Technical Notes + +- Duplicate detection compares ALL field values when `unique = true` +- Works consistently across CLI, TUI, and Web backends +- Backwards compatible: repeating groups without `unique = true` unaffected +- max_items limit already enforced in all backends (no changes needed) + +### Testing + +- All 174 unit tests passing +- No clippy warnings +- Build verified with `--all-features` flag +- Manual testing in Web backend: duplicate detection working correctly +- Item counter updates verified on add/delete operations diff --git a/Cargo.lock b/Cargo.lock index cd66562..8073697 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -162,6 +162,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -747,6 +748,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1556,6 +1566,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nix" version = "0.30.1" @@ -2535,6 +2562,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "static_assertions" version = "1.1.0" @@ -3024,6 +3057,7 @@ dependencies = [ "clap", "serde_json", "tokio", + "toml", "typedialog-core", "unic-langid", ] @@ -3036,6 +3070,7 @@ dependencies = [ "clap", "serde_json", "tokio", + "toml", "typedialog-core", "unic-langid", ] diff --git a/Cargo.toml b/Cargo.toml index d19fc54..3289c26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,7 @@ crossterm = "0.29" atty = "0.2" # Web Backend (axum) -axum = "0.8.7" +axum = { version = "0.8.7", features = ["multipart"] } tower = "0.5.2" tower-http = { version = "0.6.8", features = ["fs", "cors"] } tracing = "0.1" diff --git a/crates/typedialog-core/src/autocompletion.rs b/crates/typedialog-core/src/autocompletion.rs index 119caeb..689450a 100644 --- a/crates/typedialog-core/src/autocompletion.rs +++ b/crates/typedialog-core/src/autocompletion.rs @@ -175,7 +175,10 @@ impl PatternCompleter { ], }; - Self { pattern, suggestions } + Self { + pattern, + suggestions, + } } /// Add custom suggestion @@ -244,7 +247,11 @@ mod tests { #[test] fn test_filter_completer() { - let options = vec!["apple".to_string(), "application".to_string(), "banana".to_string()]; + let options = vec![ + "apple".to_string(), + "application".to_string(), + "banana".to_string(), + ]; let completer = FilterCompleter::new(options); let filtered = completer.filter("app"); diff --git a/crates/typedialog-core/src/backends/cli.rs b/crates/typedialog-core/src/backends/cli.rs index 6d37ba4..9c5a3d7 100644 --- a/crates/typedialog-core/src/backends/cli.rs +++ b/crates/typedialog-core/src/backends/cli.rs @@ -3,12 +3,13 @@ //! This backend provides the existing inquire-based CLI interface. //! It will be the primary implementation of FormBackend for terminal-based forms. +use super::{FormBackend, RenderContext}; +use crate::error::Result; +use crate::form_parser::{DisplayItem, FieldDefinition, FieldType}; +use crate::prompts; use async_trait::async_trait; use serde_json::Value; -use crate::error::Result; -use crate::form_parser::{FieldDefinition, DisplayItem, FieldType}; -use crate::prompts; -use super::{FormBackend, RenderContext}; +use std::collections::HashMap; /// CLI Backend implementation using inquire pub struct InquireBackend; @@ -27,7 +28,11 @@ impl InquireBackend { match field.field_type { FieldType::Text => { let prompt_with_marker = format!("{}{}", field.prompt, required_marker); - let result = prompts::text(&prompt_with_marker, field.default.as_deref(), field.placeholder.as_deref())?; + let result = prompts::text( + &prompt_with_marker, + field.default.as_deref(), + field.placeholder.as_deref(), + )?; if is_required && result.is_empty() { eprintln!("⚠ This field is required. Please enter a value."); @@ -38,11 +43,15 @@ impl InquireBackend { FieldType::Confirm => { let prompt_with_marker = format!("{}{}", field.prompt, required_marker); - let default_bool = field.default.as_deref().and_then(|s| match s.to_lowercase().as_str() { - "true" | "yes" => Some(true), - "false" | "no" => Some(false), - _ => None, - }); + let default_bool = + field + .default + .as_deref() + .and_then(|s| match s.to_lowercase().as_str() { + "true" | "yes" => Some(true), + "false" | "no" => Some(false), + _ => None, + }); let result = prompts::confirm(&prompt_with_marker, default_bool, None)?; Ok(serde_json::json!(result)) } @@ -60,10 +69,17 @@ impl InquireBackend { } FieldType::Select => { + if field.options.is_empty() { + return Err(crate::Error::form_parse_failed( + "Select field requires 'options'", + )); + } let prompt_with_marker = format!("{}{}", field.prompt, required_marker); - let options = field.options.clone().ok_or_else(|| { - crate::Error::form_parse_failed("Select field requires 'options'") - })?; + let options = field + .options + .iter() + .map(|opt| opt.as_string()) + .collect::>(); let result = prompts::select( &prompt_with_marker, options, @@ -74,10 +90,17 @@ impl InquireBackend { } FieldType::MultiSelect => { + if field.options.is_empty() { + return Err(crate::Error::form_parse_failed( + "MultiSelect field requires 'options'", + )); + } let prompt_with_marker = format!("{}{}", field.prompt, required_marker); - let options = field.options.clone().ok_or_else(|| { - crate::Error::form_parse_failed("MultiSelect field requires 'options'") - })?; + let options = field + .options + .iter() + .map(|opt| opt.as_string()) + .collect::>(); let results = prompts::multi_select( &prompt_with_marker, options, @@ -94,7 +117,11 @@ impl InquireBackend { FieldType::Editor => { let prompt_with_marker = format!("{}{}", field.prompt, required_marker); - let result = prompts::editor(&prompt_with_marker, field.file_extension.as_deref(), field.prefix_text.as_deref())?; + let result = prompts::editor( + &prompt_with_marker, + field.file_extension.as_deref(), + field.prefix_text.as_deref(), + )?; if is_required && result.is_empty() { eprintln!("⚠ This field is required. Please enter a value."); @@ -121,7 +148,8 @@ impl InquireBackend { let type_name = field.custom_type.as_ref().ok_or_else(|| { crate::Error::form_parse_failed("Custom field requires 'custom_type'") })?; - let result = prompts::custom(&prompt_with_marker, type_name, field.default.as_deref())?; + let result = + prompts::custom(&prompt_with_marker, type_name, field.default.as_deref())?; if is_required && result.is_empty() { eprintln!("⚠ This field is required. Please enter a value."); @@ -129,8 +157,259 @@ impl InquireBackend { } Ok(serde_json::json!(result)) } + + FieldType::RepeatingGroup => { + let fragment_path = field.fragment.as_ref().ok_or_else(|| { + crate::Error::form_parse_failed("RepeatingGroup requires 'fragment' field") + })?; + + self.execute_repeating_group(field, fragment_path) + } } } + + /// Execute a repeating group field with interactive add/edit/delete menu + fn execute_repeating_group( + &self, + field: &FieldDefinition, + fragment_path: &str, + ) -> Result { + let min_items = field.min_items.unwrap_or(0); + let max_items = field.max_items.unwrap_or(usize::MAX); + let default_items = field.default_items.unwrap_or(0); + + let mut items: Vec> = Vec::new(); + + // Pre-populate with default_items empty entries if requested + for _ in 0..default_items { + // Start with empty items - user can edit them + } + + loop { + // Build menu options based on current state + let options = Self::build_array_menu_options(&items, min_items, max_items); + + let status_msg = if min_items > 0 && items.len() < min_items { + format!(" - minimum {} required", min_items) + } else { + String::new() + }; + + let prompt = format!( + "{} ({} items configured){}", + field.prompt, + items.len(), + status_msg + ); + + let choice = prompts::select(&prompt, options, None, false)?; + + match choice.as_str() { + "[A] Add new item" => { + if items.len() >= max_items { + eprintln!("⚠ Maximum {} items reached", max_items); + continue; + } + + match Self::execute_fragment(fragment_path, items.len() + 1) { + Ok(item_data) => { + // Check for duplicates if unique constraint is set + if field.unique.unwrap_or(false) { + if Self::is_duplicate(&item_data, &items, None, field) { + eprintln!("⚠ This item already exists. Duplicates not allowed."); + continue; + } + } + items.push(item_data); + println!("✓ Item added successfully"); + } + Err(e) => { + eprintln!("⚠ Error adding item: {}", e); + } + } + } + "[E] Edit item" => match Self::select_item_to_edit(&items) { + Ok(index) => match Self::execute_fragment(fragment_path, index + 1) { + Ok(updated_data) => { + // Check for duplicates if unique constraint is set (exclude current item) + if field.unique.unwrap_or(false) { + if Self::is_duplicate(&updated_data, &items, Some(index), field) { + eprintln!("⚠ This item already exists. Duplicates not allowed."); + continue; + } + } + items[index] = updated_data; + println!("✓ Item updated successfully"); + } + Err(e) => { + eprintln!("⚠ Error editing item: {}", e); + } + }, + Err(e) => { + eprintln!("⚠ {}", e); + } + }, + "[D] Delete item" => match Self::select_item_to_delete(&items) { + Ok(index) => { + items.remove(index); + println!("✓ Item #{} deleted", index + 1); + } + Err(e) => { + eprintln!("⚠ {}", e); + } + }, + "[C] Continue to next field" => { + if items.len() < min_items { + eprintln!("⚠ Minimum {} items required", min_items); + continue; + } + break; + } + _ => {} + } + } + + Ok(serde_json::json!(items)) + } + + /// Build menu options for array management + fn build_array_menu_options( + items: &[HashMap], + min_items: usize, + max_items: usize, + ) -> Vec { + let mut options = Vec::new(); + + if items.len() < max_items { + options.push("[A] Add new item".to_string()); + } + + if !items.is_empty() { + options.push("[E] Edit item".to_string()); + options.push("[D] Delete item".to_string()); + } + + if items.len() >= min_items { + options.push("[C] Continue to next field".to_string()); + } + + options + } + + /// Execute a fragment to collect data for one array item + fn execute_fragment(fragment_path: &str, item_number: usize) -> Result> { + // Load fragment TOML + let fragment_form = crate::form_parser::load_fragment_form(fragment_path)?; + + println!("\n--- Item #{} ---", item_number); + + // Execute each field in fragment + let mut results = HashMap::new(); + let backend = InquireBackend::new(); + + for element in &fragment_form.elements { + if let crate::form_parser::FormElement::Field(field_def) = element { + let value = backend.execute_field_sync(field_def)?; + results.insert(field_def.name.clone(), value); + } + } + + Ok(results) + } + + /// Select an item to edit from the list + fn select_item_to_edit(items: &[HashMap]) -> Result { + if items.is_empty() { + return Err(crate::Error::form_parse_failed("No items to edit")); + } + + let labels: Vec = items + .iter() + .enumerate() + .map(|(i, item)| format!("Item #{} - {}", i + 1, Self::summarize_item(item))) + .collect(); + + let choice = prompts::select("Select item to edit", labels, None, false)?; + + // Extract index from choice (format: "Item #N - ...") + let index_str = choice + .split('#') + .nth(1) + .and_then(|s| s.split('-').next()) + .and_then(|s| s.trim().parse::().ok()) + .ok_or_else(|| crate::Error::form_parse_failed("Failed to parse item index"))?; + + Ok(index_str - 1) // Convert to 0-indexed + } + + /// Select an item to delete from the list + fn select_item_to_delete(items: &[HashMap]) -> Result { + if items.is_empty() { + return Err(crate::Error::form_parse_failed("No items to delete")); + } + + let labels: Vec = items + .iter() + .enumerate() + .map(|(i, item)| format!("Item #{} - {}", i + 1, Self::summarize_item(item))) + .collect(); + + let choice = prompts::select("Select item to delete", labels, None, false)?; + + // Extract index from choice (format: "Item #N - ...") + let index_str = choice + .split('#') + .nth(1) + .and_then(|s| s.split('-').next()) + .and_then(|s| s.trim().parse::().ok()) + .ok_or_else(|| crate::Error::form_parse_failed("Failed to parse item index"))?; + + Ok(index_str - 1) // Convert to 0-indexed + } + + /// Summarize an item for display in lists + fn summarize_item(item: &HashMap) -> String { + // Try to find a meaningful field to display (first string field, or first field) + if let Some(first_string) = item.values().find_map(|v| v.as_str()) { + first_string.chars().take(40).collect() + } else if let Some(first_value) = item.values().next() { + format!("{:?}", first_value).chars().take(40).collect() + } else { + "(empty)".to_string() + } + } + + /// Check if a new item is a duplicate of existing items + fn is_duplicate( + new_item: &HashMap, + items: &[HashMap], + exclude_index: Option, + field: &FieldDefinition, + ) -> bool { + let has_unique = field.unique.unwrap_or(false); + + for (idx, existing_item) in items.iter().enumerate() { + // Skip comparing with self when editing + if let Some(exclude_idx) = exclude_index { + if idx == exclude_idx { + continue; + } + } + + if has_unique { + // Check if ALL fields match + let all_match = new_item + .iter() + .all(|(key, value)| existing_item.get(key).map_or(false, |v| v == value)); + + if all_match && !new_item.is_empty() { + return true; + } + } + } + + false + } } impl Default for InquireBackend { @@ -151,7 +430,11 @@ impl FormBackend for InquireBackend { Ok(()) } - async fn execute_field(&self, field: &FieldDefinition, _context: &RenderContext) -> Result { + async fn execute_field( + &self, + field: &FieldDefinition, + _context: &RenderContext, + ) -> Result { // Wrap synchronous field execution in async context self.execute_field_sync(field) } diff --git a/crates/typedialog-core/src/backends/mod.rs b/crates/typedialog-core/src/backends/mod.rs index 8ff77d1..03363a5 100644 --- a/crates/typedialog-core/src/backends/mod.rs +++ b/crates/typedialog-core/src/backends/mod.rs @@ -3,12 +3,12 @@ //! This module provides a trait-based abstraction for different form rendering //! backends (CLI with inquire, TUI with ratatui, Web with axum, etc.). +use crate::error::Result; +use crate::form_parser::{DisplayItem, FieldDefinition}; 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}; /// Context passed to rendering operations #[derive(Debug, Clone)] @@ -32,12 +32,17 @@ pub trait FormBackend: Send + Sync { async fn render_display_item(&self, item: &DisplayItem, context: &RenderContext) -> Result<()>; /// Execute a field and return user input (field-by-field mode) - async fn execute_field(&self, field: &FieldDefinition, context: &RenderContext) -> Result; + 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. + /// initial_values provides pre-populated field values (e.g., from --defaults). /// /// Returns all field values as a map async fn execute_form_complete( @@ -46,9 +51,10 @@ pub trait FormBackend: Send + Sync { _base_dir: &Path, items: Vec, fields: Vec, + initial_values: Option>, ) -> Result> { // Default implementation: fall back to field-by-field mode - let mut results = std::collections::HashMap::new(); + let mut results = initial_values.unwrap_or_default(); let mut context = RenderContext { results: results.clone(), locale: form.locale.clone(), @@ -113,13 +119,9 @@ impl BackendFactory { } } #[cfg(feature = "tui")] - BackendType::Tui => { - Ok(Box::new(tui::RatatuiBackend::new())) - } + BackendType::Tui => Ok(Box::new(tui::RatatuiBackend::new())), #[cfg(feature = "web")] - BackendType::Web { port } => { - Ok(Box::new(web::WebBackend::new(port))) - } + BackendType::Web { port } => Ok(Box::new(web::WebBackend::new(port))), } } diff --git a/crates/typedialog-core/src/backends/tui.rs b/crates/typedialog-core/src/backends/tui.rs index 0541f7a..7c2f37a 100644 --- a/crates/typedialog-core/src/backends/tui.rs +++ b/crates/typedialog-core/src/backends/tui.rs @@ -8,6 +8,7 @@ use async_trait::async_trait; use serde_json::{json, Value}; +use std::collections::HashMap; use std::io; use std::sync::{Arc, RwLock}; use std::time::Duration; @@ -70,7 +71,8 @@ fn recompute_form_view( 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)?; + let (new_items, new_fields) = + crate::form_parser::recompute_visible_elements(form, base_dir, results)?; // Update items and fields *items = new_items; @@ -175,6 +177,7 @@ impl FormBackend for RatatuiBackend { FieldType::Custom => self.execute_custom_field(field).await, FieldType::Editor => self.execute_editor_field(field).await, FieldType::Date => self.execute_date_field(field).await, + FieldType::RepeatingGroup => self.execute_repeating_group_field(field).await, } } @@ -185,8 +188,9 @@ impl FormBackend for RatatuiBackend { base_dir: &std::path::Path, mut items: Vec, mut fields: Vec, + initial_values: Option>, ) -> Result> { - let mut results = std::collections::HashMap::new(); + let mut results = initial_values.unwrap_or_default(); // Render display items first for item in &items { @@ -283,7 +287,10 @@ impl FormBackend for RatatuiBackend { KeyCode::Up => { // Navigate to previous visible field let visible_indices = get_visible_field_indices(&fields, &results); - if let Some(current_pos) = visible_indices.iter().position(|&idx| idx == selected_index) { + if let Some(current_pos) = visible_indices + .iter() + .position(|&idx| idx == selected_index) + { if current_pos > 0 { selected_index = visible_indices[current_pos - 1]; load_field_buffer( @@ -297,7 +304,10 @@ impl FormBackend for RatatuiBackend { KeyCode::Down => { // Navigate to next visible field let visible_indices = get_visible_field_indices(&fields, &results); - if let Some(current_pos) = visible_indices.iter().position(|&idx| idx == selected_index) { + 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]; load_field_buffer( @@ -309,10 +319,54 @@ impl FormBackend for RatatuiBackend { } } KeyCode::Tab | KeyCode::Right => { - focus_panel = FormPanel::InputField; + // Check if current field is RepeatingGroup + if fields[selected_index].field_type == FieldType::RepeatingGroup { + // Execute RepeatingGroup handler instead of switching to InputField + let field_clone = fields[selected_index].clone(); + let value = + self.execute_repeating_group_field(&field_clone).await?; + results.insert(field_clone.name.clone(), value); + + // Reactive re-render + recompute_form_view( + form, + base_dir, + &results, + &mut items, + &mut fields, + &mut selected_index, + &mut field_buffer, + )?; + + // Stay in FieldList panel (will re-render on next loop) + } else { + focus_panel = FormPanel::InputField; + } } KeyCode::Enter => { - focus_panel = FormPanel::InputField; + // Check if current field is RepeatingGroup + if fields[selected_index].field_type == FieldType::RepeatingGroup { + // Execute RepeatingGroup handler instead of switching to InputField + let field_clone = fields[selected_index].clone(); + let value = + self.execute_repeating_group_field(&field_clone).await?; + results.insert(field_clone.name.clone(), value); + + // Reactive re-render + recompute_form_view( + form, + base_dir, + &results, + &mut items, + &mut fields, + &mut selected_index, + &mut field_buffer, + )?; + + // Stay in FieldList panel + } else { + focus_panel = FormPanel::InputField; + } } _ => {} }, @@ -323,35 +377,38 @@ impl FormBackend for RatatuiBackend { if current_field_type == FieldType::Select || current_field_type == FieldType::MultiSelect { - if let Some(options) = &fields[selected_index].options { + if !fields[selected_index].options.is_empty() { + let options = &fields[selected_index].options; match key.code { KeyCode::Up => { // Navigate to previous option let current_idx = options .iter() - .position(|o| o == &field_buffer) + .position(|o| o.value == field_buffer) .unwrap_or(0); if current_idx > 0 { - field_buffer = options[current_idx - 1].clone(); + field_buffer = + options[current_idx - 1].value.clone(); } } KeyCode::Down => { // Navigate to next option let current_idx = options .iter() - .position(|o| o == &field_buffer) + .position(|o| o.value == field_buffer) .unwrap_or(0); if current_idx < options.len() - 1 { - field_buffer = options[current_idx + 1].clone(); + field_buffer = + options[current_idx + 1].value.clone(); } } KeyCode::Char(c) => { // Search by first letter let search_char = c.to_lowercase().to_string(); if let Some(found) = options.iter().find(|o| { - o.to_lowercase().starts_with(&search_char) + o.value.to_lowercase().starts_with(&search_char) }) { - field_buffer = found.clone(); + field_buffer = found.value.clone(); } } KeyCode::Tab | KeyCode::Right => { @@ -370,7 +427,15 @@ impl FormBackend for RatatuiBackend { ); // 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)?; + recompute_form_view( + form, + base_dir, + &results, + &mut items, + &mut fields, + &mut selected_index, + &mut field_buffer, + )?; focus_panel = FormPanel::FieldList; } @@ -408,7 +473,15 @@ impl FormBackend for RatatuiBackend { ); // 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)?; + recompute_form_view( + form, + base_dir, + &results, + &mut items, + &mut fields, + &mut selected_index, + &mut field_buffer, + )?; focus_panel = FormPanel::FieldList; } @@ -448,13 +521,18 @@ impl FormBackend for RatatuiBackend { // Save field value as boolean let field = &fields[selected_index]; let bool_value = field_buffer == "true"; - results.insert( - field.name.clone(), - Value::Bool(bool_value), - ); + results.insert(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)?; + recompute_form_view( + form, + base_dir, + &results, + &mut items, + &mut fields, + &mut selected_index, + &mut field_buffer, + )?; focus_panel = FormPanel::FieldList; } @@ -491,7 +569,15 @@ impl FormBackend for RatatuiBackend { ); // 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)?; + recompute_form_view( + form, + base_dir, + &results, + &mut items, + &mut fields, + &mut selected_index, + &mut field_buffer, + )?; focus_panel = FormPanel::FieldList; } @@ -795,14 +881,15 @@ impl RatatuiBackend { /// Execute select (single choice) field with pagination support /// Implements pagination and navigation with Up/Down arrows async fn execute_select_field(&self, field: &FieldDefinition) -> Result { - let options = field - .options - .as_ref() - .ok_or_else(|| Error::validation_failed("Select field must have options"))?; + if field.options.is_empty() { + return Err(Error::validation_failed("Select field must have options")); + } let page_size = field.page_size.unwrap_or(5); + let options_strings: Vec = + field.options.iter().map(|opt| opt.as_string()).collect(); let mut state = SelectState { - options: options.clone(), + options: options_strings, selected_index: 0, scroll_offset: 0, page_size, @@ -917,15 +1004,18 @@ impl RatatuiBackend { /// Execute multiselect field with checkboxes /// Allows selecting multiple options with Space, confirm with Enter async fn execute_multiselect_field(&self, field: &FieldDefinition) -> Result { - let options = field - .options - .as_ref() - .ok_or_else(|| Error::validation_failed("MultiSelect field must have options"))?; + if field.options.is_empty() { + return Err(Error::validation_failed( + "MultiSelect field must have options", + )); + } let page_size = field.page_size.unwrap_or(5); + let options_strings: Vec = + field.options.iter().map(|opt| opt.as_string()).collect(); let mut state = MultiSelectState { - options: options.clone(), - selected: vec![false; options.len()], + options: options_strings, + selected: vec![false; field.options.len()], cursor_index: 0, scroll_offset: 0, page_size, @@ -1149,6 +1239,380 @@ impl RatatuiBackend { } } + /// Execute repeating group field with split-pane list/preview UI + /// Allows add/edit/delete operations on array items using fragments + async fn execute_repeating_group_field(&self, field: &FieldDefinition) -> Result { + use ratatui::{ + layout::{Constraint, Direction, Layout}, + text::{Line, Span}, + widgets::{List, ListItem, ListState}, + }; + + let fragment_path = field + .fragment + .as_ref() + .ok_or_else(|| Error::form_parse_failed("RepeatingGroup requires 'fragment' field"))?; + + let min_items = field.min_items.unwrap_or(0); + let max_items = field.max_items.unwrap_or(usize::MAX); + + let mut items: Vec> = Vec::new(); + let mut selected_index: usize = 0; + let mut list_state = ListState::default(); + list_state.select(Some(0)); + + loop { + // Render UI in separate scope to ensure terminal_ref is dropped + { + let mut terminal_ref = self.terminal.write().unwrap(); + if let Some(terminal) = terminal_ref.as_mut() { + let current_items = items.clone(); + let item_count = current_items.len(); + let selected_idx = selected_index; + let min = min_items; + let max = max_items; + let field_prompt = field.prompt.clone(); + + terminal + .draw(|frame| { + let area = frame.area(); + + // Split screen: left=list, right=preview + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(50), + Constraint::Percentage(50), + ]) + .split(area); + + // Left panel: List of items with actions + let mut list_items_vec = Vec::new(); + + // Add action items + if item_count < max { + list_items_vec.push(ListItem::new(Line::from(vec![ + Span::styled("[A] ", Style::default().fg(Color::Green)), + Span::raw("Add new item"), + ]))); + } + + if !current_items.is_empty() { + list_items_vec.push(ListItem::new(Line::from(vec![ + Span::styled("[E] ", Style::default().fg(Color::Yellow)), + Span::raw("Edit item"), + ]))); + list_items_vec.push(ListItem::new(Line::from(vec![ + Span::styled("[D] ", Style::default().fg(Color::Red)), + Span::raw("Delete item"), + ]))); + } + + if item_count >= min { + list_items_vec.push(ListItem::new(Line::from(vec![ + Span::styled("[Enter] ", Style::default().fg(Color::Blue)), + Span::raw("Continue to next field"), + ]))); + } + + // Add separator + list_items_vec.push(ListItem::new("")); + list_items_vec.push(ListItem::new(Line::from(vec![Span::styled( + "Items:", + Style::default().add_modifier(Modifier::BOLD), + )]))); + + // Add existing items + for (i, item) in current_items.iter().enumerate() { + let summary = Self::summarize_item_tui(item); + let style = if i == selected_idx && !current_items.is_empty() { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + list_items_vec.push(ListItem::new(Line::from(vec![ + Span::styled(format!(" #{} ", i + 1), style), + Span::styled(summary, style), + ]))); + } + + let status_msg = if min > 0 && item_count < min { + format!(" - minimum {} required", min) + } else { + String::new() + }; + + let list_title = + format!("{} ({} items){}", field_prompt, item_count, status_msg); + + let list = List::new(list_items_vec) + .block(Block::default().borders(Borders::ALL).title(list_title)); + + frame.render_widget(list, chunks[0]); + + // Right panel: Preview of selected item + let preview_text = if !current_items.is_empty() + && selected_idx < current_items.len() + { + let item = ¤t_items[selected_idx]; + let mut lines = vec![ + format!("Item #{} Details", selected_idx + 1), + "─".repeat(30), + ]; + for (key, value) in item { + lines.push(format!( + "{}: {}", + key, + Self::format_value_preview(value) + )); + } + lines.join("\n") + } else if current_items.is_empty() { + String::from("No items yet\n\nPress 'A' to add an item") + } else { + String::from("Select an item to preview") + }; + + let preview = Paragraph::new(preview_text) + .block(Block::default().borders(Borders::ALL).title("Preview")); + + frame.render_widget(preview, chunks[1]); + }) + .map_err(|e| Error::validation_failed(format!("Render failed: {}", e)))?; + } + } // terminal_ref dropped here + + // Handle keyboard events + if event::poll(Duration::from_millis(100)) + .map_err(|e| Error::validation_failed(format!("Poll failed: {}", e)))? + { + if let Event::Key(key) = event::read() + .map_err(|e| Error::validation_failed(format!("Read failed: {}", e)))? + { + match key.code { + KeyCode::Char('a') | KeyCode::Char('A') => { + if items.len() >= max_items { + self.show_validation_error(&format!( + "Maximum {} items reached", + max_items + )) + .await?; + continue; + } + + // Execute fragment to add new item + match self + .execute_fragment_for_item(fragment_path, items.len() + 1) + .await + { + Ok(item_data) => { + // Check for duplicates if unique constraint is set + if field.unique.unwrap_or(false) { + if Self::is_duplicate(&item_data, &items, None) { + self.show_validation_error( + "This item already exists. Duplicates not allowed." + ) + .await?; + continue; + } + } + items.push(item_data); + selected_index = items.len().saturating_sub(1); + } + Err(e) if e.is_cancelled() => { + // User cancelled, continue loop + } + Err(e) => { + self.show_validation_error(&format!( + "Error adding item: {}", + e + )) + .await?; + } + } + } + KeyCode::Char('e') | KeyCode::Char('E') => { + if items.is_empty() { + self.show_validation_error("No items to edit").await?; + continue; + } + + // Execute fragment to edit selected item + match self + .execute_fragment_for_item(fragment_path, selected_index + 1) + .await + { + Ok(updated_data) => { + // Check for duplicates if unique constraint is set (exclude current item) + if field.unique.unwrap_or(false) { + if Self::is_duplicate(&updated_data, &items, Some(selected_index)) { + self.show_validation_error( + "This item already exists. Duplicates not allowed." + ) + .await?; + continue; + } + } + items[selected_index] = updated_data; + } + Err(e) if e.is_cancelled() => { + // User cancelled, continue loop + } + Err(e) => { + self.show_validation_error(&format!( + "Error editing item: {}", + e + )) + .await?; + } + } + } + KeyCode::Char('d') | KeyCode::Char('D') => { + if items.is_empty() { + self.show_validation_error("No items to delete").await?; + continue; + } + + items.remove(selected_index); + if selected_index >= items.len() && selected_index > 0 { + selected_index = items.len().saturating_sub(1); + } + } + KeyCode::Up => { + if !items.is_empty() && selected_index > 0 { + selected_index -= 1; + } + } + KeyCode::Down => { + if !items.is_empty() && selected_index < items.len() - 1 { + selected_index += 1; + } + } + KeyCode::Enter => { + if items.len() < min_items { + self.show_validation_error(&format!( + "Minimum {} items required", + min_items + )) + .await?; + continue; + } + return Ok(json!(items)); + } + KeyCode::Esc => return Err(Error::cancelled()), + _ => {} + } + } + } + } + } + + /// Execute a fragment to collect data for one array item + async fn execute_fragment_for_item( + &self, + fragment_path: &str, + item_number: usize, + ) -> Result> { + // Load fragment form + let fragment_form = crate::form_parser::load_fragment_form(fragment_path)?; + + // Show header in separate scope + { + let mut terminal_ref = self.terminal.write().unwrap(); + if let Some(terminal) = terminal_ref.as_mut() { + let header_text = format!("--- Item #{} ---", item_number); + terminal + .draw(|frame| { + let area = frame.area(); + let paragraph = Paragraph::new(header_text) + .block(Block::default().borders(Borders::ALL).title("Editing Item")); + frame.render_widget(paragraph, area); + }) + .map_err(|e| Error::validation_failed(format!("Render failed: {}", e)))?; + } + } // terminal_ref dropped here + + // Wait for key press to continue + loop { + if event::poll(Duration::from_millis(100)) + .map_err(|e| Error::validation_failed(format!("Poll failed: {}", e)))? + { + if let Event::Key(_) = event::read() + .map_err(|e| Error::validation_failed(format!("Read failed: {}", e)))? + { + break; + } + } + } + + // Execute each field in fragment + let mut results = HashMap::new(); + for element in &fragment_form.elements { + if let crate::form_parser::FormElement::Field(field_def) = element { + let context = RenderContext { + results: results.clone(), + locale: None, + }; + let value = self.execute_field(field_def, &context).await?; + results.insert(field_def.name.clone(), value); + } + } + + Ok(results) + } + + /// Summarize an item for display in TUI lists + fn summarize_item_tui(item: &HashMap) -> String { + if let Some(first_string) = item.values().find_map(|v| v.as_str()) { + first_string.chars().take(30).collect() + } else if let Some(first_value) = item.values().next() { + format!("{:?}", first_value).chars().take(30).collect() + } else { + "(empty)".to_string() + } + } + + /// Format a value for preview display + fn format_value_preview(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Array(arr) => format!("[{} items]", arr.len()), + Value::Object(_) => "{object}".to_string(), + Value::Null => "null".to_string(), + } + } + + /// Check if a new item is a duplicate of existing items + fn is_duplicate( + new_item: &HashMap, + items: &[HashMap], + exclude_index: Option, + ) -> bool { + for (idx, existing_item) in items.iter().enumerate() { + // Skip comparing with self when editing + if let Some(exclude_idx) = exclude_index { + if idx == exclude_idx { + continue; + } + } + + // Check if ALL fields match + let all_match = new_item + .iter() + .all(|(key, value)| existing_item.get(key).map_or(false, |v| v == value)); + + if all_match && !new_item.is_empty() { + return true; + } + } + + false + } + /// Display validation error overlay async fn show_validation_error(&self, message: &str) -> Result<()> { let mut terminal_ref = self.terminal.write().unwrap(); @@ -1509,11 +1973,16 @@ fn render_form_layout( )); lines.push("".to_string()); - if let Some(options) = &fields[selected_index].options { + if !fields[selected_index].options.is_empty() { + let options = &fields[selected_index].options; lines.push(" Options:".to_string()); for opt in options.iter().take(4) { - let marker = if field_buffer == *opt { "▶" } else { " " }; - lines.push(format!(" {} {}", marker, opt)); + let marker = if field_buffer == opt.value { + "▶" + } else { + " " + }; + lines.push(format!(" {} {}", marker, opt.display_label())); } if options.len() > 4 { lines.push(format!(" ... and {} more", options.len() - 4)); @@ -1622,14 +2091,10 @@ fn load_field_buffer( // For Confirm fields, default to "false" if no explicit default buffer.push_str("false"); } else if (field.field_type == FieldType::Select || field.field_type == FieldType::MultiSelect) - && field.options.is_some() + && !field.options.is_empty() { // For Select/MultiSelect, default to first option if no explicit default - if let Some(options) = &field.options { - if !options.is_empty() { - buffer.push_str(&options[0]); - } - } + buffer.push_str(&field.options[0].value); } } @@ -1662,15 +2127,11 @@ fn finalize_results( results.insert(field.name.clone(), Value::Bool(false)); } FieldType::Select | FieldType::MultiSelect => { - if let Some(options) = &field.options { - if !options.is_empty() { - results.insert( - field.name.clone(), - Value::String(options[0].clone()), - ); - } else { - results.insert(field.name.clone(), Value::String(String::new())); - } + if !field.options.is_empty() { + results.insert( + field.name.clone(), + Value::String(field.options[0].value.clone()), + ); } else { results.insert(field.name.clone(), Value::String(String::new())); } @@ -1684,7 +2145,10 @@ fn finalize_results( } /// Check if a field should be visible based on its conditional -fn is_field_visible(field: &FieldDefinition, results: &std::collections::HashMap) -> bool { +fn is_field_visible( + field: &FieldDefinition, + results: &std::collections::HashMap, +) -> bool { if let Some(condition) = &field.when { crate::form_parser::evaluate_condition(condition, results) } else { @@ -1779,7 +2243,6 @@ impl DateCursor { } } - /// Helper to evaluate conditional display logic fn evaluate_condition( _condition: &str, @@ -2250,13 +2713,25 @@ mod tests { display_mode: crate::form_parser::DisplayMode::Complete, items: vec![], fields: vec![], + elements: 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"); + 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); diff --git a/crates/typedialog-core/src/backends/web/mod.rs b/crates/typedialog-core/src/backends/web/mod.rs index 4504536..b40e692 100644 --- a/crates/typedialog-core/src/backends/web/mod.rs +++ b/crates/typedialog-core/src/backends/web/mod.rs @@ -4,18 +4,18 @@ //! Forms are served over HTTP and can be accessed from any web browser. use async_trait::async_trait; -use serde_json::{Value, json}; use serde::Deserialize; +use serde_json::{json, Value}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use tokio::sync::{RwLock, oneshot}; +use tokio::sync::{oneshot, RwLock}; use tokio::task::JoinHandle; -use crate::error::{Error, Result}; -use crate::form_parser::{FieldDefinition, DisplayItem, FieldType}; use super::{FormBackend, RenderContext}; +use crate::error::{Error, Result}; +use crate::form_parser::{DisplayItem, FieldDefinition, FieldType}; /// Type alias for complete form submission channel type CompleteFormChannel = Arc>>>>; @@ -35,6 +35,8 @@ pub struct WebFormState { is_complete_mode: Arc>, /// Cached fields for complete mode rendering complete_fields: Arc>>, + /// Cached display items (section headers, etc.) for complete mode + complete_items: Arc>>, /// Base directory for lazy loading fragments (FASE 4) base_dir: Arc>>, } @@ -50,6 +52,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())), + complete_items: Arc::new(RwLock::new(Vec::new())), base_dir: Arc::new(RwLock::new(None)), } } @@ -69,7 +72,7 @@ struct FieldSubmission { value: String, field_type: String, #[serde(default)] - values: String, // Changed to String - will be comma-separated values + values: String, // Changed to String - will be comma-separated values } impl WebBackend { @@ -86,8 +89,8 @@ impl WebBackend { #[cfg(feature = "web")] use axum::{ - extract::{State, Path, Query}, - http::{StatusCode, HeaderMap}, + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, response::IntoResponse, routing::{get, post}, Form, Json, Router, @@ -115,17 +118,20 @@ impl FormBackend for WebBackend { .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)) + .route("/api/nickel/to-form", post(nickel_to_form_handler)) + .route("/api/nickel/template", post(nickel_template_handler)) + .route("/api/nickel/roundtrip", post(nickel_roundtrip_handler)) .with_state(form_state); // Server startup with graceful shutdown let addr = SocketAddr::from(([127, 0, 0, 1], self.port)); - let listener = tokio::net::TcpListener::bind(addr).await - .map_err(|e| Error::validation_failed(format!("Failed to bind to port {}: {}", self.port, e)))?; + let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| { + Error::validation_failed(format!("Failed to bind to port {}: {}", self.port, e)) + })?; - let server = axum::serve(listener, app) - .with_graceful_shutdown(async { - shutdown_rx.await.ok(); - }); + let server = axum::serve(listener, app).with_graceful_shutdown(async { + shutdown_rx.await.ok(); + }); let handle = tokio::spawn(async move { if let Err(e) = server.await { @@ -142,7 +148,9 @@ impl FormBackend for WebBackend { #[cfg(not(feature = "web"))] { - Err(Error::validation_failed("Web feature not enabled. Enable with --features web".to_string())) + Err(Error::validation_failed( + "Web feature not enabled. Enable with --features web".to_string(), + )) } } @@ -164,7 +172,10 @@ impl FormBackend for WebBackend { let content_margin_left = item.content_margin_left.unwrap_or(2); let style = format!("margin-left: {}ch;", margin_left); - let border_style = format!("margin-left: {}ch; font-family: monospace;", border_margin_left); + let border_style = format!( + "margin-left: {}ch; font-family: monospace;", + border_margin_left + ); let content_style = format!("margin-left: {}ch;", content_margin_left); // Render top border if specified @@ -181,7 +192,8 @@ impl FormBackend for WebBackend { ); html.push_str(&format!( "
{}
\n", - border_style, html_escape(&border_line) + border_style, + html_escape(&border_line) )); } @@ -189,7 +201,8 @@ impl FormBackend for WebBackend { if let Some(title) = &item.title { html.push_str(&format!( "

{}

\n", - content_style, html_escape(title) + content_style, + html_escape(title) )); } @@ -197,7 +210,8 @@ impl FormBackend for WebBackend { if let Some(content) = &item.content { html.push_str(&format!( "

{}

\n", - content_style, html_escape(content) + content_style, + html_escape(content) )); } @@ -215,7 +229,8 @@ impl FormBackend for WebBackend { ); html.push_str(&format!( "
{}
\n", - border_style, html_escape(&border_line) + border_style, + html_escape(&border_line) )); } @@ -226,8 +241,14 @@ impl FormBackend for WebBackend { Ok(()) } - async fn execute_field(&self, field: &FieldDefinition, _context: &RenderContext) -> Result { - let state = self.state.as_ref() + async fn execute_field( + &self, + field: &FieldDefinition, + _context: &RenderContext, + ) -> Result { + let state = self + .state + .as_ref() .ok_or_else(|| Error::validation_failed("Server not initialized"))?; // Create oneshot channel for field submission @@ -265,7 +286,9 @@ impl FormBackend for WebBackend { Ok(value) } Ok(Err(_)) => Err(Error::cancelled()), - Err(_) => Err(Error::validation_failed("Field submission timeout".to_string())), + Err(_) => Err(Error::validation_failed( + "Field submission timeout".to_string(), + )), } } @@ -275,11 +298,14 @@ impl FormBackend for WebBackend { base_dir: &std::path::Path, items: Vec, fields: Vec, + initial_values: Option>, ) -> Result> { - let state = self.state.as_ref() + let state = self + .state + .as_ref() .ok_or_else(|| Error::validation_failed("Server not initialized"))?; - // Set complete mode flag, cache fields, store base_dir, and store form for lazy loading + // Initialize results with initial values (for field rendering) { let mut is_complete = state.is_complete_mode.write().await; *is_complete = true; @@ -287,19 +313,24 @@ impl FormBackend for WebBackend { let mut cached_fields = state.complete_fields.write().await; *cached_fields = fields.clone(); + let mut cached_items = state.complete_items.write().await; + *cached_items = items.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 { - self.render_display_item(item, &RenderContext { - results: HashMap::new(), - locale: form.locale.clone(), - }).await?; + // Initialize results with initial values + let mut results = state.results.write().await; + *results = initial_values.clone().unwrap_or_default(); + if !results.is_empty() { + eprintln!( + "[web] Complete form initialized with {} default values", + results.len() + ); + } } // Create oneshot channel for complete form submission @@ -309,11 +340,8 @@ impl FormBackend for WebBackend { *complete_tx = Some(tx); } - // Clear any old results and field channels + // Clear field channels (but keep results with initial values) { - let mut results = state.results.write().await; - results.clear(); - let mut channels = state.field_channels.write().await; channels.clear(); } @@ -325,7 +353,9 @@ impl FormBackend for WebBackend { 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()) { + if value.is_null() + || (value.is_string() && value.as_str().unwrap_or("").is_empty()) + { // Required field is empty - would need to show error and retry // For now, just ensure field is in results all_results.insert(field.name.clone(), Value::Null); @@ -336,6 +366,15 @@ impl FormBackend for WebBackend { } } + // Merge back initial values for fields not submitted by user + if let Some(init_vals) = initial_values { + for (k, v) in init_vals.iter() { + if !all_results.contains_key(k) { + all_results.insert(k.clone(), v.clone()); + } + } + } + // Store results in state for reference { let mut results = state.results.write().await; @@ -351,7 +390,9 @@ impl FormBackend for WebBackend { Ok(all_results) } Ok(Err(_)) => Err(Error::cancelled()), - Err(_) => Err(Error::validation_failed("Form submission timeout".to_string())), + Err(_) => Err(Error::validation_failed( + "Form submission timeout".to_string(), + )), } } @@ -363,10 +404,15 @@ impl FormBackend for WebBackend { if let Some(handle) = self.server_handle.take() { match tokio::time::timeout(Duration::from_secs(5), handle).await { Ok(Ok(())) => Ok(()), - Ok(Err(e)) => Err(Error::validation_failed(format!("Server join error: {}", e))), + Ok(Err(e)) => Err(Error::validation_failed(format!( + "Server join error: {}", + e + ))), Err(_) => { // Timeout - server didn't shutdown gracefully - Err(Error::validation_failed("Server shutdown timeout".to_string())) + Err(Error::validation_failed( + "Server shutdown timeout".to_string(), + )) } } } else { @@ -394,47 +440,77 @@ impl FormBackend for WebBackend { /// HTTP Handlers (A-EXTRACTORS-FIRST pattern from AXUM.md) /// Each handler uses State extractor for type-safe access to shared state #[cfg(feature = "web")] -async fn index_handler(State(_state): State>) -> impl IntoResponse { - Html( +async fn index_handler(State(state): State>) -> impl IntoResponse { + // Get form name from state + let form_name = { + let form_opt = state.form.read().await; + form_opt + .as_ref() + .map(|f| f.name.as_str()) + .unwrap_or("Form") + .to_string() + }; + + let html = format!( r#" - Form Inquire + {}
-

Form Inquire

+

{}

- -"# - ) +"#, + html_escape(&form_name), + html_escape(&form_name) + ); + + Html(html) } #[cfg(feature = "web")] async fn get_form_handler(State(state): State>) -> impl IntoResponse { - let display_items = state.display_buffer.read().await; - // Keep only the last 3 display items to avoid overwhelming the page - let recent_items: Vec = display_items.iter().cloned().rev().take(3).collect::>(); - let display_html: String = recent_items.into_iter().rev().collect::>().join("\n"); - // Check if we're in complete form mode let is_complete = *state.is_complete_mode.read().await; + // In complete mode, display items are rendered inline with fields + // In field-by-field mode, display items are shown separately + let display_html = if is_complete { + String::new() // Don't show display_buffer in complete mode + } else { + let display_items = state.display_buffer.read().await; + display_items.iter().cloned().collect::>().join("\n") + }; + let field_html = if is_complete { // Complete form mode: render all fields in a single form let fields = state.complete_fields.read().await; - render_complete_form(&fields) + let items = state.complete_items.read().await; + let results = state.results.read().await; + let base_dir = state.base_dir.read().await; + let base_dir_path = base_dir + .as_ref() + .map(|p| p.as_path()) + .unwrap_or_else(|| std::path::Path::new(".")); + render_complete_form(&items, &fields, &results, base_dir_path) } else { // Field-by-field mode: render single current field let current_field = state.current_field.read().await; @@ -497,7 +588,10 @@ async fn get_dynamic_form_handler( 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())); + return ( + StatusCode::BAD_REQUEST, + Html("
Dynamic form only available in complete mode
".to_string()), + ); } // Convert query parameters to results HashMap for condition evaluation @@ -515,24 +609,29 @@ async fn get_dynamic_form_handler( (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)) => { + 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); + let (fields_html, _modals_and_scripts) = + render_fields_only(&visible_items, &visible_fields, &results, &base_dir); (StatusCode::OK, Html(fields_html)) } Err(_) => { // Fallback: return all fields + let items = state.complete_items.read().await.clone(); let fields = state.complete_fields.read().await.clone(); - let fields_html = render_fields_only(&fields, &results); + let (fields_html, _modals_and_scripts) = + render_fields_only(&items, &fields, &results, &base_dir); (StatusCode::OK, Html(fields_html)) } } } _ => { // Fallback if state not properly initialized + let items = state.complete_items.read().await.clone(); let fields = state.complete_fields.read().await.clone(); - let fields_html = render_fields_only(&fields, &HashMap::new()); + let (fields_html, _modals_and_scripts) = + render_fields_only(&items, &fields, &HashMap::new(), std::path::Path::new(".")); (StatusCode::OK, Html(fields_html)) } } @@ -549,7 +648,8 @@ async fn submit_field_handler( "confirm" => json!(payload.value.to_lowercase() == "true"), "multiselect" => { // Parse comma-separated values into array, filter empty strings - let values: Vec = payload.values + let values: Vec = payload + .values .split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) @@ -570,7 +670,7 @@ async fn submit_field_handler( return ( StatusCode::BAD_REQUEST, headers, - Json(json!({"success": false, "error": e})) + Json(json!({"success": false, "error": e})), ); } @@ -590,12 +690,75 @@ async fn submit_field_handler( return ( StatusCode::BAD_REQUEST, headers, - Json(json!({"success": false, "error": "Content cannot be empty"})) + Json(json!({"success": false, "error": "Content cannot be empty"})), ); } } json!(payload.value) } + "repeating_group" => { + // Parse JSON array for repeating groups + let current_field = state.current_field.read().await; + + match serde_json::from_str::(&payload.value) { + Ok(parsed_value) => { + // Validate it's an array + if !parsed_value.is_array() { + let mut headers = HeaderMap::new(); + headers.insert("HX-Trigger", "formUpdate".parse().unwrap()); + return ( + StatusCode::BAD_REQUEST, + headers, + Json(json!({"success": false, "error": "Value must be a JSON array"})), + ); + } + + // Validate min/max items if field constraints exist + if let Some(field) = current_field.as_ref() { + let array_len = parsed_value.as_array().map(|a| a.len()).unwrap_or(0); + + if let Some(min_items) = field.min_items { + if array_len < min_items { + let mut headers = HeaderMap::new(); + headers.insert("HX-Trigger", "formUpdate".parse().unwrap()); + return ( + StatusCode::BAD_REQUEST, + headers, + Json( + json!({"success": false, "error": format!("Minimum {} items required, got {}", min_items, array_len)}), + ), + ); + } + } + + if let Some(max_items) = field.max_items { + if array_len > max_items { + let mut headers = HeaderMap::new(); + headers.insert("HX-Trigger", "formUpdate".parse().unwrap()); + return ( + StatusCode::BAD_REQUEST, + headers, + Json( + json!({"success": false, "error": format!("Maximum {} items allowed, got {}", max_items, array_len)}), + ), + ); + } + } + } + + parsed_value + } + Err(e) => { + let mut headers = HeaderMap::new(); + headers.insert("HX-Trigger", "formUpdate".parse().unwrap()); + return ( + StatusCode::BAD_REQUEST, + headers, + Json(json!({"success": false, "error": format!("Invalid JSON: {}", e)})), + ); + } + } + } _ => json!(payload.value), }; @@ -617,11 +780,7 @@ async fn submit_field_handler( let mut headers = HeaderMap::new(); headers.insert("HX-Trigger", "formUpdate".parse().unwrap()); - ( - StatusCode::OK, - headers, - Json(json!({"success": true})) - ) + (StatusCode::OK, headers, Json(json!({"success": true}))) } #[cfg(feature = "web")] @@ -673,82 +832,214 @@ async fn submit_complete_form_handler( let mut headers = HeaderMap::new(); headers.insert("HX-Trigger", "formComplete".parse().unwrap()); - ( - StatusCode::OK, - headers, - Json(json!({"success": true})) - ) + (StatusCode::OK, headers, Json(json!({"success": true}))) } #[cfg(feature = "web")] use axum::response::Html; /// Render all fields in a single form for complete mode -fn render_complete_form(fields: &[FieldDefinition]) -> String { - let fields_html = render_form_fields(fields); +fn render_complete_form( + items: &[DisplayItem], + fields: &[FieldDefinition], + results: &HashMap, + base_dir: &std::path::Path, +) -> String { + let (fields_html, modals_and_scripts) = render_form_fields(items, fields, results, base_dir); + + // Generate global repeating group script (injected once, manages all repeating groups independently) + let global_repeating_group_script = render_global_repeating_group_script(); // FASE 4: HTMX polling for reactive rendering // Form wrapper stays static, only fields div is updated dynamically + // Modals and scripts are placed OUTSIDE the form to avoid nested forms format!( - r##"
+ r##"
{} -
-
"##, - fields_html + + {} + {}"##, + fields_html, modals_and_scripts, global_repeating_group_script ) } /// Render just the fields wrapper for reactive updates (JavaScript handles reactivity) -fn render_form_fields(fields: &[FieldDefinition]) -> String { - // Initial render has no state yet, use empty HashMap - let fields_html = render_fields_only(fields, &HashMap::new()); +/// Returns (fields_html, modals_and_scripts) where modals_and_scripts should be placed outside the form +fn render_form_fields( + items: &[DisplayItem], + fields: &[FieldDefinition], + results: &HashMap, + base_dir: &std::path::Path, +) -> (String, String) { + let (fields_html, modals_and_scripts) = render_fields_only(items, fields, results, base_dir); // Wrapper div for vanilla JS reactivity // JavaScript addEventListener listens for change events and updates this div's innerHTML - format!( + let wrapped_fields = format!( r##"
{}
"##, fields_html - ) + ); + + (wrapped_fields, modals_and_scripts) } /// Render field HTML only (no wrapper, for dynamic updates via HTMX) -fn render_fields_only(fields: &[FieldDefinition], results: &HashMap) -> String { - let mut fields_html = String::new(); - for field in fields { - fields_html.push_str(&render_field_for_complete_form(field, results)); +/// Intercalates display items (section headers) with fields based on order +/// Returns (fields_html, modals_and_scripts) where modals_and_scripts contains all modals and JavaScript from RepeatingGroups +fn render_fields_only( + items: &[DisplayItem], + fields: &[FieldDefinition], + results: &HashMap, + base_dir: &std::path::Path, +) -> (String, String) { + let mut html = String::new(); + let mut modals_and_scripts = String::new(); + + // Create a sorted list of (order, element_type) tuples + let mut elements: Vec<(usize, String)> = Vec::new(); + + // Add items with their order + for (idx, item) in items.iter().enumerate() { + elements.push((item.order, format!("item:{}", idx))); + } + + // Add fields with their order + for field in fields { + elements.push((field.order, format!("field:{}", field.name))); + } + + // Sort by order + elements.sort_by_key(|(order, _)| *order); + + // Render elements in order + for (_, element_type) in elements { + if let Some(item_idx) = element_type.strip_prefix("item:") { + let idx: usize = item_idx.parse().unwrap(); + if let Some(item) = items.get(idx) { + html.push_str(&render_display_item_html(item)); + } + } else if let Some(field_name) = element_type.strip_prefix("field:") { + if let Some(field) = fields.iter().find(|f| f.name == field_name) { + let (field_html, field_modals_scripts) = + render_field_for_complete_form(field, results, base_dir); + html.push_str(&field_html); + modals_and_scripts.push_str(&field_modals_scripts); + } + } + } + + (html, modals_and_scripts) +} + +/// Render a display item as HTML +fn render_display_item_html(item: &DisplayItem) -> String { + match item.item_type.as_str() { + "section_header" => { + let mut html = String::new(); + let margin = item.margin_left.unwrap_or(0) * 2; + let border_top = item.border_top.unwrap_or(false); + let border_bottom = item.border_bottom.unwrap_or(false); + let title = item.title.as_deref().unwrap_or(""); + + if border_top { + html.push_str(&format!( + "
╭────────────────────────────────────────╮
\n", + margin + )); + } + + html.push_str(&format!( + "

{}

\n", + margin + 2, + html_escape(title) + )); + + if border_bottom { + html.push_str(&format!( + "
╰────────────────────────────────────────╯
\n", + margin + )); + } + + html.push_str("
\n\n"); + html + } + "text" => { + let content = item.content.as_deref().unwrap_or(""); + let indent = item.margin_left.unwrap_or(0); + format!( + "
{}
\n", + indent * 2, + html_escape(content) + ) + } + "border" => { + let indent = item.margin_left.unwrap_or(0); + let style = item.content.as_deref().unwrap_or("single"); + let border_char = match style { + "thick" => "━", + "double" => "═", + _ => "─", + }; + format!( + "
{}
\n", + indent * 2, + border_char.repeat(40) + ) + } + _ => String::new(), } - fields_html } /// Render a single field for inclusion in the complete form (without individual submit button) /// Includes state injection for selected/checked attributes based on current results -fn render_field_for_complete_form(field: &FieldDefinition, results: &HashMap) -> String { - match field.field_type { +/// Returns (field_html, modals_and_scripts) where modals_and_scripts contains modals/JS for RepeatingGroup +fn render_field_for_complete_form( + field: &FieldDefinition, + results: &HashMap, + base_dir: &std::path::Path, +) -> (String, String) { + let (field_html, modals_and_scripts) = match field.field_type { FieldType::Text => { let placeholder = field.placeholder.as_deref().unwrap_or(""); let default = field.default.as_deref().unwrap_or(""); - format!( - r#"
+ // Use value from results if available (e.g., from initial_values), otherwise use default + let current_value = results + .get(&field.name) + .and_then(|v| v.as_str()) + .unwrap_or(default); + + ( + format!( + r#"
"#, - html_escape(&field.prompt), - html_escape(&field.name), - html_escape(placeholder), - html_escape(default), - if field.required.unwrap_or(false) { "required" } else { "" } + html_escape(&field.prompt), + html_escape(&field.name), + html_escape(placeholder), + html_escape(current_value), + if field.required.unwrap_or(false) { + "required" + } else { + "" + } + ), + String::new(), ) } FieldType::Confirm => { // Get current value from results for state preservation // Handle both boolean values and string "true"/"false" - let current_value = results.get(&field.name) + let current_value = results + .get(&field.name) .and_then(|v| { if let Some(b) = v.as_bool() { Some(b) @@ -758,8 +1049,9 @@ fn render_field_for_complete_form(field: &FieldDefinition, results: &HashMap + ( + format!( + r#"
"#, - html_escape(&field.prompt), - html_escape(&field.name), - if current_value { "checked" } else { "" }, - html_escape(&field.name), - if !current_value { "checked" } else { "" } + html_escape(&field.prompt), + html_escape(&field.name), + if current_value { "checked" } else { "" }, + html_escape(&field.name), + if !current_value { "checked" } else { "" } + ), + String::new(), ) } FieldType::Password => { let placeholder = field.placeholder.as_deref().unwrap_or(""); - format!( - r#"
+ ( + format!( + r#"
- +
"#, - html_escape(&field.prompt), - html_escape(&field.name), - html_escape(placeholder), - if field.required.unwrap_or(false) { "required" } else { "" } + html_escape(&field.prompt), + html_escape(&field.name), + html_escape(placeholder), + if field.required.unwrap_or(false) { + "required" + } else { + "" + } + ), + String::new(), ) } FieldType::Select => { // Get current value from results for state preservation - let current_value = results.get(&field.name) + let current_value = results + .get(&field.name) .and_then(|v| v.as_str()) .unwrap_or(""); - let options = field.options.as_ref().map(|opts| { - opts.iter() + let options = if field.options.is_empty() { + String::new() + } else { + field + .options + .iter() .map(|opt| { - let selected = if opt == current_value { "selected" } else { "" }; - format!("", - html_escape(opt), selected, html_escape(opt)) + let selected = if opt.value == current_value { + "selected" + } else { + "" + }; + format!( + "", + html_escape(&opt.value), + selected, + html_escape(opt.display_label()) + ) }) .collect::>() .join("\n") - }).unwrap_or_default(); + }; - format!( - r#"
+ ( + format!( + r#"
"#, - html_escape(&field.prompt), - html_escape(&field.name), - if field.required.unwrap_or(false) { "required" } else { "" }, - options + html_escape(&field.prompt), + html_escape(&field.name), + if field.required.unwrap_or(false) { + "required" + } else { + "" + }, + options + ), + String::new(), ) } FieldType::MultiSelect => { let field_name = &field.name; // Get current selected values from results for state preservation - let selected_values: Vec = results.get(field_name) + let selected_values: Vec = results + .get(field_name) .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) .unwrap_or_default(); - let options = field.options.as_ref().map(|opts| { - opts.iter() + let options = if field.options.is_empty() { + String::new() + } else { + field + .options + .iter() .map(|opt| { - let checked = if selected_values.contains(&opt.to_string()) { "checked" } else { "" }; + let checked = if selected_values.contains(&opt.value) { + "checked" + } else { + "" + }; format!( r#""#, html_escape(field_name), - html_escape(opt), + html_escape(&opt.value), checked, - html_escape(opt) + html_escape(opt.display_label()) ) }) .collect::>() .join("\n") - }).unwrap_or_default(); + }; - format!( - r#"
+ ( + format!( + r#"
@@ -863,11 +1198,13 @@ fn render_field_for_complete_form(field: &FieldDefinition, results: &HashMap
"#, - html_escape(&field.prompt), - html_escape(field_name), - html_escape(field_name), - options, - html_escape(field_name) + html_escape(&field.prompt), + html_escape(field_name), + html_escape(field_name), + options, + html_escape(field_name) + ), + String::new(), ) } FieldType::Custom => { @@ -875,18 +1212,25 @@ fn render_field_for_complete_form(field: &FieldDefinition, results: &HashMap + ( + format!( + r#"
{}
"#, - html_escape(&field.prompt), - html_escape(&field.name), - html_escape(placeholder), - html_escape(&type_hint), - if field.required.unwrap_or(false) { "required" } else { "" }, - html_escape(&type_hint) + html_escape(&field.prompt), + html_escape(&field.name), + html_escape(placeholder), + html_escape(&type_hint), + if field.required.unwrap_or(false) { + "required" + } else { + "" + }, + html_escape(&type_hint) + ), + String::new(), ) } FieldType::Editor => { @@ -894,40 +1238,62 @@ fn render_field_for_complete_form(field: &FieldDefinition, results: &HashMap + ( + format!( + r#"
File type: {}
"#, - html_escape(&field.prompt), - html_escape(&field.name), - rows, - cols, - if field.required.unwrap_or(false) { "required" } else { "" }, - html_escape(prefix_text), - html_escape(field.file_extension.as_deref().unwrap_or("text")) + html_escape(&field.prompt), + html_escape(&field.name), + rows, + cols, + if field.required.unwrap_or(false) { + "required" + } else { + "" + }, + html_escape(prefix_text), + html_escape(field.file_extension.as_deref().unwrap_or("text")) + ), + String::new(), ) } FieldType::Date => { - let value = field.default.as_deref().unwrap_or(""); + let default_value = field.default.as_deref().unwrap_or(""); + // Use value from results if available (e.g., from initial_values), otherwise use default + let current_value = results + .get(&field.name) + .and_then(|v| v.as_str()) + .unwrap_or(default_value); let min = field.min_date.as_deref().unwrap_or(""); let max = field.max_date.as_deref().unwrap_or(""); - format!( - r#"
+ ( + format!( + r#"
"#, - html_escape(&field.prompt), - html_escape(&field.name), - html_escape(value), - html_escape(min), - html_escape(max), - if field.required.unwrap_or(false) { "required" } else { "" } + html_escape(&field.prompt), + html_escape(&field.name), + html_escape(current_value), + html_escape(min), + html_escape(max), + if field.required.unwrap_or(false) { + "required" + } else { + "" + } + ), + String::new(), ) } - } + FieldType::RepeatingGroup => render_repeating_group_field(field, results, base_dir), + }; + + (field_html, modals_and_scripts) } /// Render a field to HTML based on its type (A-EXTRACTORS-FIRST supports diverse types) @@ -969,7 +1335,7 @@ fn render_field_to_html(field: &FieldDefinition) -> String {
- +
"#, @@ -978,12 +1344,22 @@ fn render_field_to_html(field: &FieldDefinition) -> String { ) } FieldType::Select => { - let options = field.options.as_ref().map(|opts| { - opts.iter() - .map(|opt| format!("", html_escape(opt), html_escape(opt))) + let options = if field.options.is_empty() { + String::new() + } else { + field + .options + .iter() + .map(|opt| { + format!( + "", + html_escape(&opt.value), + html_escape(opt.display_label()) + ) + }) .collect::>() .join("\n") - }).unwrap_or_default(); + }; format!( r#"
@@ -1003,19 +1379,25 @@ fn render_field_to_html(field: &FieldDefinition) -> String { } FieldType::MultiSelect => { let field_name = &field.name; - let options = field.options.as_ref().map(|opts| { - opts.iter() - .map(|opt| format!( - r#"