Skip to main content

hyle_dioxus_native/
table.rs

1use dioxus::prelude::*;
2use hyle::{HyleDataState, FieldType};
3use hyle_dioxus::{use_context_provider, use_hyle_components, field_type_key, HyleFiltersState, HyleListState, HyleValueProps, FilterField};
4
5#[cfg(target_arch = "wasm32")]
6use wasm_bindgen::prelude::*;
7#[cfg(target_arch = "wasm32")]
8use web_sys::AddEventListenerOptions;
9
10// ── HyleTableBody ─────────────────────────────────────────────────────────────
11
12/// Renders the `<table>` body from a `HyleListState`.
13///
14/// - Column headers are sortable (click to sort, click again to toggle direction).
15/// - Rows are clickable when `on_row_click` is supplied; the selected row is
16///   highlighted when its id matches `selected_id`.
17#[component]
18pub fn HyleTableBody(
19    list: HyleListState,
20    on_row_click: Option<Callback<hyle_dioxus::Row>>,
21    selected_id: Option<hyle_dioxus::Value>,
22    row_href: Option<Callback<hyle_dioxus::Row, String>>,
23) -> Element {
24    let data = list.data.read();
25    match &*data {
26        HyleDataState::Loading { .. } => rsx! {
27            div { "Loading…" }
28        },
29        HyleDataState::Error { error, .. } => rsx! {
30            div { class: "hyle-error", "{error}" }
31        },
32        HyleDataState::Ready { manifest, outcome, rows, columns, .. } => {
33            let manifest = manifest.clone();
34            let outcome = outcome.clone();
35            let rows = rows.clone();
36            let columns = columns.clone();
37            let sort_field = list.sort_field.read().clone();
38            let sort_ascending = *list.sort_ascending.read();
39            let components = use_hyle_components();
40
41            rsx! {
42                div { class: "hyle-table-wrap",
43                    table {
44                        thead {
45                            tr {
46                                for col in columns.clone() {
47                                    {
48                                        let col_key = col.key.clone();
49                                        let is_active = sort_field.as_deref() == Some(&col.key);
50                                        let sort_indicator = if is_active {
51                                            if sort_ascending { " ▲" } else { " ▼" }
52                                        } else { "" };
53                                        let mut sort_field_sig = list.sort_field;
54                                        let mut sort_asc_sig = list.sort_ascending;
55                                        rsx! {
56                                            th { key: "{col.key}",
57                                                button {
58                                                    r#type: "button",
59                                                    class: "hyle-sort-button",
60                                                    onclick: move |_| {
61                                                        if sort_field_sig.read().as_deref() == Some(&col_key) {
62                                                            sort_asc_sig.toggle();
63                                                        } else {
64                                                            sort_field_sig.set(Some(col_key.clone()));
65                                                            sort_asc_sig.set(true);
66                                                        }
67                                                    },
68                                                    "{col.label}{sort_indicator}"
69                                                }
70                                            }
71                                        }
72                                    }
73                                }
74                            }
75                        }
76                        tbody {
77                            if rows.is_empty() {
78                                tr {
79                                    td { colspan: "{columns.len()}", class: "hyle-empty-state",
80                                        "No results match the current filters."
81                                    }
82                                }
83                            } else {
84                                for row in rows {
85                                    {
86                                        let row_id = row.get("id").cloned().unwrap_or(hyle_dioxus::Value::Null);
87                                        let is_selected = selected_id.as_ref()
88                                            .map(|sid| sid == &row_id)
89                                            .unwrap_or(false);
90                                        let has_click = on_row_click.is_some();
91                                        let class = if is_selected {
92                                            "hyle-row-selected"
93                                        } else if has_click || row_href.is_some() {
94                                            "hyle-row-clickable"
95                                        } else {
96                                            ""
97                                        };
98                                        let row2 = row.clone();
99                                        let href = row_href.map(|cb| cb.call(row.clone()));
100                                        rsx! {
101                                            tr {
102                                                key: "{row_id}",
103                                                class: "{class}",
104                                                onclick: move |_| {
105                                                    if let Some(cb) = on_row_click {
106                                                        cb.call(row2.clone());
107                                                    }
108                                                },
109                                                for (i, col) in columns.clone().into_iter().enumerate() {
110                                                    {
111                                                        let val = row.get(&col.key)
112                                                            .cloned()
113                                                            .unwrap_or(hyle_dioxus::Value::Null);
114                                                        let type_key = field_type_key(&col.field.field_type);
115                                                        let custom_render = components
116                                                            .as_ref()
117                                                            .and_then(|c| c.values.get(type_key).copied());
118                                                        let cell_content = if let Some(render_fn) = custom_render {
119                                                            render_fn(HyleValueProps {
120                                                                key: col.key.clone(),
121                                                                field: col.field.clone(),
122                                                                value: val.clone(),
123                                                                outcome: outcome.clone(),
124                                                                model_name: manifest.base.clone(),
125                                                            })
126                                        } else {
127                                            let display = match &col.field.field_type {
128                                                FieldType::Array { .. } => {
129                                                    if let Some(arr) = val.as_array() {
130                                                        arr.iter()
131                                                            .map(|v| hyle::display_value_from_outcome(&outcome, &col.key, v))
132                                                            .collect::<Vec<_>>()
133                                                            .join(", ")
134                                                    } else {
135                                                        hyle::display_value_from_outcome(&outcome, &col.key, &val)
136                                                    }
137                                                }
138                                                _ => hyle::display_value_from_outcome(&outcome, &col.key, &val),
139                                            };
140                                            rsx! { "{display}" }
141                                        };
142                                                        if i == 0 {
143                                                            if let Some(ref url) = href {
144                                                                rsx! {
145                                                                    td { key: "{col.key}",
146                                                                        a { href: "{url}", {cell_content} }
147                                                                    }
148                                                                }
149                                                            } else {
150                                                                rsx! {
151                                                                    td { key: "{col.key}", {cell_content} }
152                                                                }
153                                                            }
154                                                        } else {
155                                                            rsx! {
156                                                                td { key: "{col.key}", {cell_content} }
157                                                            }
158                                                        }
159                                                    }
160                                                }
161                                            }
162                                        }
163                                    }
164                                }
165                            }
166                        }
167                    }
168                }
169            }
170        }
171    }
172}
173
174// ── HyleTableFilterBar ────────────────────────────────────────────────────────
175
176/// Renders a row of filter inputs above the table, one per field.
177///
178/// Reads `HyleFiltersState` from context (provided by `HyleTablePanel`) so no
179/// explicit prop threading is needed when used inside `HyleTablePanel`.
180///
181/// Pass `only` to restrict which fields are shown (by key). When `None` all
182/// fields from `filters.fields` are rendered.
183#[component]
184pub fn HyleTableFilterBar(
185    filters: HyleFiltersState,
186    only: Option<Vec<String>>,
187    children: Option<Element>,
188) -> Element {
189    let fields = filters.fields.read();
190    let visible: Vec<_> = fields.iter().filter(|f| {
191        only.as_ref().map(|keys| keys.contains(&f.key)).unwrap_or(true)
192    }).cloned().collect();
193    drop(fields);
194
195    rsx! {
196        div { class: "hyle-filter-bar",
197            for field_meta in visible {
198                FilterField {
199                    key: "{field_meta.key}",
200                    state: filters,
201                    field_key: field_meta.key.clone(),
202                }
203            }
204            {children}
205        }
206    }
207}
208
209// ── HyleTableFilters ──────────────────────────────────────────────────────────
210
211/// Renders Apply / Clear filter buttons.
212///
213/// Must be rendered inside a `HyleTablePanel` so the buttons are within the
214/// enclosing `<form method="get">`.
215///
216/// When JS is enabled, Apply triggers the form's `onsubmit` (which calls
217/// `filter_apply` and prevents navigation).  Clear reads `HyleFiltersState`
218/// from context (set by `HyleTablePanel`) to call `filter_clear` directly,
219/// also preventing default so no navigation occurs.
220#[component]
221pub fn HyleTableFilters() -> Element {
222    let filters = dioxus_core::has_context::<HyleFiltersState>();
223    rsx! {
224        div { class: "hyle-filter-actions",
225            button {
226                r#type: "reset",
227                onclick: move |e| {
228                    if let Some(fs) = filters {
229                        e.prevent_default();
230                        fs.filter_clear.call(());
231                    }
232                },
233                "Clear"
234            }
235            button { r#type: "submit", "Apply" }
236        }
237    }
238}
239
240// ── HyleTablePagination ───────────────────────────────────────────────────────
241
242/// Renders page-navigation controls for a `HyleListState`.
243///
244/// Controls are native `<button type="submit">` elements inside the outer
245/// `<form method="get">` wrapping the table, so pagination works without JS.
246/// JS signal mutations are kept as well so client-side navigation still works
247/// when JS is available (progressive enhancement).
248#[component]
249pub fn HyleTablePagination(list: HyleListState) -> Element {
250    let data = list.data.read();
251    let (total, row_count) = match &*data {
252        HyleDataState::Ready { outcome, rows, .. } => (outcome.total, rows.len()),
253        _ => return rsx! {},
254    };
255    drop(data);
256
257    let page = *list.page.read();
258    let per_page = *list.per_page.read();
259    let mut page_sig = list.page;
260    let mut per_page_sig = list.per_page;
261    let mut page_sig2 = list.page;
262
263    let prev_page = page.saturating_sub(1).max(1);
264    let next_page = page + 1;
265
266    rsx! {
267        div { class: "hyle-table-footer",
268            div { class: "hyle-pagination",
269                button {
270                    r#type: "submit",
271                    name: "page",
272                    value: "{prev_page}",
273                    disabled: page <= 1,
274                    onclick: move |e| {
275                        e.prevent_default();
276                        page_sig.with_mut(|p| *p = p.saturating_sub(1).max(1));
277                    },
278                    "← Prev"
279                }
280                span { "Page {page}" }
281                button {
282                    r#type: "submit",
283                    name: "page",
284                    value: "{next_page}",
285                    disabled: row_count < per_page,
286                    onclick: move |e| {
287                        e.prevent_default();
288                        page_sig2.with_mut(|p| *p += 1);
289                    },
290                    "Next →"
291                }
292                select {
293                    name: "per_page",
294                    value: "{per_page}",
295                    onchange: move |e| {
296                        if let Ok(n) = e.value().parse::<usize>() {
297                            per_page_sig.set(n);
298                            page_sig.set(1);
299                        }
300                    },
301                    for n in [5usize, 10, 20, 50, 100] {
302                        option { value: "{n}", selected: n == per_page, "{n} / page" }
303                    }
304                }
305                // No-JS fallback: submit button for per-page change.
306                // With JS the select's onchange auto-submits; without JS the
307                // user clicks this button after selecting a value.
308                button { r#type: "submit", "Apply" }
309            }
310            span { class: "hyle-row-count",
311                "{row_count} of {total} rows"
312            }
313        }
314    }
315}
316
317// ── HyleTable ─────────────────────────────────────────────────────────────────
318
319/// Composes `HyleTableBody` + `HyleTablePagination`.
320///
321/// Does not own a `<form>` — use `HyleTablePanel` when you need the full
322/// no-JS GET-form wrapper (filters + table + pagination inside one form).
323#[component]
324pub fn HyleTable(
325    list: HyleListState,
326    on_row_click: Option<Callback<hyle_dioxus::Row>>,
327    selected_id: Option<hyle_dioxus::Value>,
328    row_href: Option<Callback<hyle_dioxus::Row, String>>,
329) -> Element {
330    rsx! {
331        HyleTableBody {
332            list,
333            on_row_click,
334            selected_id,
335            row_href,
336        }
337        HyleTablePagination { list }
338    }
339}
340
341// ── HyleTablePanel ────────────────────────────────────────────────────────────
342
343/// Wraps a `<form method="get">` around a header slot, `HyleTableBody`, and
344/// `HyleTablePagination` so that `HyleTableFilters` buttons, filter inputs, and
345/// pagination controls all belong to the same native form.
346///
347/// Place your header (including `HyleTableFilterBar`, `HyleTableFilters`, and
348/// any other controls) as `children`; they will be rendered inside the form
349/// before the table.
350///
351/// When JS is enabled the form `onsubmit` is intercepted: `filter_apply` is
352/// called on the filters state and the page is reset to 1, so the table updates
353/// reactively without a full-page navigation.  Without JS the native GET submit
354/// proceeds unchanged (progressive enhancement).
355///
356/// `HyleFiltersState` is provided as context so that `HyleTableFilterBar`,
357/// `HyleTableFilters`, and `HyleTablePagination` can read it without requiring
358/// explicit prop threading.
359///
360/// # Example
361/// ```rust,ignore
362/// HyleTablePanel { list, filters,
363///     header { class: "panelHeader",
364///         h2 { "Users" }
365///         HyleTableFilterBar { only: vec!["name".into(), "role".into()] }
366///         HyleTableFilters {}
367///     }
368/// }
369/// ```
370#[component]
371pub fn HyleTablePanel(
372    list: HyleListState,
373    filters: Option<HyleFiltersState>,
374    on_row_click: Option<Callback<hyle_dioxus::Row>>,
375    selected_id: Option<hyle_dioxus::Value>,
376    row_href: Option<Callback<hyle_dioxus::Row, String>>,
377    children: Element,
378) -> Element {
379    // Provide filters state as context so HyleTableFilterBar / HyleTableFilters
380    // / HyleTablePagination can call filter_apply / reset page without explicit
381    // prop drilling.
382    if let Some(fs) = filters {
383        use_context_provider(|| fs);
384    }
385
386    let mut page_sig = list.page;
387
388    // On wasm, attach a capture-phase submit listener to the form so that
389    // preventDefault() fires before the browser commits to navigation.
390    // Dioxus's bubble-phase onsubmit handler is too late for reliable prevention.
391    #[cfg(target_arch = "wasm32")]
392    use_effect(|| {
393        let window = web_sys::window().unwrap();
394        let document = window.document().unwrap();
395        let closure = Closure::<dyn Fn(web_sys::Event)>::new(|e: web_sys::Event| {
396            e.prevent_default();
397        });
398        let mut opts = AddEventListenerOptions::new();
399        opts.capture(true);
400        document
401            .query_selector("form[data-hyle-panel]")
402            .ok()
403            .flatten()
404            .and_then(|el| el.dyn_into::<web_sys::EventTarget>().ok())
405            .map(|et| et.add_event_listener_with_callback_and_add_event_listener_options(
406                "submit",
407                closure.as_ref().unchecked_ref(),
408                &opts,
409            ));
410        closure.forget();
411    });
412
413    rsx! {
414        form {
415            method: "get",
416            "data-hyle-panel": "true",
417            onsubmit: move |e| {
418                e.prevent_default();
419                if let Some(fs) = filters {
420                    fs.filter_apply.call(());
421                }
422                page_sig.set(1);
423            },
424            {children}
425            HyleTableBody {
426                list,
427                on_row_click,
428                selected_id,
429                row_href,
430            }
431            HyleTablePagination { list }
432        }
433    }
434}