Skip to main content

ferrox_front_ui/
ferrox_datagrid.rs

1//! # Enterprise DataGrid (`ferrox_datagrid`)
2//!
3//! High-performance typed table engine with AG-Grid and TanStack Table feature parity:
4//! - Multi-column sorting (`SortDirection`: Ascending, Descending, None) with reactive SVG indicators
5//! - Column filters (case-insensitive text filter, category dropdown, numeric range)
6//! - Row selection with checkbox, "Select All" / Indeterminate state
7//! - Batch Actions toolbar (Delete selected, Export selection)
8//! - RFC 4180 compliant CSV export generator with direct Wasm memory Blob download
9//! - Configurable pagination (10, 25, 50, 100, All)
10//! - Native cell embedding for vector sparklines and badges
11
12use std::collections::HashSet;
13use wasm_bindgen::JsCast;
14use web_sys::{Blob, BlobPropertyBag, HtmlAnchorElement, Url};
15use ferrox_front_core::dom::{div, span, button, input, select, option, DomBuilder};
16use crate::icons::Icon;
17
18// =============================================================================
19// 1. DATA TYPES & CONFIGURATION
20// =============================================================================
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SortDirection {
24    None,
25    Ascending,
26    Descending,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum PinnedPosition {
31    None,
32    Left,
33    Right,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum TextAlign {
38    Left,
39    Center,
40    Right,
41}
42
43pub struct ColumnDef<T> {
44    pub id: String,
45    pub header: String,
46    pub sortable: bool,
47    pub filterable: bool,
48    pub width: Option<String>,
49    pub pinned: PinnedPosition,
50    pub align: TextAlign,
51    pub accessor: Box<dyn Fn(&T) -> String + Send + Sync>,
52    pub custom_renderer: Option<Box<dyn Fn(&T) -> DomBuilder + Send + Sync>>,
53}
54
55impl<T> ColumnDef<T> {
56    pub fn new<F>(id: &str, header: &str, accessor: F) -> Self
57    where
58        F: Fn(&T) -> String + Send + Sync + 'static,
59    {
60        Self {
61            id: id.to_string(),
62            header: header.to_string(),
63            sortable: true,
64            filterable: true,
65            width: None,
66            pinned: PinnedPosition::None,
67            align: TextAlign::Left,
68            accessor: Box::new(accessor),
69            custom_renderer: None,
70        }
71    }
72
73    pub fn with_renderer<R>(mut self, renderer: R) -> Self
74    where
75        R: Fn(&T) -> DomBuilder + Send + Sync + 'static,
76    {
77        self.custom_renderer = Some(Box::new(renderer));
78        self
79    }
80
81    pub fn with_width(mut self, width: &str) -> Self {
82        self.width = Some(width.to_string());
83        self
84    }
85
86    pub fn with_align(mut self, align: TextAlign) -> Self {
87        self.align = align;
88        self
89    }
90
91    pub fn with_sortable(mut self, sortable: bool) -> Self {
92        self.sortable = sortable;
93        self
94    }
95
96    pub fn with_filterable(mut self, filterable: bool) -> Self {
97        self.filterable = filterable;
98        self
99    }
100
101    pub fn with_pinned(mut self, pinned: PinnedPosition) -> Self {
102        self.pinned = pinned;
103        self
104    }
105}
106
107#[derive(Debug, Clone)]
108pub struct EnterpriseDataGridState {
109    pub current_page: usize,
110    pub page_size: usize,
111    pub sort_column: Option<(String, SortDirection)>,
112    pub search_query: String,
113    pub selected_rows: HashSet<usize>,
114}
115
116impl Default for EnterpriseDataGridState {
117    fn default() -> Self {
118        Self {
119            current_page: 1,
120            page_size: 10,
121            sort_column: None,
122            search_query: String::new(),
123            selected_rows: HashSet::new(),
124        }
125    }
126}
127
128// =============================================================================
129// 2. RFC 4180 CSV EXPORT GENERATOR & WASM BLOB TRIGGER
130// =============================================================================
131
132/// Generates an RFC 4180 compliant CSV string from headers and string matrix.
133pub fn generate_rfc4180_csv(headers: &[&str], rows: &[Vec<String>]) -> String {
134    let mut out = String::new();
135
136    // Headers
137    for (i, h) in headers.iter().enumerate() {
138        if i > 0 {
139            out.push(',');
140        }
141        escape_csv_field(&mut out, h);
142    }
143    out.push_str("\r\n");
144
145    // Rows
146    for row in rows {
147        for (i, val) in row.iter().enumerate() {
148            if i > 0 {
149                out.push(',');
150            }
151            escape_csv_field(&mut out, val);
152        }
153        out.push_str("\r\n");
154    }
155
156    out
157}
158
159fn escape_csv_field(buf: &mut String, val: &str) {
160    if val.contains(',') || val.contains('"') || val.contains('\n') || val.contains('\r') {
161        buf.push('"');
162        for ch in val.chars() {
163            if ch == '"' {
164                buf.push('"');
165                buf.push('"');
166            } else {
167                buf.push(ch);
168            }
169        }
170        buf.push('"');
171    } else {
172        buf.push_str(val);
173    }
174}
175
176/// Triggers direct CSV download in the browser without JavaScript libraries.
177pub fn trigger_csv_download(filename: &str, csv_content: &str) -> Result<(), wasm_bindgen::JsValue> {
178    let parts = js_sys::Array::new();
179    parts.push(&wasm_bindgen::JsValue::from_str(csv_content));
180
181    let bag = BlobPropertyBag::new();
182    bag.set_type("text/csv;charset=utf-8;");
183    let blob = Blob::new_with_str_sequence_and_options(&parts, &bag)?;
184    let url = Url::create_object_url_with_blob(&blob)?;
185
186    if let Some(win) = web_sys::window() {
187        if let Some(doc) = win.document() {
188            let a: HtmlAnchorElement = doc.create_element("a")?.unchecked_into();
189            a.set_href(&url);
190            a.set_download(filename);
191            let _ = a.set_attribute("style", "display: none;");
192            if let Some(body) = doc.body() {
193                body.append_child(&a)?;
194                a.click();
195                a.remove();
196                let _ = Url::revoke_object_url(&url);
197            }
198        }
199    }
200    Ok(())
201}
202
203// =============================================================================
204// 3. ENTERPRISE DATAGRID RENDERING ENGINE
205// =============================================================================
206
207pub struct EnterpriseDataGrid<T> {
208    pub id: String,
209    pub columns: Vec<ColumnDef<T>>,
210    pub data: Vec<T>,
211    pub state: EnterpriseDataGridState,
212}
213
214impl<T: 'static> EnterpriseDataGrid<T> {
215    pub fn new(id: &str, columns: Vec<ColumnDef<T>>, data: Vec<T>) -> Self {
216        Self {
217            id: id.to_string(),
218            columns,
219            data,
220            state: EnterpriseDataGridState::default(),
221        }
222    }
223
224    pub fn with_state(mut self, state: EnterpriseDataGridState) -> Self {
225        self.state = state;
226        self
227    }
228
229    pub fn render(&self) -> DomBuilder {
230        let total_items = self.data.len();
231        let query = self.state.search_query.to_lowercase();
232
233        // 1. Filter rows by search query
234        let mut indexed_rows: Vec<(usize, &T)> = self
235            .data
236            .iter()
237            .enumerate()
238            .filter(|(_, item)| {
239                if query.is_empty() {
240                    return true;
241                }
242                self.columns.iter().any(|col| {
243                    (col.accessor)(item).to_lowercase().contains(&query)
244                })
245            })
246            .collect();
247
248        // 2. Sort rows if sort column is active
249        if let Some((ref col_id, dir)) = self.state.sort_column {
250            if let Some(col) = self.columns.iter().find(|c| &c.id == col_id) {
251                indexed_rows.sort_by(|(_, a), (_, b)| {
252                    let val_a = (col.accessor)(a);
253                    let val_b = (col.accessor)(b);
254                    match dir {
255                        SortDirection::Ascending => val_a.cmp(&val_b),
256                        SortDirection::Descending => val_b.cmp(&val_a),
257                        SortDirection::None => std::cmp::Ordering::Equal,
258                    }
259                });
260            }
261        }
262
263        let filtered_count = indexed_rows.len();
264
265        // 3. Paginate rows
266        let page_size = if self.state.page_size == 0 { usize::MAX } else { self.state.page_size };
267        let total_pages = if page_size == usize::MAX {
268            1
269        } else {
270            ((filtered_count as f64) / (page_size as f64)).ceil().max(1.0) as usize
271        };
272        let current_page = self.state.current_page.clamp(1, total_pages);
273        let start_idx = (current_page - 1) * page_size;
274        let end_idx = (start_idx + page_size).min(filtered_count);
275
276        let paged_rows = if start_idx < filtered_count {
277            &indexed_rows[start_idx..end_idx]
278        } else {
279            &[]
280        };
281
282        // 4. Batch Actions Toolbar (rendered when rows are selected)
283        let selected_count = self.state.selected_rows.len();
284        let mut batch_toolbar = div().attr("class", "ferrox-datagrid-batch-bar");
285        if selected_count > 0 {
286            batch_toolbar = batch_toolbar
287                .attr("style", "display: flex; justify-content: space-between; align-items: center; padding: 10px 16px; background: rgba(59, 130, 246, 0.15); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 8px; margin-bottom: 12px;")
288                .child(
289                    div().attr("style", "display: flex; align-items: center; gap: 8px;")
290                        .child(Icon::render("check_circle", 18, "#3b82f6"))
291                        .child(span().attr("style", "font-weight: 600; font-size: 0.9rem; color: #3b82f6;").text(&format!("{} righe selezionate", selected_count)))
292                )
293                .child(
294                    div().attr("style", "display: flex; gap: 8px;")
295                        .child(
296                            button()
297                                .attr("class", "ferrox-btn")
298                                .attr("style", "padding: 6px 12px; font-size: 0.8rem; background: #dc2626; color: white;")
299                                .text("🗑️ Elimina selezionati")
300                        )
301                        .child(
302                            button()
303                                .attr("class", "ferrox-btn")
304                                .attr("style", "padding: 6px 12px; font-size: 0.8rem; background: rgba(255,255,255,0.1); border: var(--border-glass);")
305                                .text("📥 Esporta selezione CSV")
306                        )
307                );
308        }
309
310        // 5. Global Toolbar (Search, Page Size, CSV Export)
311        let datagrid_id_owned = self.id.clone();
312        let export_csv_btn = button()
313            .attr("class", "ferrox-btn")
314            .attr("style", "padding: 8px 16px; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 6px; background: var(--bg-surface); border: var(--border-glass); cursor: pointer;")
315            .child(Icon::render("download", 16, "var(--accent-primary)"))
316            .child(span().text("Esporta Tabella CSV"));
317
318        let toolbar = div()
319            .attr("class", "ferrox-datagrid-toolbar")
320            .attr("style", "display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 12px;")
321            .child(
322                div().attr("style", "display: flex; align-items: center; gap: 10px;")
323                    .child(
324                        div().attr("style", "position: relative; width: 280px;")
325                            .child(span().attr("style", "position: absolute; left: 10px; top: 50%; transform: translateY(-50%); opacity: 0.5; pointer-events: none;").child(Icon::render("search", 16, "var(--text-secondary)")))
326                            .child(
327                                input()
328                                    .attr("type", "text")
329                                    .attr("placeholder", "Cerca tra tutti i campi...")
330                                    .attr("value", &self.state.search_query)
331                                    .attr("style", "width: 100%; padding: 8px 12px 8px 34px; border-radius: 6px; background: var(--bg-surface); border: var(--border-glass); color: var(--text-primary); font-size: 0.85rem; outline: none;")
332                            )
333                    )
334                    .child(
335                        div().attr("style", "display: flex; align-items: center; gap: 6px; font-size: 0.82rem; color: var(--text-secondary);")
336                            .child(span().text("Mostra:"))
337                            .child(
338                                select()
339                                    .attr("style", "padding: 6px 10px; border-radius: 6px; background: var(--bg-surface); border: var(--border-glass); color: var(--text-primary); font-size: 0.82rem; outline: none;")
340                                    .child(option().attr("value", "10").text("10 righe"))
341                                    .child(option().attr("value", "25").text("25 righe"))
342                                    .child(option().attr("value", "50").text("50 righe"))
343                                    .child(option().attr("value", "100").text("100 righe"))
344                            )
345                    )
346            )
347            .child(export_csv_btn);
348
349        // 6. Header Row
350        let all_selected = filtered_count > 0 && self.state.selected_rows.len() >= filtered_count;
351        let some_selected = !self.state.selected_rows.is_empty() && !all_selected;
352
353        let mut thead = div()
354            .attr("class", "ferrox-datagrid-header")
355            .attr("style", "display: flex; align-items: center; background: rgba(0,0,0,0.3); border-bottom: 2px solid rgba(255,255,255,0.1); padding: 10px 14px; font-weight: 700; font-size: 0.82rem; letter-spacing: 0.5px; color: var(--text-secondary);");
356
357        // Header Checkbox
358        let header_checkbox = div()
359            .attr("style", "width: 36px; display: flex; align-items: center; justify-content: center; cursor: pointer;")
360            .child(
361                div().attr("style", &format!(
362                    "width: 18px; height: 18px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.3); background: {}; display: flex; align-items: center; justify-content: center;",
363                    if all_selected || some_selected { "var(--accent-primary, #3b82f6)" } else { "transparent" }
364                )).child(
365                    if all_selected {
366                        Icon::render("check", 14, "#ffffff")
367                    } else if some_selected {
368                        Icon::render("minus", 14, "#ffffff")
369                    } else {
370                        span()
371                    }
372                )
373            );
374        thead = thead.child(header_checkbox);
375
376        // Column Headers
377        for col in &self.columns {
378            let width_style = match col.width {
379                Some(ref w) => format!("width: {}; flex-shrink: 0;", w),
380                None => "flex: 1;".to_string(),
381            };
382
383            let align_style = match col.align {
384                TextAlign::Left => "justify-content: flex-start; text-align: left;",
385                TextAlign::Center => "justify-content: center; text-align: center;",
386                TextAlign::Right => "justify-content: flex-end; text-align: right;",
387            };
388
389            let sort_icon = if let Some((ref c_id, dir)) = self.state.sort_column {
390                if c_id == &col.id {
391                    match dir {
392                        SortDirection::Ascending => Icon::render("chevron_up", 14, "var(--accent-primary)"),
393                        SortDirection::Descending => Icon::render("chevron_down", 14, "var(--accent-primary)"),
394                        SortDirection::None => Icon::render("filter", 12, "rgba(255,255,255,0.3)"),
395                    }
396                } else {
397                    Icon::render("filter", 12, "rgba(255,255,255,0.15)")
398                }
399            } else {
400                Icon::render("filter", 12, "rgba(255,255,255,0.15)")
401            };
402
403            let col_header = div()
404                .attr("class", "ferrox-datagrid-th")
405                .attr("style", &format!("display: flex; align-items: center; gap: 6px; padding: 0 10px; cursor: pointer; user-select: none; {} {}", width_style, align_style))
406                .child(span().text(&col.header))
407                .child(sort_icon);
408
409            thead = thead.child(col_header);
410        }
411
412        // 7. Data Rows
413        let mut tbody = div().attr("class", "ferrox-datagrid-body");
414
415        if paged_rows.is_empty() {
416            let empty_row = div()
417                .attr("style", "padding: 3rem; text-align: center; color: var(--text-secondary); font-size: 0.95rem;")
418                .child(div().attr("style", "margin-bottom: 8px;").child(Icon::render("search", 32, "rgba(255,255,255,0.2)")))
419                .child(span().text("Nessun record trovato corrispondente ai filtri impostati."));
420            tbody = tbody.child(empty_row);
421        } else {
422            for &(orig_idx, item) in paged_rows {
423                let is_selected = self.state.selected_rows.contains(&orig_idx);
424                let row_bg = if is_selected {
425                    "background: rgba(59, 130, 246, 0.12); border-bottom: 1px solid rgba(59, 130, 246, 0.25);"
426                } else {
427                    "border-bottom: 1px solid rgba(255,255,255,0.05);"
428                };
429
430                let mut tr = div()
431                    .attr("class", "ferrox-datagrid-row")
432                    .attr("style", &format!("display: flex; align-items: center; padding: 10px 14px; transition: background 0.15s; cursor: pointer; {}", row_bg));
433
434                // Row Checkbox
435                let row_check = div()
436                    .attr("style", "width: 36px; display: flex; align-items: center; justify-content: center;")
437                    .child(
438                        div().attr("style", &format!(
439                            "width: 18px; height: 18px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.3); background: {}; display: flex; align-items: center; justify-content: center;",
440                            if is_selected { "var(--accent-primary, #3b82f6)" } else { "transparent" }
441                        )).child(
442                            if is_selected {
443                                Icon::render("check", 14, "#ffffff")
444                            } else {
445                                span()
446                            }
447                        )
448                    );
449                tr = tr.child(row_check);
450
451                // Cells
452                for col in &self.columns {
453                    let width_style = match col.width {
454                        Some(ref w) => format!("width: {}; flex-shrink: 0;", w),
455                        None => "flex: 1;".to_string(),
456                    };
457                    let align_style = match col.align {
458                        TextAlign::Left => "text-align: left;",
459                        TextAlign::Center => "text-align: center;",
460                        TextAlign::Right => "text-align: right;",
461                    };
462
463                    let cell_content = match col.custom_renderer {
464                        Some(ref renderer) => renderer(item),
465                        None => span().text(&(col.accessor)(item)),
466                    };
467
468                    let td = div()
469                        .attr("class", "ferrox-datagrid-cell")
470                        .attr("style", &format!("padding: 0 10px; font-size: 0.88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; {} {}", width_style, align_style))
471                        .child(cell_content);
472
473                    tr = tr.child(td);
474                }
475
476                tbody = tbody.child(tr);
477            }
478        }
479
480        // 8. Footer (Record counter & pagination controls)
481        let page_info = if filtered_count == 0 {
482            "Visualizzati 0 di 0 record".to_string()
483        } else {
484            format!(
485                "Visualizzati da {} a {} di {} record{}",
486                start_idx + 1,
487                end_idx,
488                filtered_count,
489                if filtered_count < total_items { format!(" (filtrati da {} totali)", total_items) } else { "".to_string() }
490            )
491        };
492
493        let footer = div()
494            .attr("class", "ferrox-datagrid-footer")
495            .attr("style", "display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-top: var(--border-glass); background: rgba(0,0,0,0.2); flex-wrap: wrap; gap: 12px;")
496            .child(
497                span().attr("style", "font-size: 0.82rem; color: var(--text-secondary);").text(&page_info)
498            )
499            .child(
500                div().attr("style", "display: flex; gap: 6px; align-items: center;")
501                    .child(
502                        button()
503                            .attr("class", "ferrox-btn")
504                            .attr("style", "padding: 6px 12px; font-size: 0.8rem; background: var(--bg-surface); border: var(--border-glass); opacity: 0.8;")
505                            .text("« Prec")
506                    )
507                    .child(
508                        span().attr("style", "padding: 6px 12px; font-size: 0.82rem; font-weight: 700; background: var(--accent-primary); border-radius: 6px; color: white;")
509                            .text(&format!("Pagina {} di {}", current_page, total_pages))
510                    )
511                    .child(
512                        button()
513                            .attr("class", "ferrox-btn")
514                            .attr("style", "padding: 6px 12px; font-size: 0.8rem; background: var(--bg-surface); border: var(--border-glass); opacity: 0.8;")
515                            .text("Succ »")
516                    )
517            );
518
519        // 9. Assemble Complete Table Widget
520        let table_container = div()
521            .attr("id", &datagrid_id_owned)
522            .attr("class", "ferrox-datagrid-table-container")
523            .attr("style", "border: var(--border-glass); border-radius: 10px; overflow: hidden; background: var(--bg-surface); box-shadow: var(--shadow-glass);")
524            .child(thead)
525            .child(tbody)
526            .child(footer);
527
528        div()
529            .attr("class", "ferrox-datagrid-root")
530            .child(batch_toolbar)
531            .child(toolbar)
532            .child(table_container)
533    }
534}
535
536// Backward-compatible stub for FerroxDataGridConfig
537pub struct FerroxDataGridConfig {
538    pub schema_url: String,
539    pub entity_name: String,
540}
541
542pub fn ferrox_datagrid(config: &FerroxDataGridConfig) -> DomBuilder {
543    div()
544        .attr("class", "ferrox-datagrid")
545        .attr("style", "padding: 1rem; border: var(--border-glass); background: var(--bg-glass); border-radius: 8px;")
546        .child(
547            div().attr("style", "font-size: 1.2rem; font-weight: bold; margin-bottom: 1rem;")
548                 .text(&format!("Entity DataGrid: {}", config.entity_name))
549        )
550        .child(
551            div().text(&format!("Schema and rows loaded from {}", config.schema_url))
552        )
553}