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