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            if let Some(options) = ff.options {
151                checkbox_reference_fieldset(
152                    ff.key,
153                    ff.label,
154                    value,
155                    options,
156                    ff.display_field_type.clone(),
157                    set,
158                )
159            } else if ff.field.options.input.as_ref().map(|i| i.kind == "textarea").unwrap_or(false) {
160                // Fall through to text_input below
161                text_input(ff, value, set)
162            } else {
163                rsx! {
164                    fieldset {
165                        legend { "{ff.label}" }
166                        span { aria_busy: "true", "Loading…" }
167                    }
168                }
169            }
170        }
171
172        _ => text_input(ff, value, set),
173    }
174}
175
176fn boolean_select(name: String, label: String, value: String, set: Callback<String>) -> Element {
177    rsx! {
178        label {
179            "{label}"
180            select {
181                name: "{name}",
182                onchange: move |e| set.call(e.value()),
183                option { value: "", selected: value.is_empty(), "Any" }
184                option { value: "true", selected: value == "true", "Yes" }
185                option { value: "false", selected: value == "false", "No" }
186            }
187        }
188    }
189}
190
191pub(crate) fn boolean_checkbox(name: String, label: String, value: String, set: Callback<String>) -> Element {
192    rsx! {
193        fieldset {
194            legend { "" }
195            label {
196                input {
197                    r#type: "checkbox",
198                    name: "{name}",
199                    checked: value == "true",
200                    onchange: move |e| set.call(if e.checked() { "true".to_owned() } else { String::new() }),
201                }
202                "{label}"
203            }
204        }
205    }
206}
207
208fn reference_select_loading(name: String, label: String) -> Element {
209    rsx! {
210        label {
211            "{label}"
212            select { name: "{name}", disabled: true,
213                option { "Loading…" }
214            }
215        }
216    }
217}
218
219fn reference_select(
220    name: String,
221    label: String,
222    value: String,
223    options: Vec<(String, String)>,
224    set: Callback<String>,
225) -> Element {
226    rsx! {
227        label {
228            "{label}"
229            select {
230                name: "{name}",
231                onchange: move |e| set.call(e.value()),
232                option { value: "", selected: value.is_empty(), "All {label}s" }
233                for (id, display) in options {
234                    option { key: "{id}", value: "{id}", selected: value == id, "{display}" }
235                }
236            }
237        }
238    }
239}
240
241fn checkbox_reference_fieldset(
242    name: String,
243    label: String,
244    value: String,
245    options: Vec<(String, String)>,
246    display_field_type: Option<FieldType>,
247    set: Callback<String>,
248) -> Element {
249    let selected: Vec<String> = if value.is_empty() {
250        vec![]
251    } else {
252        value.split(',').map(|s| s.trim().to_owned()).collect()
253    };
254
255    // Look up a value renderer for the display field type — same map as table cells (symmetric with React).
256    let components: Option<HyleComponents> = use_hyle_components();
257    let label_render_fn: Option<fn(HyleValueProps) -> Element> =
258        display_field_type.as_ref().and_then(|ft| {
259            components.as_ref().and_then(|c| {
260                let key = field_type_key(ft);
261                c.values.get(key).copied()
262            })
263        });
264
265    // Blueprint from context — needed to build HyleValueProps for the label renderer.
266    let blueprint = use_context::<HyleConfig>().blueprint;
267
268    rsx! {
269        fieldset {
270            legend { "{label}" }
271            for (id, display) in options {
272                label {
273                    key: "{id}",
274                    input {
275                        r#type: "checkbox",
276                        name: "{name}",
277                        value: "{id}",
278                        checked: selected.contains(&id),
279                        onchange: {
280                            let id = id.clone();
281                            let value = value.clone();
282                            let set = set.clone();
283                            move |e: Event<FormData>| {
284                                let mut current: Vec<String> = if value.is_empty() {
285                                    vec![]
286                                } else {
287                                    value.split(',').map(|s| s.trim().to_owned()).collect()
288                                };
289                                if e.checked() {
290                                    if !current.contains(&id) {
291                                        current.push(id.clone());
292                                    }
293                                } else {
294                                    current.retain(|s| s != &id);
295                                }
296                                set.call(current.join(","));
297                            }
298                        },
299                    }
300                    if let Some(render_fn) = label_render_fn {
301                        {
302                            let ft = display_field_type.clone().unwrap_or(FieldType::Primitive { primitive: Primitive::String });
303                            let field = hyle::Field { label: display.clone(), field_type: ft, options: Default::default() };
304                            let display_val = hyle::Value::String(display.clone());
305                            render_fn(HyleValueProps {
306                                key: id.clone(),
307                                field,
308                                value: display_val,
309                                outcome: hyle::Outcome::empty(),
310                                model_name: String::new(),
311                                blueprint: (*blueprint).clone(),
312                                components: components.clone(),
313                            })
314                        }
315                    } else {
316                        " {display}"
317                    }
318                }
319            }
320        }
321    }
322}
323
324fn text_input(ff: HyleFilterField, value: String, set: Callback<String>) -> Element {
325    let kind = ff
326        .field
327        .options
328        .input
329        .as_ref()
330        .map(|i| i.kind.as_str())
331        .unwrap_or("text");
332
333    if kind == "textarea" {
334        let rows = ff
335            .field
336            .options
337            .input
338            .as_ref()
339            .and_then(|i| i.props.get("rows"))
340            .and_then(|v| v.as_u64())
341            .unwrap_or(4);
342        return rsx! {
343            label {
344                "{ff.label}"
345                textarea {
346                    name: "{ff.key}",
347                    aria_label: "{ff.label}",
348                    rows: "{rows}",
349                    oninput: move |e| set.call(e.value()),
350                    "{value}"
351                }
352            }
353        };
354    }
355
356    rsx! {
357        label {
358            "{ff.label}"
359            input {
360                r#type: "{kind}",
361                name: "{ff.key}",
362                aria_label: "{ff.label}",
363                placeholder: "{ff.label}",
364                value: "{value}",
365                oninput: move |e| set.call(e.value()),
366            }
367        }
368    }
369}