diff options
Diffstat (limited to 'src/core/components/form_builder.rs')
| -rw-r--r-- | src/core/components/form_builder.rs | 371 |
1 files changed, 371 insertions, 0 deletions
diff --git a/src/core/components/form_builder.rs b/src/core/components/form_builder.rs new file mode 100644 index 0000000..30f25ef --- /dev/null +++ b/src/core/components/form_builder.rs @@ -0,0 +1,371 @@ +use eframe::egui; +use serde_json::Value; +use std::collections::HashMap; + +use super::help::{show_help_window, HelpWindowOptions}; +use egui_commonmark::CommonMarkCache; +use egui_phosphor::regular as icons; + +/// Field types supported by the generic editor +#[derive(Clone)] +pub enum FieldType { + Text, + #[allow(dead_code)] + Dropdown(Vec<(String, String)>), // (value, label) + MultilineText, + Checkbox, + Date, // simple single-line date input (YYYY-MM-DD) +} + +/// Definition of an editable field +#[derive(Clone)] +pub struct EditorField { + pub name: String, + pub label: String, + pub field_type: FieldType, + pub required: bool, + pub read_only: bool, +} + +/// Replacement for FormBuilder that uses egui_form + garde for validation. +/// Maintains compatibility with existing EditorField schema. +pub struct FormBuilder { + pub title: String, + pub fields: Vec<EditorField>, + pub data: HashMap<String, String>, // Store as strings for form editing + pub original_data: serde_json::Map<String, Value>, // Store original JSON data + pub show: bool, + pub item_id: Option<String>, + pub is_new: bool, + field_help: HashMap<String, String>, + pub form_help_text: Option<String>, + pub show_form_help: bool, + help_cache: CommonMarkCache, +} + +impl FormBuilder { + pub fn new(title: impl Into<String>, fields: Vec<EditorField>) -> Self { + Self { + title: title.into(), + fields, + data: HashMap::new(), + original_data: serde_json::Map::new(), + show: false, + item_id: None, + is_new: false, + field_help: HashMap::new(), + form_help_text: None, + show_form_help: false, + help_cache: CommonMarkCache::default(), + } + } + + #[allow(dead_code)] + pub fn with_help(mut self, help_text: impl Into<String>) -> Self { + self.form_help_text = Some(help_text.into()); + self + } + + pub fn open(&mut self, item: &Value) { + self.show = true; + self.data.clear(); + self.original_data.clear(); + + // Convert JSON to string map + if let Some(obj) = item.as_object() { + self.original_data = obj.clone(); + for (k, v) in obj { + let value_str = match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + _ => serde_json::to_string(v).unwrap_or_default(), + }; + self.data.insert(k.clone(), value_str); + } + + self.item_id = obj.get("id").and_then(|v| match v { + Value::String(s) => Some(s.clone()), + Value::Number(n) => n.as_i64().map(|i| i.to_string()), + _ => None, + }); + self.is_new = false; + } + } + + pub fn open_new(&mut self, preset: Option<&serde_json::Map<String, Value>>) { + self.show = true; + self.data.clear(); + + if let Some(p) = preset { + for (k, v) in p { + let value_str = match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + _ => serde_json::to_string(v).unwrap_or_default(), + }; + self.data.insert(k.clone(), value_str); + } + } + + self.item_id = None; + self.is_new = true; + } + + pub fn close(&mut self) { + self.show = false; + self.data.clear(); + self.original_data.clear(); + self.item_id = None; + self.is_new = false; + } + + /// Show the form editor and return Some(data) if saved, None if still open + pub fn show_editor( + &mut self, + ctx: &egui::Context, + ) -> Option<Option<serde_json::Map<String, Value>>> { + if !self.show { + return None; + } + + let mut result = None; + let mut close_requested = false; + + // Dynamic sizing + let root_bounds = ctx.available_rect(); + let screen_bounds = ctx.input(|i| { + i.viewport().inner_rect.unwrap_or(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(800.0, 600.0), + )) + }); + let horizontal_margin = 24.0; + let vertical_margin = 24.0; + + let max_w = (root_bounds.width() - horizontal_margin) + .min(screen_bounds.width() - horizontal_margin) + .max(260.0); + let max_h = (root_bounds.height() - vertical_margin) + .min(screen_bounds.height() - vertical_margin) + .max(260.0); + + let default_w = (root_bounds.width() * 0.6).clamp(320.0, max_w); + let default_h = (root_bounds.height() * 0.7).clamp(300.0, max_h); + let content_max_h = (max_h - 160.0).max(180.0); + let _window_response = egui::Window::new(&self.title) + .collapsible(false) + .resizable(true) + .default_width(default_w) + .default_height(default_h) + .min_width(f32::min(280.0, max_w)) + .min_height(f32::min(260.0, max_h)) + .max_width(max_w) + .max_height(max_h) + .open(&mut self.show) + .show(ctx, |ui| { + egui::ScrollArea::vertical() + .max_height(content_max_h) + .show(ui, |ui| { + ui.vertical(|ui| { + for field in &self.fields.clone() { + let field_value = self + .data + .entry(field.name.clone()) + .or_insert_with(String::new); + + match &field.field_type { + FieldType::Text => { + let label_text = if field.required { + format!("{} *", field.label) + } else { + field.label.clone() + }; + ui.label(&label_text); + ui.add( + egui::TextEdit::singleline(field_value) + .desired_width(f32::INFINITY) + .interactive(!field.read_only), + ); + } + FieldType::MultilineText => { + let label_text = if field.required { + format!("{} *", field.label) + } else { + field.label.clone() + }; + ui.label(&label_text); + ui.add( + egui::TextEdit::multiline(field_value) + .desired_width(f32::INFINITY) + .interactive(!field.read_only), + ); + } + FieldType::Checkbox => { + let mut checked = + field_value == "true" || field_value == "1"; + ui.add_enabled( + !field.read_only, + egui::Checkbox::new(&mut checked, &field.label), + ); + if !field.read_only { + *field_value = if checked { + "true".to_string() + } else { + "false".to_string() + }; + } + } + FieldType::Date => { + let label_text = if field.required { + format!("{} *", field.label) + } else { + field.label.clone() + }; + ui.label(&label_text); + ui.add( + egui::TextEdit::singleline(field_value) + .hint_text("YYYY-MM-DD") + .desired_width(f32::INFINITY) + .interactive(!field.read_only), + ); + } + FieldType::Dropdown(options) => { + let label_text = if field.required { + format!("{} *", field.label) + } else { + field.label.clone() + }; + ui.label(&label_text); + ui.add_enabled_ui(!field.read_only, |ui| { + egui::ComboBox::from_id_salt(&field.name) + .width(ui.available_width()) + .selected_text( + options + .iter() + .find(|(v, _)| v == field_value) + .map(|(_, l)| l.as_str()) + .unwrap_or(""), + ) + .show_ui(ui, |ui| { + for (value, label) in options { + ui.selectable_value( + field_value, + value.clone(), + label, + ); + } + }); + }); + } + } + + // Show help text if available + if let Some(help) = self.field_help.get(&field.name) { + ui.label( + egui::RichText::new(help) + .small() + .color(ui.visuals().weak_text_color()), + ); + } + + ui.add_space(8.0); + } + }); + }); + + ui.separator(); + + ui.horizontal(|ui| { + // Help button if help text is available + if self.form_help_text.is_some() { + if ui.button(format!("{} Help", icons::QUESTION)).clicked() { + self.show_form_help = true; + } + ui.separator(); + } + + // Submit button + if ui.button(format!("{} Save", icons::CHECK)).clicked() { + // Validate required fields + let mut missing_fields = Vec::new(); + for field in &self.fields { + if field.required { + let value = + self.data.get(&field.name).map(|s| s.as_str()).unwrap_or(""); + if value.trim().is_empty() { + missing_fields.push(field.label.clone()); + } + } + } + + if !missing_fields.is_empty() { + log::warn!("Missing required fields: {}", missing_fields.join(", ")); + // Show error in UI - for now just log, could add error message field + } else { + // Convert string map back to JSON + let mut json_map = serde_json::Map::new(); + for (k, v) in &self.data { + // Try to preserve types + let json_value = if v == "true" { + Value::Bool(true) + } else if v == "false" { + Value::Bool(false) + } else if let Ok(n) = v.parse::<i64>() { + Value::Number(n.into()) + } else if let Ok(n) = v.parse::<f64>() { + serde_json::Number::from_f64(n) + .map(Value::Number) + .unwrap_or_else(|| Value::String(v.clone())) + } else if v.is_empty() { + Value::Null + } else { + Value::String(v.clone()) + }; + json_map.insert(k.clone(), json_value); + } + + // CRITICAL: Include the item_id so updates work + if let Some(ref id) = self.item_id { + json_map.insert( + "__editor_item_id".to_string(), + Value::String(id.clone()), + ); + } + + result = Some(Some(json_map)); + close_requested = true; + } + } + + if ui.button(format!("{} Cancel", icons::X)).clicked() { + result = Some(None); + close_requested = true; + } + }); + }); + if close_requested || !self.show { + self.close(); + } + + // Show help window if requested + if let Some(help_text) = &self.form_help_text { + if self.show_form_help { + show_help_window( + ctx, + &mut self.help_cache, + format!("{}_help", self.title), + &format!("{} - Help", self.title), + help_text, + &mut self.show_form_help, + HelpWindowOptions::default(), + ); + } + } + + result + } +} |
