Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facet-wrapped/src/tabular.rs

//! **Data-bearing views** — tables, the Iceberg browser, KPI grids, charts, matrices.
//!
//! facett owns real components for every one of these (`facett-table`,
//! `facett-icebergview`, `facett-stat`, `facett-plot`, `facett-matrix`), and they are
//! what data applications actually put on screen. A caller that draws rows or bars
//! itself has re-implemented them.
//!
//! The rule: **a caller supplies values; the component decides every pixel.** The
//! payloads below carry cells, labels and numbers — never a colour, a width or a sort
//! order.
//!
//! # Contracts
//!
//! `table` — a grid of already-stringified cells:
//! ```json
//! { "title": "Person", "columns": ["id", "name"], "rows": [["0", "n0"], ["1", "n1"]] }
//! ```
//! Rows may be arrays of cells or objects keyed by column; columns are inferred from
//! the first object row when omitted.
//!
//! `icebergview` — a table browsed across snapshots (newest-first picker + row grid):
//! ```json
//! { "title": "warehouse", "table": "Person",
//!   "columns": ["id", "name"],
//!   "snapshots": [{ "id": 7, "timestamp_ms": 1699999999000, "rows": [["0", "n0"]] }] }
//! ```
//!
//! `stat-grid` — labelled magnitudes; the CATEGORICAL bar surface:
//! ```json
//! { "title": "rows_by_table",
//!   "rows": [{ "label": "TaxRecord", "value": 1200, "status": "ok", "spark": [1,2,3] }] }
//! ```
//! `bars: [{key, value}]` is accepted as an alias so a `(key, value)` series needs no
//! reshaping.
//!
//! `plot` — continuous `(x, y)` series: `{ "kind": "line"|"bars", "series": [{ "name", "points" }] }`.
//!
//! `matrix` — a labelled heat/adjacency grid:
//! `{ "rows": ["a"], "columns": ["b"], "values": [[0.5]] }`.

use std::sync::Arc;

use facett_core::Facet;
use serde_json::Value;

use crate::registry::{DATA, Entry, Registry};

/// Register every data-bearing component.
pub(crate) fn register(r: &mut Registry) {
    r.register(
        Entry::new("table", "A sortable/filterable data grid.", || {
            let mut t = facett_table::Table::new("repos", vec!["name".into(), "version".into()]);
            t.push_row(vec!["knut".into(), "0.1".into()]);
            t.push_row(vec!["korp".into(), "0.2".into()]);
            Box::new(t)
        })
        .in_group(DATA)
        .with_data(table),
    );
    r.register(
        Entry::new(
            "icebergview",
            "Browse + time-travel an Iceberg table: newest-first snapshot picker over a row grid.",
            || Box::new(facett_icebergview::IcebergView::new("iceberg", "demo", Vec::new())),
        )
        .in_group(DATA)
        .with_data(icebergview),
    );
    r.register(
        Entry::new("stat-grid", "A KPI/magnitude grid with sparklines + status pills.", || {
            Box::new(facett_stat::StatGridView::local())
        })
        .in_group(DATA)
        .with_data(stat_grid),
    );
    r.register(
        Entry::new("plot", "A line/bar chart over named (x, y) series.", || {
            Box::new(facett_plot::PlotView::line(
                "plot",
                vec![("demo".into(), vec![(0.0, 0.0), (1.0, 1.0)])],
            ))
        })
        .in_group(DATA)
        .with_data(plot),
    );
    r.register(
        Entry::new("matrix", "A labelled heat/adjacency matrix.", || {
            Box::new(facett_matrix::MatrixView::local())
        })
        .in_group(DATA)
        .with_data(matrix),
    );
    r.register(
        Entry::new("timeline", "A lane/bar timeline of tasks and phases.", || {
            Box::new(facett_timeline::TimelineView::local())
        })
        .in_group(DATA),
    );
}

fn title_of(v: &Value, fallback: &str) -> String {
    v.get("title")
        .and_then(Value::as_str)
        .filter(|s| !s.trim().is_empty())
        .unwrap_or(fallback)
        .to_string()
}

/// Stringify a JSON cell the way a server-formatted preview would.
fn cell(v: &Value) -> String {
    match v {
        Value::String(s) => s.clone(),
        Value::Null => String::new(),
        other => other.to_string(),
    }
}

/// Resolve the column roster: explicit `columns`, else the keys of the first object row.
fn columns_of(v: &Value, rows: &[Value]) -> Option<Vec<String>> {
    let cols: Vec<String> = match v.get("columns").and_then(Value::as_array) {
        Some(c) => c.iter().map(cell).collect(),
        None => match rows.first() {
            Some(Value::Object(m)) => m.keys().cloned().collect(),
            // array rows with no declared columns are not a renderable table
            _ => return None,
        },
    };
    (!cols.is_empty()).then_some(cols)
}

