ui-grid-core 0.1.1

Deterministic Rust engine for ui-grid
Documentation
use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::{
    constants::SortDirection,
    models::{GridSavedPaginationState, GridSavedState, SortState},
};

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildGridSavedStateContext {
    pub column_order: Vec<String>,
    pub active_filters: BTreeMap<String, String>,
    pub sort_state: SortState,
    pub group_by_columns: Vec<String>,
    pub current_page: usize,
    pub page_size: usize,
    pub total_items: usize,
    pub expanded_rows: BTreeMap<String, bool>,
    pub expanded_tree_rows: BTreeMap<String, bool>,
}

pub fn build_grid_saved_state(context: BuildGridSavedStateContext) -> GridSavedState {
    GridSavedState {
        column_order: context.column_order,
        filters: context.active_filters,
        sort: Some(context.sort_state),
        grouping: context.group_by_columns,
        pagination: Some(GridSavedPaginationState {
            pagination_current_page: current_page_value(context.current_page),
            pagination_page_size: effective_page_size(context.page_size, context.total_items),
        }),
        expandable: context.expanded_rows,
        tree_view: context.expanded_tree_rows,
    }
}

pub fn normalize_grid_saved_state(value: &Value) -> GridSavedState {
    let mut normalized = GridSavedState::default();
    let Some(state) = value.as_object() else {
        return normalized;
    };

    if let Some(Value::Array(column_order)) = state.get("columnOrder") {
        normalized.column_order = column_order
            .iter()
            .filter_map(|value| value.as_str())
            .filter(|value| is_safe_state_key(value))
            .map(ToOwned::to_owned)
            .collect();
    }

    if let Some(Value::Object(filters)) = state.get("filters") {
        normalized.filters = filters
            .iter()
            .filter_map(|(key, value)| {
                let string_value = value.as_str()?;
                is_safe_state_key(key).then(|| (key.clone(), string_value.to_string()))
            })
            .collect();
    }

    if let Some(Value::Object(sort)) = state.get("sort") {
        normalized.sort = Some(SortState {
            column_name: sort
                .get("columnName")
                .and_then(Value::as_str)
                .filter(|value| is_safe_state_key(value))
                .map(ToOwned::to_owned),
            direction: match sort.get("direction").and_then(Value::as_str) {
                Some("asc") => SortDirection::Asc,
                Some("desc") => SortDirection::Desc,
                _ => SortDirection::None,
            },
        });
    }

    if let Some(Value::Array(grouping)) = state.get("grouping") {
        normalized.grouping = grouping
            .iter()
            .filter_map(Value::as_str)
            .filter(|value| is_safe_state_key(value))
            .map(ToOwned::to_owned)
            .collect();
    }

    if let Some(Value::Object(pagination)) = state.get("pagination") {
        normalized.pagination = Some(GridSavedPaginationState {
            pagination_current_page: pagination
                .get("paginationCurrentPage")
                .and_then(Value::as_u64)
                .map(|value| value.max(1) as usize)
                .unwrap_or(1),
            pagination_page_size: pagination
                .get("paginationPageSize")
                .and_then(Value::as_u64)
                .map(|value| value as usize)
                .unwrap_or(0),
        });
    }

    if let Some(Value::Object(expandable)) = state.get("expandable") {
        normalized.expandable = normalize_boolean_map(expandable);
    }

    if let Some(Value::Object(tree_view)) = state.get("treeView") {
        normalized.tree_view = normalize_boolean_map(tree_view);
    }

    normalized
}

fn current_page_value(current_page: usize) -> usize {
    current_page.max(1)
}

fn effective_page_size(page_size: usize, total_items: usize) -> usize {
    if page_size > 0 {
        page_size
    } else {
        total_items
    }
}

pub fn sanitize_download_filename(value: &str) -> String {
    let sanitized = value
        .chars()
        .map(|ch| {
            if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
                ch
            } else {
                '_'
            }
        })
        .collect::<String>();
    let trimmed = sanitized.trim_matches('_').to_string();
    if trimmed.is_empty() {
        "ui-grid".to_string()
    } else {
        trimmed
    }
}

pub fn normalize_boolean_map(value: &serde_json::Map<String, Value>) -> BTreeMap<String, bool> {
    value
        .iter()
        .filter_map(|(key, entry)| {
            let boolean = entry.as_bool()?;
            is_safe_state_key(key).then(|| (key.clone(), boolean))
        })
        .collect()
}

pub fn is_safe_state_key(value: &str) -> bool {
    !matches!(value, "__proto__" | "constructor" | "prototype")
}