skill_web/components/run/
inline_parameter_editor.rs1use 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 pub parameters: Vec<ParameterInfo>,
20 pub values: HashMap<String, serde_json::Value>,
22 pub on_change: Callback<(String, serde_json::Value)>,
24 #[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 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 let make_on_keydown = {
47 |_param_name: String| {
48 Callback::from(move |e: KeyboardEvent| {
49 if e.key() == "Enter" {
51 e.prevent_default(); }
53 })
54 }
55 };
56
57 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 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 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 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 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 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 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 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 <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
158fn 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(¶m.name)
171 .and_then(|v| serde_json::to_string(v).ok())
172 .unwrap_or_default();
173 let has_error = props.errors.contains_key(¶m.name);
174 let error_msg = props.errors.get(¶m.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 <label class="flex items-center gap-2">
182 <span class="text-sm font-medium text-gray-700 dark:text-gray-300">
183 { ¶m.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 { ¶m.param_type }
192 </span>
193 </label>
194
195 if !param.description.is_empty() {
197 <p class="text-xs text-gray-600 dark:text-gray-400">
198 { ¶m.description }
199 </p>
200 }
201
202 <div class="relative flex items-center gap-2">
204 if is_complex {
205 <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 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 <div class="flex-shrink-0 w-6 h-6">
292 if has_error {
293 <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 <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 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}