/// Lower one row (array or object) into cells in `columns` order.
fn row_cells(r: &Value, columns: &[String]) -> Option<Vec<String>> {
    match r {
        Value::Array(cells) => Some(cells.iter().map(cell).collect()),
        Value::Object(m) => {
            Some(columns.iter().map(|c| m.get(c).map(cell).unwrap_or_default()).collect())
        }
        _ => None,
    }
}

/// **`table`** — [`facett_table::Table`].
#[must_use]
pub fn table(v: &Value) -> Option<Box<dyn Facet>> {
    let rows_json = v.get("rows")?.as_array()?;
    let columns = columns_of(v, rows_json)?;
    let mut t = facett_table::Table::new(title_of(v, "table"), columns.clone());
    for r in rows_json {
        if let Some(cells) = row_cells(r, &columns) {
            t.push_row(cells);
        }
    }
    (!t.rows.is_empty()).then(|| Box::new(t) as Box<dyn Facet>)
}

/// **`icebergview`** — [`facett_icebergview::IcebergView`].
///
/// The component takes Arrow frames, so each snapshot's rows are lowered into a
/// `RecordBatch` of Utf8 columns (cells arrive already server-formatted). Snapshots
/// are ordered by the component's own newest-first picker.
#[must_use]
pub fn icebergview(v: &Value) -> Option<Box<dyn Facet>> {
    use arrow_array::{ArrayRef, RecordBatch, StringArray};
    use arrow_schema::{DataType, Field, Schema};

    let snaps_json = v.get("snapshots")?.as_array()?;
    let mut snapshots = Vec::new();

    for (i, s) in snaps_json.iter().enumerate() {
        let rows_json = s.get("rows").and_then(Value::as_array).cloned().unwrap_or_default();
        // Columns may be declared per snapshot or once at the top level.
        let columns = columns_of(s, &rows_json).or_else(|| columns_of(v, &rows_json))?;

        let cells: Vec<Vec<String>> =
            rows_json.iter().filter_map(|r| row_cells(r, &columns)).collect();

        let schema = Arc::new(Schema::new(
            columns.iter().map(|c| Field::new(c, DataType::Utf8, true)).collect::<Vec<_>>(),
        ));
        let arrays: Vec<ArrayRef> = (0..columns.len())
            .map(|ci| {
                let col: Vec<&str> =
                    cells.iter().map(|r| r.get(ci).map(String::as_str).unwrap_or("")).collect();
                Arc::new(StringArray::from(col)) as ArrayRef
            })
            .collect();

        let batches = match RecordBatch::try_new(schema, arrays) {
            Ok(b) => vec![b],
            Err(_) => Vec::new(),
        };
        let meta = facett_icebergview::SnapshotMeta::new(
            s.get("id").and_then(Value::as_i64).unwrap_or(i as i64),
            s.get("timestamp_ms").and_then(Value::as_i64).unwrap_or(0),
            cells.len() as u64,
        );
        snapshots.push(facett_icebergview::Snapshot::new(meta, batches));
    }

    if snapshots.is_empty() {
        return None;
    }
    let table_name = v.get("table").and_then(Value::as_str).unwrap_or("table").to_string();
    Some(Box::new(facett_icebergview::IcebergView::new(
        title_of(v, "iceberg"),
        table_name,
        snapshots,
    )))
}

/// **`stat-grid`** — [`facett_stat::StatGridView`], the categorical magnitude surface.
#[must_use]
pub fn stat_grid(v: &Value) -> Option<Box<dyn Facet>> {
    let arr = v.get("rows").or_else(|| v.get("bars")).and_then(Value::as_array)?;
    let mut view = facett_stat::StatGridView::new(title_of(v, "stats"));
    for r in arr {
        let label = r
            .get("label")
            .or_else(|| r.get("key"))
            .and_then(Value::as_str)
            .unwrap_or_default();
        let Some(value) = r.get("value").and_then(Value::as_f64) else { continue };
        if label.is_empty() {
            continue;
        }
        let mut row = facett_stat::StatRow::new(label, value);
        if let Some(spark) = r.get("spark").and_then(Value::as_array) {
            let pts: Vec<f64> = spark.iter().filter_map(Value::as_f64).collect();
            if !pts.is_empty() {
                row = row.with_spark(pts);
            }
        }
        if let Some(status) = r.get("status").and_then(Value::as_str).filter(|s| !s.is_empty()) {
            row = row.with_status(status);
        }
        view.rows.push(row);
    }
    (!view.rows.is_empty()).then(|| Box::new(view) as Box<dyn Facet>)
}

