Skip to main content

hyle_dioxus_native/
table.rs

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