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());
}
}