Skip to main content

nightshade_api/web/
dynamic_form.rs

1//! Schema-driven form rendering: describe fields with [`FormField`] and [`FieldSchema`]
2//! and let [`DynamicForm`] build the inputs and collect their values as JSON.
3
4use leptos::prelude::*;
5use serde_json::{Map, Value};
6
7/// The type of a form field, which determines the control [`DynamicForm`] renders and the
8/// JSON shape of its value.
9#[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/// A single field in a [`DynamicForm`], pairing a JSON `key` and display `label` with its
23/// [`FieldSchema`].
24#[derive(Clone)]
25pub struct FormField {
26    /// The key under which this field's value is stored in the output JSON object.
27    pub key: String,
28    /// The label shown next to the control.
29    pub label: String,
30    /// The field type describing which control to render.
31    pub schema: FieldSchema,
32}
33
34impl FormField {
35    /// Creates a [`FormField`] from a key, label, and schema.
36    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/// Renders a form from a list of [`FormField`]s, maintaining the collected values as a
237/// JSON object. Emits the object through `on_change` on every edit and, when an
238/// `on_submit` callback is provided, shows a submit button labelled `submit_label`.
239#[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}