use std::collections::HashSet;
use wasm_bindgen::JsCast;
use web_sys::{Blob, BlobPropertyBag, HtmlAnchorElement, Url};
use ferrox_front_core::dom::{div, span, button, input, select, option, DomBuilder};
use crate::icons::Icon;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDirection {
None,
Ascending,
Descending,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PinnedPosition {
None,
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextAlign {
Left,
Center,
Right,
}
pub struct ColumnDef<T> {
pub id: String,
pub header: String,
pub sortable: bool,
pub filterable: bool,
pub width: Option<String>,
pub pinned: PinnedPosition,
pub align: TextAlign,
pub accessor: Box<dyn Fn(&T) -> String + Send + Sync>,
pub custom_renderer: Option<Box<dyn Fn(&T) -> DomBuilder + Send + Sync>>,
}
impl<T> ColumnDef<T> {
pub fn new<F>(id: &str, header: &str, accessor: F) -> Self
where
F: Fn(&T) -> String + Send + Sync + 'static,
{
Self {
id: id.to_string(),
header: header.to_string(),
sortable: true,
filterable: true,
width: None,
pinned: PinnedPosition::None,
align: TextAlign::Left,
accessor: Box::new(accessor),
custom_renderer: None,
}
}
pub fn with_renderer<R>(mut self, renderer: R) -> Self
where
R: Fn(&T) -> DomBuilder + Send + Sync + 'static,
{
self.custom_renderer = Some(Box::new(renderer));
self
}
pub fn with_width(mut self, width: &str) -> Self {
self.width = Some(width.to_string());
self
}
pub fn with_align(mut self, align: TextAlign) -> Self {
self.align = align;
self
}
pub fn with_sortable(mut self, sortable: bool) -> Self {
self.sortable = sortable;
self
}
pub fn with_filterable(mut self, filterable: bool) -> Self {
self.filterable = filterable;
self
}
pub fn with_pinned(mut self, pinned: PinnedPosition) -> Self {
self.pinned = pinned;
self
}
}
#[derive(Debug, Clone)]
pub struct EnterpriseDataGridState {
pub current_page: usize,
pub page_size: usize,
pub sort_column: Option<(String, SortDirection)>,
pub search_query: String,
pub selected_rows: HashSet<usize>,
}
impl Default for EnterpriseDataGridState {
fn default() -> Self {
Self {
current_page: 1,
page_size: 10,
sort_column: None,
search_query: String::new(),
selected_rows: HashSet::new(),
}
}
}
pub fn generate_rfc4180_csv(headers: &[&str], rows: &[Vec<String>]) -> String {
let mut out = String::new();
for (i, h) in headers.iter().enumerate() {
if i > 0 {
out.push(',');
}
escape_csv_field(&mut out, h);
}
out.push_str("\r\n");
for row in rows {
for (i, val) in row.iter().enumerate() {
if i > 0 {
out.push(',');
}
escape_csv_field(&mut out, val);
}
out.push_str("\r\n");
}
out
}
fn escape_csv_field(buf: &mut String, val: &str) {
if val.contains(',') || val.contains('"') || val.contains('\n') || val.contains('\r') {
buf.push('"');
for ch in val.chars() {
if ch == '"' {
buf.push('"');
buf.push('"');
} else {
buf.push(ch);
}
}
buf.push('"');
} else {
buf.push_str(val);
}
}
pub fn trigger_csv_download(filename: &str, csv_content: &str) -> Result<(), wasm_bindgen::JsValue> {
let parts = js_sys::Array::new();
parts.push(&wasm_bindgen::JsValue::from_str(csv_content));
let bag = BlobPropertyBag::new();
bag.set_type("text/csv;charset=utf-8;");
let blob = Blob::new_with_str_sequence_and_options(&parts, &bag)?;
let url = Url::create_object_url_with_blob(&blob)?;
if let Some(win) = web_sys::window() {
if let Some(doc) = win.document() {
let a: HtmlAnchorElement = doc.create_element("a")?.unchecked_into();
a.set_href(&url);
a.set_download(filename);
let _ = a.set_attribute("style", "display: none;");
if let Some(body) = doc.body() {
body.append_child(&a)?;
a.click();
a.remove();
let _ = Url::revoke_object_url(&url);
}
}
}
Ok(())
}
pub struct EnterpriseDataGrid<T> {
pub id: String,
pub columns: Vec<ColumnDef<T>>,
pub data: Vec<T>,
pub state: EnterpriseDataGridState,
}
impl<T: 'static> EnterpriseDataGrid<T> {
pub fn new(id: &str, columns: Vec<ColumnDef<T>>, data: Vec<T>) -> Self {
Self {
id: id.to_string(),
columns,
data,
state: EnterpriseDataGridState::default(),
}
}
pub fn with_state(mut self, state: EnterpriseDataGridState) -> Self {
self.state = state;
self
}
pub fn render(&self) -> DomBuilder {
let total_items = self.data.len();
let query = self.state.search_query.to_lowercase();
let mut indexed_rows: Vec<(usize, &T)> = self
.data
.iter()
.enumerate()
.filter(|(_, item)| {
if query.is_empty() {
return true;
}
self.columns.iter().any(|col| {
(col.accessor)(item).to_lowercase().contains(&query)
})
})
.collect();
if let Some((ref col_id, dir)) = self.state.sort_column {
if let Some(col) = self.columns.iter().find(|c| &c.id == col_id) {
indexed_rows.sort_by(|(_, a), (_, b)| {
let val_a = (col.accessor)(a);
let val_b = (col.accessor)(b);
match dir {
SortDirection::Ascending => val_a.cmp(&val_b),
SortDirection::Descending => val_b.cmp(&val_a),
SortDirection::None => std::cmp::Ordering::Equal,
}
});
}
}
let filtered_count = indexed_rows.len();
let page_size = if self.state.page_size == 0 { usize::MAX } else { self.state.page_size };
let total_pages = if page_size == usize::MAX {
1
} else {
((filtered_count as f64) / (page_size as f64)).ceil().max(1.0) as usize
};
let current_page = self.state.current_page.clamp(1, total_pages);
let start_idx = (current_page - 1) * page_size;
let end_idx = (start_idx + page_size).min(filtered_count);
let paged_rows = if start_idx < filtered_count {
&indexed_rows[start_idx..end_idx]
} else {
&[]
};
let selected_count = self.state.selected_rows.len();
let mut batch_toolbar = div().attr("class", "ferrox-datagrid-batch-bar");
if selected_count > 0 {
batch_toolbar = batch_toolbar
.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;")
.child(
div().attr("style", "display: flex; align-items: center; gap: 8px;")
.child(Icon::render("check_circle", 18, "#3b82f6"))
.child(span().attr("style", "font-weight: 600; font-size: 0.9rem; color: #3b82f6;").text(&format!("{} righe selezionate", selected_count)))
)
.child(
div().attr("style", "display: flex; gap: 8px;")
.child(
button()
.attr("class", "ferrox-btn")
.attr("style", "padding: 6px 12px; font-size: 0.8rem; background: #dc2626; color: white;")
.text("🗑️ Elimina selezionati")
)
.child(
button()
.attr("class", "ferrox-btn")
.attr("style", "padding: 6px 12px; font-size: 0.8rem; background: rgba(255,255,255,0.1); border: var(--border-glass);")
.text("📥 Esporta selezione CSV")
)
);
}
let datagrid_id_owned = self.id.clone();
let export_csv_btn = button()
.attr("class", "ferrox-btn")
.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;")
.child(Icon::render("download", 16, "var(--accent-primary)"))
.child(span().text("Esporta Tabella CSV"));
let toolbar = div()
.attr("class", "ferrox-datagrid-toolbar")
.attr("style", "display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 12px;")
.child(
div().attr("style", "display: flex; align-items: center; gap: 10px;")
.child(
div().attr("style", "position: relative; width: 280px;")
.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)")))
.child(
input()
.attr("type", "text")
.attr("placeholder", "Cerca tra tutti i campi...")
.attr("value", &self.state.search_query)
.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;")
)
)
.child(
div().attr("style", "display: flex; align-items: center; gap: 6px; font-size: 0.82rem; color: var(--text-secondary);")
.child(span().text("Mostra:"))
.child(
select()
.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;")
.child(option().attr("value", "10").text("10 righe"))
.child(option().attr("value", "25").text("25 righe"))
.child(option().attr("value", "50").text("50 righe"))
.child(option().attr("value", "100").text("100 righe"))
)
)
)
.child(export_csv_btn);
let all_selected = filtered_count > 0 && self.state.selected_rows.len() >= filtered_count;
let some_selected = !self.state.selected_rows.is_empty() && !all_selected;
let mut thead = div()
.attr("class", "ferrox-datagrid-header")
.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);");
let header_checkbox = div()
.attr("style", "width: 36px; display: flex; align-items: center; justify-content: center; cursor: pointer;")
.child(
div().attr("style", &format!(
"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;",
if all_selected || some_selected { "var(--accent-primary, #3b82f6)" } else { "transparent" }
)).child(
if all_selected {
Icon::render("check", 14, "#ffffff")
} else if some_selected {
Icon::render("minus", 14, "#ffffff")
} else {
span()
}
)
);
thead = thead.child(header_checkbox);
for col in &self.columns {
let width_style = match col.width {
Some(ref w) => format!("width: {}; flex-shrink: 0;", w),
None => "flex: 1;".to_string(),
};
let align_style = match col.align {
TextAlign::Left => "justify-content: flex-start; text-align: left;",
TextAlign::Center => "justify-content: center; text-align: center;",
TextAlign::Right => "justify-content: flex-end; text-align: right;",
};
let sort_icon = if let Some((ref c_id, dir)) = self.state.sort_column {
if c_id == &col.id {
match dir {
SortDirection::Ascending => Icon::render("chevron_up", 14, "var(--accent-primary)"),
SortDirection::Descending => Icon::render("chevron_down", 14, "var(--accent-primary)"),
SortDirection::None => Icon::render("filter", 12, "rgba(255,255,255,0.3)"),
}
} else {
Icon::render("filter", 12, "rgba(255,255,255,0.15)")
}
} else {
Icon::render("filter", 12, "rgba(255,255,255,0.15)")
};
let col_header = div()
.attr("class", "ferrox-datagrid-th")
.attr("style", &format!("display: flex; align-items: center; gap: 6px; padding: 0 10px; cursor: pointer; user-select: none; {} {}", width_style, align_style))
.child(span().text(&col.header))
.child(sort_icon);
thead = thead.child(col_header);
}
let mut tbody = div().attr("class", "ferrox-datagrid-body");
if paged_rows.is_empty() {
let empty_row = div()
.attr("style", "padding: 3rem; text-align: center; color: var(--text-secondary); font-size: 0.95rem;")
.child(div().attr("style", "margin-bottom: 8px;").child(Icon::render("search", 32, "rgba(255,255,255,0.2)")))
.child(span().text("Nessun record trovato corrispondente ai filtri impostati."));
tbody = tbody.child(empty_row);
} else {
for &(orig_idx, item) in paged_rows {
let is_selected = self.state.selected_rows.contains(&orig_idx);
let row_bg = if is_selected {
"background: rgba(59, 130, 246, 0.12); border-bottom: 1px solid rgba(59, 130, 246, 0.25);"
} else {
"border-bottom: 1px solid rgba(255,255,255,0.05);"
};
let mut tr = div()
.attr("class", "ferrox-datagrid-row")
.attr("style", &format!("display: flex; align-items: center; padding: 10px 14px; transition: background 0.15s; cursor: pointer; {}", row_bg));
let row_check = div()
.attr("style", "width: 36px; display: flex; align-items: center; justify-content: center;")
.child(
div().attr("style", &format!(
"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;",
if is_selected { "var(--accent-primary, #3b82f6)" } else { "transparent" }
)).child(
if is_selected {
Icon::render("check", 14, "#ffffff")
} else {
span()
}
)
);
tr = tr.child(row_check);
for col in &self.columns {
let width_style = match col.width {
Some(ref w) => format!("width: {}; flex-shrink: 0;", w),
None => "flex: 1;".to_string(),
};
let align_style = match col.align {
TextAlign::Left => "text-align: left;",
TextAlign::Center => "text-align: center;",
TextAlign::Right => "text-align: right;",
};
let cell_content = match col.custom_renderer {
Some(ref renderer) => renderer(item),
None => span().text(&(col.accessor)(item)),
};
let td = div()
.attr("class", "ferrox-datagrid-cell")
.attr("style", &format!("padding: 0 10px; font-size: 0.88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; {} {}", width_style, align_style))
.child(cell_content);
tr = tr.child(td);
}
tbody = tbody.child(tr);
}
}
let page_info = if filtered_count == 0 {
"Visualizzati 0 di 0 record".to_string()
} else {
format!(
"Visualizzati da {} a {} di {} record{}",
start_idx + 1,
end_idx,
filtered_count,
if filtered_count < total_items { format!(" (filtrati da {} totali)", total_items) } else { "".to_string() }
)
};
let footer = div()
.attr("class", "ferrox-datagrid-footer")
.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;")
.child(
span().attr("style", "font-size: 0.82rem; color: var(--text-secondary);").text(&page_info)
)
.child(
div().attr("style", "display: flex; gap: 6px; align-items: center;")
.child(
button()
.attr("class", "ferrox-btn")
.attr("style", "padding: 6px 12px; font-size: 0.8rem; background: var(--bg-surface); border: var(--border-glass); opacity: 0.8;")
.text("« Prec")
)
.child(
span().attr("style", "padding: 6px 12px; font-size: 0.82rem; font-weight: 700; background: var(--accent-primary); border-radius: 6px; color: white;")
.text(&format!("Pagina {} di {}", current_page, total_pages))
)
.child(
button()
.attr("class", "ferrox-btn")
.attr("style", "padding: 6px 12px; font-size: 0.8rem; background: var(--bg-surface); border: var(--border-glass); opacity: 0.8;")
.text("Succ »")
)
);
let table_container = div()
.attr("id", &datagrid_id_owned)
.attr("class", "ferrox-datagrid-table-container")
.attr("style", "border: var(--border-glass); border-radius: 10px; overflow: hidden; background: var(--bg-surface); box-shadow: var(--shadow-glass);")
.child(thead)
.child(tbody)
.child(footer);
div()
.attr("class", "ferrox-datagrid-root")
.child(batch_toolbar)
.child(toolbar)
.child(table_container)
}
}
pub struct FerroxDataGridConfig {
pub schema_url: String,
pub entity_name: String,
}
pub fn ferrox_datagrid(config: &FerroxDataGridConfig) -> DomBuilder {
div()
.attr("class", "ferrox-datagrid")
.attr("style", "padding: 1rem; border: var(--border-glass); background: var(--bg-glass); border-radius: 8px;")
.child(
div().attr("style", "font-size: 1.2rem; font-weight: bold; margin-bottom: 1rem;")
.text(&format!("Entity DataGrid: {}", config.entity_name))
)
.child(
div().text(&format!("Schema and rows loaded from {}", config.schema_url))
)
}