/// **`plot`** — [`facett_plot::PlotView`].
#[must_use]
pub fn plot(v: &Value) -> Option<Box<dyn Facet>> {
    let mut series: Vec<(String, Vec<(f64, f64)>)> = Vec::new();
    for s in v.get("series")?.as_array()? {
        let name = s.get("name").and_then(Value::as_str).unwrap_or("series").to_string();
        let points: Vec<(f64, f64)> = s
            .get("points")
            .and_then(Value::as_array)
            .map(|pts| {
                pts.iter()
                    .filter_map(|p| {
                        let a = p.as_array()?;
                        Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))
                    })
                    .collect()
            })
            .unwrap_or_default();
        if !points.is_empty() {
            series.push((name, points));
        }
    }
    if series.is_empty() {
        return None;
    }
    let title = title_of(v, "plot");
    let bars =
        v.get("kind").and_then(Value::as_str).is_some_and(|k| k.eq_ignore_ascii_case("bars"));
    Some(Box::new(if bars {
        facett_plot::PlotView::bars(title, series)
    } else {
        facett_plot::PlotView::line(title, series)
    }))
}

/// **`matrix`** — [`facett_matrix::MatrixView`], the per-repo × per-aspect TEST matrix.
///
/// Contract (one object per test result — the flat row shape the component derives its
/// grid from):
/// ```json
/// { "title": "tests",
///   "rows": [{ "repo": "facett", "aspect": "unit", "test_name": "f::t1",
///              "status": "pass", "run_id": "run-a", "duration_ms": 1.0,
///              "message": "", "metric": 0.0 }],
///   "expected_repos": ["facett"], "expected_aspects": ["build", "unit"] }
/// ```
/// `expected_*` are optional and paint the not-run surface before results arrive.
#[must_use]
pub fn matrix(v: &Value) -> Option<Box<dyn Facet>> {
    use facett_matrix::TestResultRow;

    let rows: Vec<TestResultRow> = v
        .get("rows")?
        .as_array()?
        .iter()
        .filter_map(|r| {
            let repo = r.get("repo").and_then(Value::as_str)?.to_string();
            let aspect = r.get("aspect").and_then(Value::as_str).unwrap_or("").to_string();
            let status = r.get("status").and_then(Value::as_str).unwrap_or("").to_string();
            if aspect.is_empty() || status.is_empty() {
                return None;
            }
            Some(TestResultRow {
                run_id: r.get("run_id").and_then(Value::as_str).unwrap_or("run").to_string(),
                suite: r
                    .get("suite")
                    .and_then(Value::as_str)
                    .unwrap_or(&repo)
                    .to_string(),
                test_name: r
                    .get("test_name")
                    .and_then(Value::as_str)
                    .unwrap_or(&aspect)
                    .to_string(),
                message: r.get("message").and_then(Value::as_str).unwrap_or("").to_string(),
                duration_ms: r.get("duration_ms").and_then(Value::as_f64).unwrap_or(0.0),
                ts_micros: r.get("ts_micros").and_then(Value::as_i64).unwrap_or(0),
                metric: r.get("metric").and_then(Value::as_f64).unwrap_or(0.0),
                repo,
                aspect,
                status,
            })
        })
        .collect();
    if rows.is_empty() {
        return None;
    }

    let mut view = facett_matrix::MatrixView::new(title_of(v, "matrix")).with_rows(rows);
    let repos: Vec<String> = v
        .get("expected_repos")
        .and_then(Value::as_array)
        .map(|a| a.iter().map(cell).filter(|s| !s.is_empty()).collect())
        .unwrap_or_default();
    let aspects: Vec<String> = v
        .get("expected_aspects")
        .and_then(Value::as_array)
        .map(|a| a.iter().map(cell).filter(|s| !s.is_empty()).collect())
        .unwrap_or_default();
    if !repos.is_empty() || !aspects.is_empty() {
        let refs: Vec<&str> = aspects.iter().map(String::as_str).collect();
        view = view.with_expected(repos, &refs);
    }
    Some(Box::new(view))
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    /// A server-formatted preview: explicit columns, arrays of stringified cells.
    #[test]
    fn table_builds_from_server_formatted_rows() {
        let v = json!({
            "title": "Person",
            "columns": ["_gar_id", "name", "kommun"],
            "rows": [["0", "n0", "Stockholm"], ["1", "n1", "Jönköping"]]
        });
        let f = table(&v).expect("builds");
        assert_eq!(f.title(), "Person");
        let st = f.state_json();
        assert!(st.is_object(), "{st}");
    }

    /// A catalog listing is naturally a list of objects — columns are inferred.
    #[test]
    fn table_accepts_object_rows_and_infers_columns() {
        let v = json!({
            "title": "catalog",
            "rows": [{ "table": "Person", "rows": 220 }, { "table": "Debt", "rows": 100 }]
        });
        assert!(table(&v).is_some());
    }

    #[test]
    fn table_object_rows_follow_the_declared_column_order() {
        let v = json!({ "columns": ["b", "a"], "rows": [{ "a": "1", "b": "2" }] });
        let f = table(&v).expect("builds");
        let st = f.state_json();
        // the component reports the DECLARED order, not the object's key order
        assert_eq!(st["columns"], json!(["b", "a"]), "{st}");
        assert_eq!(st["rows"], json!(1));
    }

    #[test]
    fn icebergview_builds_snapshots_of_arrow_frames() {
        let v = json!({
            "title": "warehouse",
            "table": "Person",
            "columns": ["id", "name"],
            "snapshots": [
                { "id": 7, "timestamp_ms": 1_699_999_999_000i64,
                  "rows": [["0", "n0"], ["1", "n1"]] },
                { "id": 6, "timestamp_ms": 1_699_000_000_000i64, "rows": [["0", "n0"]] }
            ]
        });
        let f = icebergview(&v).expect("builds");
        let st = f.state_json();
        assert!(st.is_object(), "{st}");
        let s = serde_json::to_string(&st).unwrap();
        assert!(s.contains("Person"), "the browsed table name is observable: {s}");
    }

    /// `(key, value)` bars are accepted with no reshaping.
    #[test]
    fn stat_grid_builds_from_key_value_bars() {
        let v = json!({
            "title": "rows_by_table",
            "bars": [{ "key": "TaxRecord", "value": 1200.0 }, { "key": "Person", "value": 220.0 }]
        });
        let f = stat_grid(&v).expect("builds");
        assert_eq!(f.title(), "rows_by_table");
    }

    #[test]
    fn stat_grid_takes_spark_and_status() {
        let v = json!({
            "rows": [{ "label": "ok", "value": 1.0, "spark": [1.0, 2.0], "status": "good" }]
        });
        assert!(stat_grid(&v).is_some());
    }

    #[test]
    fn plot_builds_line_and_bars() {
        assert!(plot(&json!({ "series": [{ "name": "s", "points": [[0.0, 1.0], [1.0, 2.0]] }] }))
            .is_some());
        assert!(plot(&json!({ "kind": "bars", "series": [{ "name": "s", "points": [[0.0, 1.0]] }] }))
            .is_some());
    }

    /// The test matrix takes flat per-result rows (repo x aspect), not a heat grid.
    #[test]
    fn matrix_builds_from_flat_test_result_rows() {
        let v = json!({
            "title": "tests",
            "rows": [
                { "repo": "facett", "aspect": "build", "test_name": "build", "status": "pass" },
                { "repo": "facett", "aspect": "unit", "test_name": "f::t1", "status": "fail",
                  "message": "boom" },
                { "repo": "korp", "aspect": "unit", "test_name": "k::t1", "status": "pass" }
            ],
            "expected_repos": ["facett", "korp"],
            "expected_aspects": ["build", "unit"]
        });
        let f = matrix(&v).expect("builds");
        assert_eq!(f.title(), "tests");
        let st = serde_json::to_string(&f.state_json()).unwrap();
        assert!(st.contains("facett") && st.contains("korp"), "both repos are on the grid: {st}");
    }

    #[test]
    fn matrix_rejects_rows_without_repo_aspect_or_status() {
        assert!(matrix(&json!({ "rows": [{ "repo": "a" }] })).is_none());
        assert!(matrix(&json!({ "rows": [{ "aspect": "unit", "status": "pass" }] })).is_none());
    }

    /// No fixture is ever substituted for an empty or unusable payload.
    #[test]
    fn empty_payloads_yield_none() {
        for f in [table, icebergview, stat_grid, plot, matrix] {
            assert!(f(&json!({})).is_none());
        }
        assert!(table(&json!({ "rows": [] })).is_none());
        assert!(table(&json!({ "rows": [[1, 2]] })).is_none(), "array rows need columns");
        assert!(table(&json!({ "columns": [], "rows": [["1"]] })).is_none());
        assert!(icebergview(&json!({ "snapshots": [] })).is_none());
        assert!(stat_grid(&json!({ "bars": [] })).is_none());
        assert!(stat_grid(&json!({ "bars": [{ "key": "x" }] })).is_none(), "no value");
        assert!(plot(&json!({ "series": [{ "name": "s", "points": [] }] })).is_none());
    }
}