nightshade_api/web/
dynamic_form.rs1use leptos::prelude::*;
5use serde_json::{Map, Value};
6
7#[derive(Clone)]
10pub enum FieldSchema {
11 Number {
12 min: Option<f64>,
13 max: Option<f64>,
14 integer: bool,
15 },
16 Text,
17 Bool,
18 Enum(Vec<String>),
19 Vector(usize),
20}
21
22#[derive(Clone)]
25pub struct FormField {
26 pub key: String,
28 pub label: String,
30 pub schema: FieldSchema,
32}
33
34impl FormField {
35 pub fn new(key: impl Into<String>, label: impl Into<String>, schema: FieldSchema) -> Self {
37 Self {
38 key: key.into(),
39 label: label.into(),
40 schema,
41 }
42 }
43}
44
45fn default_value(schema: &FieldSchema) -> Value {
46 match schema {
47 FieldSchema::Number { .. } => Value::from(0.0),
48 FieldSchema::Text => Value::from(""),
49 FieldSchema::Bool => Value::from(false),
50 FieldSchema::Enum(options) => Value::from(options.first().cloned().unwrap_or_default()),
51 FieldSchema::Vector(size) => Value::from(vec![0.0_f64; *size]),
52 }
53}
54
55type FormState = RwSignal<Map<String, Value>>;
56
57fn number_control(
58 key: String,
59 min: Option<f64>,
60 max: Option<f64>,
61 integer: bool,
62 state: FormState,
63 emit: impl Fn() + Copy + 'static,
64) -> AnyView {
65 let read_key = key.clone();
66 view! {
67 <input
68 type="number"
69 min=min.map(|value| value.to_string())
70 max=max.map(|value| value.to_string())
71 step=if integer { "1" } else { "any" }
72 prop:value=move || {
73 state.with(|map| {
74 map.get(&read_key).and_then(Value::as_f64).unwrap_or(0.0).to_string()
75 })
76 }
77 on:input=move |event| {
78 if let Ok(parsed) = event_target_value(&event).parse::<f64>() {
79 let parsed = if integer { parsed.round() } else { parsed };
80 state.update(|map| {
81 map.insert(key.clone(), Value::from(parsed));
82 });
83 emit();
84 }
85 }
86 />
87 }
88 .into_any()
89}
90
91fn vector_control(
92 key: String,
93 size: usize,
94 state: FormState,
95 emit: impl Fn() + Copy + 'static,
96) -> AnyView {
97 let axes = (0..size)
98 .map(|index| {
99 let key = key.clone();
100 let read_key = key.clone();
101 view! {
102 <input
103 type="number"
104 step="any"
105 class="nightshade-vec-input"
106 prop:value=move || {
107 state.with(|map| {
108 map.get(&read_key)
109 .and_then(Value::as_array)
110 .and_then(|array| array.get(index))
111 .and_then(Value::as_f64)
112 .unwrap_or(0.0)
113 .to_string()
114 })
115 }
116 on:input=move |event| {
117 if let Ok(parsed) = event_target_value(&event).parse::<f64>() {
118 state.update(|map| {
119 let mut values = map
120 .get(&key)
121 .and_then(Value::as_array)
122 .map(|array| {
123 array
124 .iter()
125 .map(|value| value.as_f64().unwrap_or(0.0))
126 .collect::<Vec<_>>()
127 })
128 .unwrap_or_else(|| vec![0.0; size]);
129 if values.len() < size {
130 values.resize(size, 0.0);
131 }
132 values[index] = parsed;
133 map.insert(key.clone(), Value::from(values));
134 });
135 emit();
136 }
137 }
138 />
139 }
140 })
141 .collect_view();
142 view! { <div class="nightshade-vec-field">{axes}</div> }.into_any()
143}
144
145fn field_view(field: FormField, state: FormState, emit: impl Fn() + Copy + 'static) -> AnyView {
146 let label = field.label.clone();
147 let control = match field.schema {
148 FieldSchema::Number { min, max, integer } => {
149 number_control(field.key, min, max, integer, state, emit)
150 }
151 FieldSchema::Text => {
152 let key = field.key.clone();
153 let read_key = field.key.clone();
154 view! {
155 <input
156 type="text"
157 prop:value=move || {
158 state.with(|map| {
159 map.get(&read_key)
160 .and_then(Value::as_str)
161 .unwrap_or("")
162 .to_string()
163 })
164 }
165 on:input=move |event| {
166 state.update(|map| {
167 map.insert(key.clone(), Value::from(event_target_value(&event)));
168 });
169 emit();
170 }
171 />
172 }
173 .into_any()
174 }
175 FieldSchema::Bool => {
176 let key = field.key.clone();
177 let read_key = field.key.clone();
178 view! {
179 <input
180 type="checkbox"
181 prop:checked=move || {
182 state.with(|map| {
183 map.get(&read_key).and_then(Value::as_bool).unwrap_or(false)
184 })
185 }
186 on:change=move |event| {
187 state.update(|map| {
188 map.insert(key.clone(), Value::from(event_target_checked(&event)));
189 });
190 emit();
191 }
192 />
193 }
194 .into_any()
195 }
196 FieldSchema::Enum(options) => {
197 let key = field.key.clone();
198 let read_key = field.key.clone();
199 view! {
200 <select
201 class="nightshade-select"
202 prop:value=move || {
203 state.with(|map| {
204 map.get(&read_key)
205 .and_then(Value::as_str)
206 .unwrap_or("")
207 .to_string()
208 })
209 }
210 on:change=move |event| {
211 state.update(|map| {
212 map.insert(key.clone(), Value::from(event_target_value(&event)));
213 });
214 emit();
215 }
216 >
217 {options
218 .into_iter()
219 .map(|option| view! { <option value=option.clone()>{option.clone()}</option> })
220 .collect_view()}
221 </select>
222 }
223 .into_any()
224 }
225 FieldSchema::Vector(size) => vector_control(field.key, size, state, emit),
226 };
227 view! {
228 <label class="nightshade-field">
229 <span class="nightshade-field-label">{label}</span>
230 {control}
231 </label>
232 }
233 .into_any()
234}
235
236#[component]
240pub fn DynamicForm(
241 fields: Vec<FormField>,
242 #[prop(optional)] on_change: Option<Callback<Value>>,
243 #[prop(optional)] on_submit: Option<Callback<Value>>,
244 #[prop(into, optional)] submit_label: String,
245) -> impl IntoView {
246 let state: FormState = RwSignal::new({
247 let mut map = Map::new();
248 for field in &fields {
249 map.insert(field.key.clone(), default_value(&field.schema));
250 }
251 map
252 });
253 let emit = move || {
254 if let Some(callback) = on_change {
255 callback.run(Value::Object(state.get_untracked()));
256 }
257 };
258 let submit_label = if submit_label.is_empty() {
259 "Submit".to_string()
260 } else {
261 submit_label
262 };
263 let rows = fields
264 .into_iter()
265 .map(|field| field_view(field, state, emit))
266 .collect_view();
267 view! {
268 <div class="nightshade-dynamic-form">
269 {rows}
270 {on_submit
271 .map(|callback| {
272 view! {
273 <button
274 class="nightshade-button primary"
275 on:click=move |_| callback.run(Value::Object(state.get_untracked()))
276 >
277 {submit_label}
278 </button>
279 }
280 })}
281 </div>
282 }
283}