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}