Skip to main content

hyle_dioxus/
filter.rs

1use dioxus::prelude::*;
2
3use hyle::{FieldType, Primitive};
4
5use crate::context::{use_hyle_components, HyleConfig};
6use crate::types::{field_type_key, HyleComponents, HyleFilterField, HyleFilterFieldProps, HyleFiltersState, HyleValueProps};
7
8/// A reactive filter input derived from `HyleFiltersState`.
9///
10/// Looks up the field definition in `state.fields`, reads the current value
11/// from `state.form_data`, and calls `state.set_field` on change.
12///
13/// Rendering priority:
14/// 1. Per-field `render` function stored in the `HyleFilterField` (from `use_filters` change map)
15/// 2. Matching entry in the `HyleComponents` context (registered via `use_hyle_components`)
16/// 3. Built-in default based on field type:
17///    - `Boolean` → `<select>` with Any / Yes / No
18///    - `Reference` → `<select>` populated from pre-resolved options
19///    - `Array<Reference>` → `<fieldset>` with one checkbox per option
20///    - Everything else → `<input type="text">` (or the `input.kind` hint from the blueprint)
21///
22/// # Props
23/// - `state` — the `HyleFiltersState` from `use_filters`
24/// - `field_key` — the field name to render
25#[component]
26pub fn FilterField(
27    state: HyleFiltersState,
28    field_key: String,
29) -> Element {
30    let filter_field: Option<HyleFilterField> = state
31        .fields
32        .read()
33        .iter()
34        .find(|f| f.key == field_key)
35        .cloned();
36
37    let Some(ff) = filter_field else {
38        return rsx! {};
39    };
40
41    let key = ff.key.clone();
42    let value = state
43        .form_data
44        .read()
45        .get(&key)
46        .cloned()
47        .unwrap_or_default();
48
49    let set: Callback<String> = {
50        let key = key.clone();
51        Callback::new(move |v: String| state.set_field.call((key.clone(), v)))
52    };
53
54    // Priority 1: per-field render from the `change` map (highest).
55    if let Some(ref render_fn) = ff.render {
56        return render_fn(HyleFilterFieldProps {
57            key: ff.key,
58            label: ff.label,
59            field: ff.field,
60            options: ff.options,
61            value,
62            set,
63        });
64    }
65
66    // Priority 2: global HyleComponents context.
67    let props = HyleFilterFieldProps {
68        key: ff.key.clone(),
69        label: ff.label.clone(),
70        field: ff.field.clone(),
71        options: ff.options.clone(),
72        value: value.clone(),
73        set: set.clone(),
74    };
75    if let Some(components) = use_hyle_components() {
76        let type_key = field_type_key(&ff.field.field_type);
77        if let Some(render_fn) = components.filters.get(type_key).copied() {
78            return render_fn(props);
79        }
80    }
81
82    // Priority 3: built-in default.
83    default_input(ff, value, set, false)
84}
85
86/// Like [`FilterField`] but intended for form contexts: boolean fields render
87/// as a self-labelling checkbox (label on the right) instead of a 3-state
88/// select, matching the React `FilterBoolean` checkbox appearance.
89#[component]
90pub fn FormFilterField(
91    state: HyleFiltersState,
92    field_key: String,
93) -> Element {
94    let filter_field: Option<HyleFilterField> = state
95        .fields
96        .read()
97        .iter()
98        .find(|f| f.key == field_key)
99        .cloned();
100
101    let Some(ff) = filter_field else {
102        return rsx! {};
103    };
104
105    let key = ff.key.clone();
106    let value = state
107        .form_data
108        .read()
109        .get(&key)
110        .cloned()
111        .unwrap_or_default();
112
113    let set: Callback<String> = {
114        let key = key.clone();
115        Callback::new(move |v: String| state.set_field.call((key.clone(), v)))
116    };
117
118    if let Some(ref render_fn) = ff.render {
119        return render_fn(HyleFilterFieldProps {
120            key: ff.key, label: ff.label, field: ff.field, options: ff.options, value, set,
121        });
122    }
123
124    form_default_input(ff, value, set)
125}
126
127fn form_default_input(ff: HyleFilterField, value: String, set: Callback<String>) -> Element {
128    match &ff.field.field_type {
129        FieldType::Primitive {
130            primitive: Primitive::Boolean,
131        } => boolean_checkbox(ff.key, ff.label, value, set),
132        _ => default_input(ff, value, set, true),
133    }
134}
135
136// ── Default input dispatch ────────────────────────────────────────────────────
137
138fn default_input(ff: HyleFilterField, value: String, set: Callback<String>, is_form: bool) -> Element {
139    match &ff.field.field_type {
140        FieldType::Primitive {
141            primitive: Primitive::Boolean,
142        } => boolean_select(ff.key, ff.label, value, set),
143
144        FieldType::Reference { .. } => match ff.options {
145            Some(options) => reference_select(ff.key, ff.label, value, options, set),
146            None => reference_select_loading(ff.key, ff.label),
147        },
148
149        FieldType::Array { .. } => {
150            // Only show textarea in form context, not filter bar
151            let is_textarea = is_form && ff.field.options.input.as_ref()
152                .map(|i| i.kind == "textarea")
153                .unwrap_or(false);
154
155            if is_textarea {
156                let rows = ff.field.options.input.as_ref()
157                    .and_then(|i| i.props.get("rows"))
158                    .and_then(|v| v.as_str())
159                    .unwrap_or("4");
160                return rsx! {
161                    label {
162                        "{ff.label}"
163                        textarea {
164                            name: "{ff.key}",
165                            rows: "{rows}",
166                            value: "{value}",
167                            oninput: move |e| set.call(e.value()),
168                            placeholder: "One type per line (e.g., folk\\nrock)",
169                        }
170                    }
171                };
172            }
173
174            match ff.options {
175                Some(options) => checkbox_reference_fieldset(ff.key, ff.label, value, options, ff.display_field_type.clone(), set),
176                None => rsx! {
177                    fieldset {
178                        legend { "{ff.label}" }
179                        span { aria_busy: "true", "Loading…" }
180                    }
181                },
182            }
183        }
184
185        _ => text_input(ff, value, set),
186    }
187}
188
189fn boolean_select(name: String, label: String, value: String, set: Callback<String>) -> Element {
190    rsx! {
191        label {
192            "{label}"
193            select {
194                name: "{name}",
195                onchange: move |e| set.call(e.value()),
196                option { value: "", selected: value.is_empty(), "Any" }
197                option { value: "true", selected: value == "true", "Yes" }
198                option { value: "false", selected: value == "false", "No" }
199            }
200        }
201    }
202}
203
204pub(crate) fn boolean_checkbox(name: String, label: String, value: String, set: Callback<String>) -> Element {
205    rsx! {
206        fieldset {
207            legend { "" }
208            label {
209                input {
210                    r#type: "checkbox",
211                    name: "{name}",
212                    checked: value == "true",
213                    onchange: move |e| set.call(if e.checked() { "true".to_owned() } else { String::new() }),
214                }
215                "{label}"
216            }
217        }
218    }
219}
220
221fn reference_select_loading(name: String, label: String) -> Element {
222    rsx! {
223        label {
224            "{label}"
225            select { name: "{name}", disabled: true,
226                option { "Loading…" }
227            }
228        }
229    }
230}
231
232fn reference_select(
233    name: String,
234    label: String,
235    value: String,
236    options: Vec<(String, String)>,
237    set: Callback<String>,
238) -> Element {
239    rsx! {
240        label {
241            "{label}"
242            select {
243                name: "{name}",
244                onchange: move |e| set.call(e.value()),
245                option { value: "", selected: value.is_empty(), "All {label}s" }
246                for (id, display) in options {
247                    option { key: "{id}", value: "{id}", selected: value == id, "{display}" }
248                }
249            }
250        }
251    }
252}
253
254fn checkbox_reference_fieldset(
255    name: String,
256    label: String,
257    value: String,
258    options: Vec<(String, String)>,
259    display_field_type: Option<FieldType>,
260    set: Callback<String>,
261) -> Element {
262    let selected: Vec<String> = if value.is_empty() {
263        vec![]
264    } else {
265        value.split(',').map(|s| s.trim().to_owned()).collect()
266    };
267
268    // Look up a value renderer for the display field type — same map as table cells (symmetric with React).
269    let components: Option<HyleComponents> = use_hyle_components();
270    let label_render_fn: Option<fn(HyleValueProps) -> Element> =
271        display_field_type.as_ref().and_then(|ft| {
272            components.as_ref().and_then(|c| {
273                let key = field_type_key(ft);
274                c.values.get(key).copied()
275            })
276        });
277
278    // Blueprint from context — needed to build HyleValueProps for the label renderer.
279    let blueprint = use_context::<HyleConfig>().blueprint;
280
281    rsx! {
282        fieldset {
283            legend { "{label}" }
284            for (id, display) in options {
285                label {
286                    key: "{id}",
287                    input {
288                        r#type: "checkbox",
289                        name: "{name}",
290                        value: "{id}",
291                        checked: selected.contains(&id),
292                        onchange: {
293                            let id = id.clone();
294                            let value = value.clone();
295                            let set = set.clone();
296                            move |e: Event<FormData>| {
297                                let mut current: Vec<String> = if value.is_empty() {
298                                    vec![]
299                                } else {
300                                    value.split(',').map(|s| s.trim().to_owned()).collect()
301                                };
302                                if e.checked() {
303                                    if !current.contains(&id) {
304                                        current.push(id.clone());
305                                    }
306                                } else {
307                                    current.retain(|s| s != &id);
308                                }
309                                set.call(current.join(","));
310                            }
311                        },
312                    }
313                    if let Some(render_fn) = label_render_fn {
314                        {
315                            let ft = display_field_type.clone().unwrap_or(FieldType::Primitive { primitive: Primitive::String });
316                            let field = hyle::Field { label: display.clone(), field_type: ft, options: Default::default() };
317                            let display_val = hyle::Value::String(display.clone());
318                            render_fn(HyleValueProps {
319                                key: id.clone(),
320                                field,
321                                value: display_val,
322                                outcome: hyle::Outcome::empty(),
323                                model_name: String::new(),
324                                blueprint: (*blueprint).clone(),
325                                components: components.clone(),
326                            })
327                        }
328                    } else {
329                        " {display}"
330                    }
331                }
332            }
333        }
334    }
335}
336
337fn text_input(ff: HyleFilterField, value: String, set: Callback<String>) -> Element {
338    let input_type = ff
339        .field
340        .options
341        .input
342        .as_ref()
343        .map(|i| i.kind.clone())
344        .unwrap_or_else(|| "text".to_owned());
345
346    rsx! {
347        label {
348            "{ff.label}"
349            input {
350                r#type: "{input_type}",
351                name: "{ff.key}",
352                placeholder: "{ff.label}",
353                value: "{value}",
354                oninput: move |e| set.call(e.value()),
355            }
356        }
357    }
358}