Skip to main content

hyle_dioxus/
filter.rs

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