skill_web/components/run/
inline_parameter_editor.rs

1//! Inline Parameter Editor - Compact parameter form that appears below search
2//!
3//! Features:
4//! - Inline single-line inputs with validation
5//! - Tab navigation between fields
6//! - Real-time validation (green checkmark / red X)
7//! - JSON editor for complex parameters
8//! - Auto-focus first required field
9
10use yew::prelude::*;
11use web_sys::{HtmlInputElement, HtmlTextAreaElement, KeyboardEvent};
12use wasm_bindgen::JsCast;
13use std::collections::HashMap;
14use crate::api::types::ParameterInfo;
15
16#[derive(Properties, PartialEq)]
17pub struct InlineParameterEditorProps {
18    /// Parameter definitions from the tool
19    pub parameters: Vec<ParameterInfo>,
20    /// Current parameter values
21    pub values: HashMap<String, serde_json::Value>,
22    /// Callback when a parameter value changes
23    pub on_change: Callback<(String, serde_json::Value)>,
24    /// Validation errors for parameters
25    #[prop_or_default]
26    pub errors: HashMap<String, String>,
27}
28
29#[function_component(InlineParameterEditor)]
30pub fn inline_parameter_editor(props: &InlineParameterEditorProps) -> Html {
31    let first_input_ref = use_node_ref();
32    let should_focus_first = use_state(|| true);
33
34    // Focus first required field on mount
35    use_effect_with((first_input_ref.clone(), should_focus_first.clone()), |(input_ref, should_focus)| {
36        if **should_focus {
37            if let Some(input) = input_ref.cast::<HtmlInputElement>() {
38                let _ = input.focus();
39                should_focus.set(false);
40            }
41        }
42        || ()
43    });
44
45    // Handle Tab key for navigation (allow default browser behavior)
46    let make_on_keydown = {
47        |_param_name: String| {
48            Callback::from(move |e: KeyboardEvent| {
49                // Allow Tab for natural navigation, Enter submits the form
50                if e.key() == "Enter" {
51                    e.prevent_default(); // Prevent form submission, let parent handle execution
52                }
53            })
54        }
55    };
56
57    // Handle input change for text/number parameters
58    let make_on_input = {
59        let on_change = props.on_change.clone();
60        move |param_name: String, param_type: String| {
61            let on_change = on_change.clone();
62            Callback::from(move |e: InputEvent| {
63                let input: HtmlInputElement = e.target().unwrap().dyn_into().unwrap();
64                let value = input.value();
65
66                // Parse value based on type
67                let json_value = match param_type.as_str() {
68                    "number" | "integer" => {
69                        value.parse::<i64>()
70                            .map(|n| serde_json::json!(n))
71                            .unwrap_or_else(|_| serde_json::json!(value))
72                    }
73                    "boolean" => {
74                        let bool_val = value.to_lowercase() == "true" || value == "1";
75                        serde_json::json!(bool_val)
76                    }
77                    "array" | "object" => {
78                        // Try to parse as JSON
79                        serde_json::from_str(&value)
80                            .unwrap_or_else(|_| serde_json::json!(value))
81                    }
82                    _ => serde_json::json!(value),
83                };
84
85                on_change.emit((param_name.clone(), json_value));
86            })
87        }
88    };
89
90    // Handle textarea change for complex parameters
91    let make_on_textarea_change = {
92        let on_change = props.on_change.clone();
93        move |param_name: String| {
94            let on_change = on_change.clone();
95            Callback::from(move |e: InputEvent| {
96                let textarea: HtmlTextAreaElement = e.target().unwrap().dyn_into().unwrap();
97                let value = textarea.value();
98
99                // Try to parse as JSON for validation
100                let json_value = serde_json::from_str(&value)
101                    .unwrap_or_else(|_| serde_json::json!(value));
102
103                on_change.emit((param_name.clone(), json_value));
104            })
105        }
106    };
107
108    if props.parameters.is_empty() {
109        return html! {
110            <div class="text-center py-4">
111                <span class="text-sm text-gray-500 dark:text-gray-400">
112                    { "No parameters required" }
113                </span>
114            </div>
115        };
116    }
117
118    // Group parameters by required/optional
119    let (required_params, optional_params): (Vec<_>, Vec<_>) =
120        props.parameters.iter().partition(|p| p.required);
121
122    html! {
123        <div class="space-y-6">
124            // Required Parameters Section
125            if !required_params.is_empty() {
126                <div class="bg-primary-50 dark:bg-primary-900/20 p-4 rounded-lg space-y-4">
127                    <h4 class="text-sm font-semibold text-gray-900 dark:text-white">
128                        { "Required Parameters" }
129                    </h4>
130                    { for required_params.iter().enumerate().map(|(idx, param)| {
131                        let is_first = idx == 0;
132                        render_parameter(param, is_first, &props, &first_input_ref, &make_on_input, &make_on_textarea_change, &make_on_keydown)
133                    }) }
134                </div>
135            }
136
137            // Optional Parameters Section
138            if !optional_params.is_empty() {
139                <div class="space-y-4">
140                    <h4 class="text-sm font-medium text-gray-700 dark:text-gray-300">
141                        { "Optional Parameters" }
142                    </h4>
143                    { for optional_params.iter().map(|param| {
144                        render_parameter(param, false, &props, &first_input_ref, &make_on_input, &make_on_textarea_change, &make_on_keydown)
145                    }) }
146                </div>
147            }
148
149            // Helper text
150            <div class="text-xs text-gray-500 dark:text-gray-400 pt-2 border-t border-gray-200 dark:border-gray-700">
151                { "Press Tab to move between fields. Required fields marked with " }
152                <span class="text-error-500">{ "*" }</span>
153            </div>
154        </div>
155    }
156}
157
158// Helper function to render a single parameter
159fn render_parameter(
160    param: &ParameterInfo,
161    is_first: bool,
162    props: &InlineParameterEditorProps,
163    first_input_ref: &NodeRef,
164    make_on_input: &impl Fn(String, String) -> Callback<InputEvent>,
165    make_on_textarea_change: &impl Fn(String) -> Callback<InputEvent>,
166    make_on_keydown: &impl Fn(String) -> Callback<KeyboardEvent>,
167) -> Html {
168    let param_name = param.name.clone();
169    let param_type = param.param_type.clone();
170    let current_value = props.values.get(&param.name)
171        .and_then(|v| serde_json::to_string(v).ok())
172        .unwrap_or_default();
173    let has_error = props.errors.contains_key(&param.name);
174    let error_msg = props.errors.get(&param.name).cloned();
175    let is_valid = !param.required || !current_value.is_empty();
176    let is_complex = matches!(param_type.as_str(), "array" | "object");
177
178    html! {
179        <div class="space-y-2">
180            // Parameter label with required indicator and type badge
181            <label class="flex items-center gap-2">
182                <span class="text-sm font-medium text-gray-700 dark:text-gray-300">
183                    { &param.name }
184                </span>
185                if param.required {
186                    <span class="text-xs text-error-500">
187                        { "*" }
188                    </span>
189                }
190                <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300">
191                    { &param.param_type }
192                </span>
193            </label>
194
195            // Parameter description
196            if !param.description.is_empty() {
197                <p class="text-xs text-gray-600 dark:text-gray-400">
198                    { &param.description }
199                </p>
200            }
201
202            // Input field with validation indicator
203            <div class="relative flex items-center gap-2">
204                if is_complex {
205                    // Textarea for arrays/objects
206                    <textarea
207                        class={classes!(
208                            "input",
209                            "w-full",
210                            "font-mono",
211                            "text-sm",
212                            has_error.then(|| "border-error-500 focus:border-error-500 focus:ring-error-500")
213                        )}
214                        placeholder={format!("Enter {} (JSON format)", param.param_type)}
215                        value={current_value.clone()}
216                        oninput={make_on_textarea_change(param_name.clone())}
217                        rows="3"
218                    />
219                } else {
220                    // Single-line input for simple types
221                    if is_first {
222                        <input
223                            ref={first_input_ref.clone()}
224                            type={match param_type.as_str() {
225                                "number" | "integer" => "number",
226                                "boolean" => "checkbox",
227                                _ => "text"
228                            }}
229                            class={classes!(
230                                "input",
231                                "flex-1",
232                                has_error.then(|| "border-error-500 focus:border-error-500 focus:ring-error-500")
233                            )}
234                            placeholder={param.default_value.as_ref()
235                                .map(|d| format!("Default: {}", d))
236                                .unwrap_or_else(|| format!("Enter {}", param.name))}
237                            value={current_value.clone()}
238                            oninput={make_on_input(param_name.clone(), param_type.clone())}
239                            onkeydown={make_on_keydown(param_name.clone())}
240                        />
241                    } else {
242                        <input
243                            type={match param_type.as_str() {
244                                "number" | "integer" => "number",
245                                "boolean" => "checkbox",
246                                _ => "text"
247                            }}
248                            class={classes!(
249                                if param_type == "boolean" { "checkbox checkbox-primary" } else { "input w-full" },
250                                if param_type != "boolean" { Some("flex-1") } else { None },
251                                has_error.then(|| "border-error-500 focus:border-error-500 focus:ring-error-500")
252                            )}
253                            placeholder={if param_type == "boolean" {
254                                String::new()
255                            } else {
256                                param.default_value.as_ref()
257                                    .map(|d| format!("Default: {}", d))
258                                    .unwrap_or_else(|| format!("Enter {}", param.name))
259                            }}
260                            checked={if param_type == "boolean" {
261                                current_value == "true"
262                            } else {
263                                false
264                            }}
265                            value={if param_type == "boolean" {
266                                "true".to_string()
267                            } else {
268                                current_value.clone()
269                            }}
270                            onchange={if param_type == "boolean" {
271                                let on_change = props.on_change.clone();
272                                let param_name = param_name.clone();
273                                Some(Callback::from(move |e: Event| {
274                                    let input: HtmlInputElement = e.target_unchecked_into();
275                                    on_change.emit((param_name.clone(), serde_json::json!(input.checked())));
276                                }))
277                            } else {
278                                None
279                            }}
280                            oninput={if param_type != "boolean" {
281                                Some(make_on_input(param_name.clone(), param_type.clone()))
282                            } else {
283                                None
284                            }}
285                            onkeydown={make_on_keydown(param_name.clone())}
286                        />
287                    }
288                }
289
290                // Validation indicator
291                <div class="flex-shrink-0 w-6 h-6">
292                    if has_error {
293                        // Red X for error
294                        <svg class="w-6 h-6 text-error-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
295                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
296                        </svg>
297                    } else if is_valid && !current_value.is_empty() {
298                        // Green checkmark for valid
299                        <svg class="w-6 h-6 text-success-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
300                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
301                        </svg>
302                    }
303                </div>
304            </div>
305
306            // Error message
307            if let Some(error) = error_msg {
308                <p class="text-xs text-error-500 mt-1">
309                    { error }
310                </p>
311            }
312        </div>
313    }
314}