aboutsummaryrefslogtreecommitdiff
path: root/src/core/components/form_builder.rs
diff options
context:
space:
mode:
authorUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
committerUMTS at Teleco <crt@teleco.ch>2025-12-13 02:51:15 +0100
commit8323fdd73272a2882781aba3c499ba0be3dff2a6 (patch)
treeffbf86473933e69cfaeef30d5c6ea7e5b494856c /src/core/components/form_builder.rs
committing to insanityHEADmaster
Diffstat (limited to 'src/core/components/form_builder.rs')
-rw-r--r--src/core/components/form_builder.rs371
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
+ }
+}