use renox::chrono::NaiveDate;
use renox::db::{DateTime, Json, Migration};
use renox::grid::{Column, Grid, GridPrefs, GridRequest};
use renox::prelude::*;
use renox::testing::TestApp;
const SCHEMA: Migration = Migration::new(
"20300101000000_grid_orders",
"",
Some("DROP TABLE grid_orders;"),
)
.sqlite(
"CREATE TABLE grid_orders (id INTEGER PRIMARY KEY AUTOINCREMENT, number TEXT NOT NULL,
status TEXT NOT NULL, tags TEXT NOT NULL DEFAULT '[]', total INTEGER NOT NULL,
ordered_on TEXT NOT NULL, placed_at TEXT, paid INTEGER NOT NULL DEFAULT 0)",
None,
)
.postgres(
"CREATE TABLE grid_orders (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
number TEXT NOT NULL, status TEXT NOT NULL, tags JSONB NOT NULL DEFAULT '[]',
total BIGINT NOT NULL, ordered_on DATE NOT NULL, placed_at TIMESTAMPTZ,
paid BOOLEAN NOT NULL DEFAULT FALSE)",
None,
);
#[derive(Model, serde::Serialize, Default, Debug, Clone)]
#[model(table = "grid_orders")]
struct GridOrder {
id: i64,
number: String,
status: String,
tags: Json<Vec<String>>,
total: i64,
ordered_on: NaiveDate,
placed_at: Option<DateTime>,
paid: bool,
}
fn grid() -> Grid {
Grid::new("orders")
.title("Orders")
.column(Column::text("number", "Order").frozen().mobile())
.column(
Column::select("status", "Status", [("new", "New"), ("paid", "Paid")]).under(["State"]),
)
.column(Column::bool("paid", "Paid").under(["State"]))
.column(Column::tags(
"tags",
"Tags",
[("online", "Online"), ("promo", "Promo")],
))
.column(Column::money("total", "Total"))
.column(Column::date("ordered_on", "Ordered"))
.column(Column::datetime("placed_at", "Placed").hidden())
.column(Column::custom("chart", "Chart"))
.per_page(10)
.sort_by("-total")
}
struct Orders;
impl Module for Orders {
fn name(&self) -> &'static str {
"orders"
}
fn routes(&self) -> Routes {
Routes::new()
.get("/orders", index)
.get("/totals", totals)
.get("/exports", exports)
.get("/exports-detail", exports_detail)
.get("/plain", plain)
.group("/back", "back.", Routes::new().get("/exports", exports))
}
}
async fn index(request: GridRequest) -> Result<View> {
let page = grid()
.page(GridOrder::query(), &request)
.await?
.extend(|order| json!({ "points": [1, order.total % 7, 3] }));
Ok(view("orders.html", context! { orders => page }))
}
async fn exports(request: GridRequest) -> Result<Response> {
let grid = grid().exports();
if let Some(file) = grid.export(GridOrder::query(), &request).await? {
return Ok(file);
}
let page = grid.page(GridOrder::query(), &request).await?;
Ok(view("orders.html", context! { orders => page }).into_response())
}
async fn exports_detail(request: GridRequest) -> Result<Response> {
let grid = Grid::new("detail")
.column(Column::text("number", "Order"))
.column(Column::datetime("placed_at", "Placed"))
.column(Column::number("total", "Total").decimals(2))
.column(Column::bool("paid", "Paid"))
.sort_by("number")
.exports();
match grid.export(GridOrder::query(), &request).await? {
Some(file) => Ok(file),
None => Ok("no export".into_response()),
}
}
async fn totals(State(state): State<AppState>, request: GridRequest) -> Result<String> {
let sum: i64 = grid()
.filter(GridOrder::query(), &request)
.sum(&state.db, "total")
.await?;
Ok(sum.to_string())
}
async fn app() -> (TestApp, tempfile::TempDir) {
app_in("UTC").await
}
async fn app_in(timezone: &str) -> (TestApp, tempfile::TempDir) {
app_with(timezone, "IDR").await
}
async fn app_with(timezone: &str, currency: &str) -> (TestApp, tempfile::TempDir) {
let views = tempfile::tempdir().unwrap();
std::fs::write(
views.path().join("orders.html"),
r#"{% from "renox/grid.html" import grid %}<!doctype html><title>t</title>
{% call(row, column) grid(orders) %}<i class="chart">{{ sparkline(row.points) }}</i>{% endcall %}"#,
)
.unwrap();
let app = TestApp::with_config(
App::new()
.migrations(&[SCHEMA])
.module(Auth::new())
.module(Orders),
|c| {
c.views_path = views.path().to_path_buf();
c.timezone = timezone.parse().unwrap();
c.currency = currency.into();
},
)
.await;
let day = |d: u32| NaiveDate::from_ymd_opt(2026, 3, d).unwrap();
let rows = [
("SO-001", "new", vec!["online"], 5_000, day(1), false),
("SO-002", "paid", vec!["promo"], 12_000, day(5), true),
(
"SO-003",
"paid",
vec!["online", "promo"],
30_000,
day(10),
true,
),
("SO-104", "new", vec![], 800, day(20), false),
("XX-105", "paid", vec!["online"], 45_000, day(31), true),
];
for (number, status, tags, total, ordered_on, paid) in rows {
GridOrder::create(
app.db(),
GridOrder {
number: number.into(),
status: status.into(),
tags: Json(tags.into_iter().map(String::from).collect()),
total,
ordered_on,
placed_at: Some(ordered_on.and_hms_opt(9, 0, 0).unwrap().and_utc()),
paid,
..Default::default()
},
)
.await
.unwrap();
}
for i in 0..20 {
GridOrder::create(
app.db(),
GridOrder {
number: format!("ZZ-{i:03}"),
status: "new".into(),
total: 100 + i,
ordered_on: day(28),
..Default::default()
},
)
.await
.unwrap();
}
(app, views)
}
fn text_of(html: &str) -> String {
let mut out = String::new();
let mut tag = false;
for c in html.chars() {
match c {
'<' => tag = true,
'>' => tag = false,
c if !tag => out.push(c),
_ => {}
}
}
out.trim().to_owned()
}
fn numbers(html: &str) -> Vec<String> {
html.split("<td data-col=\"number\"")
.skip(1)
.filter_map(|cell| {
let text = cell.split_once('>')?.1.split_once("</td>")?.0;
Some(text_of(text))
})
.collect()
}
#[renox::test]
async fn money_is_shown_and_filtered_in_whole_units() {
let (app, _views) = app_with("UTC", "USD").await;
let html = app.get("/orders?q.number=SO-003").await.text();
assert!(html.contains(">300.00<"), "{html}");
assert!(!html.contains("30,000"), "{html}");
assert_eq!(
rows(&app, "min.total=120&max.total=300").await,
["SO-003", "SO-002"]
);
assert_eq!(
rows(&app, "min.total=299.99&max.total=300").await,
["SO-003"]
);
assert_eq!(
rows(&app, "r.0.c=total&r.0.o=gte&r.0.v=450").await,
["XX-105"]
);
let csv = app.get("/exports?q.number=XX-105&export=csv").await.text();
assert!(
csv.contains("XX-105,Paid,Yes,Online,450.00,2026-03-31"),
"{csv}"
);
app.get("/exports?q.number=XX-105&export=print")
.await
.assert_see(r#"<td class="num">450.00</td>"#);
}
async fn rows(app: &TestApp, query: &str) -> Vec<String> {
let res = app.get(&format!("/orders?{query}")).await;
res.assert_ok();
numbers(&res.text())
}
#[renox::test]
async fn filters_by_each_kind_of_column() {
let (app, _views) = app().await;
assert_eq!(
rows(&app, "q.number=SO-00").await,
["SO-003", "SO-002", "SO-001"]
);
assert_eq!(
rows(&app, "q.number=so-1&m.number=starts").await,
["SO-104"]
);
assert_eq!(rows(&app, "q.number=105&m.number=ends").await, ["XX-105"]);
assert_eq!(
rows(&app, "q.number=SO-%25-&m.number=equals").await,
Vec::<String>::new()
);
assert_eq!(
rows(&app, "q.number=S_-1%25").await,
["SO-104"],
"a pattern"
);
assert_eq!(
rows(&app, "min.total=10000&max.total=40000").await,
["SO-003", "SO-002"]
);
assert_eq!(
rows(&app, "from.ordered_on=2026-03-05&to.ordered_on=2026-03-10").await,
["SO-003", "SO-002"]
);
assert_eq!(
rows(&app, "from.placed_at=2026-03-31").await,
["XX-105"],
"a date-time column, whole days"
);
assert_eq!(
rows(&app, "in.status=paid&in.paid=true&q.number=SO").await,
["SO-003", "SO-002"]
);
assert_eq!(
rows(&app, "in.paid=false&q.number=SO").await,
["SO-001", "SO-104"]
);
assert_eq!(
rows(&app, "in.tags=promo").await,
["SO-003", "SO-002"],
"tags: any of the picked"
);
assert_eq!(
rows(&app, "in.tags=online&in.tags=promo").await,
["XX-105", "SO-003", "SO-002", "SO-001"]
);
let all = rows(&app, "").await;
for query in [
"q.nope=x",
"q.chart=x",
"min.total=lots",
"from.ordered_on=yesterday",
"in.status=evil",
"sort=chart",
"sort=total;DROP",
"per_page=7",
] {
assert_eq!(rows(&app, query).await, all, "{query}");
}
app.get("/totals?in.status=paid").await.assert_see("87000");
}
#[renox::test]
async fn date_time_filters_take_days_of_the_app_timezone() {
let (app, _views) = app_in("-10:00").await;
assert_eq!(
rows(&app, "from.placed_at=2026-03-31").await,
Vec::<String>::new()
);
assert_eq!(
rows(&app, "from.placed_at=2026-03-30&to.placed_at=2026-03-30").await,
["XX-105"]
);
assert_eq!(
rows(&app, "r.0.c=placed_at&r.0.o=on&r.0.v=2026-03-30").await,
["XX-105"],
"the advanced filter too"
);
assert_eq!(
rows(&app, "r.0.c=placed_at&r.0.o=after&r.0.v=2026-03-30").await,
Vec::<String>::new()
);
assert_eq!(rows(&app, "from.ordered_on=2026-03-31").await, ["XX-105"]);
}
#[renox::test]
async fn sorts_and_pages_from_the_server() {
let (app, _views) = app().await;
let first = rows(&app, "").await;
assert_eq!(first.len(), 10, "per_page(10)");
assert_eq!(first[0], "XX-105", "sort_by(-total)");
assert_eq!(rows(&app, "sort=total").await[0], "ZZ-000");
assert_eq!(rows(&app, "sort=-number&per_page=25").await.len(), 25);
let third = rows(&app, "page=3").await;
assert_eq!(third.len(), 5);
assert_eq!(rows(&app, "page=99").await.len(), 0);
let res = app.get("/orders?q.number=SO&sort=number").await;
res.assert_see("4 rows")
.assert_see(r#"aria-sort="ascending""#)
.assert_see(r#"data-grid-sort="-number""#)
.assert_see(r#"<option value="25">25</option>"#);
}
#[renox::test]
async fn the_macro_draws_headings_cells_and_controls() {
let (app, _views) = app().await;
let res = app.get("/orders?q.number=SO-002").await;
res.assert_ok()
.assert_see(r#"<form class="rx-grid" id="grid-orders""#)
.assert_see(r##"hx-select="#grid-orders""##)
.assert_see("/_renox/grid-")
.assert_see("/_renox/cally-")
.assert_see(r#"<th scope="colgroup" colspan="2" class="rx-grid__group" data-cols="status paid">State</th>"#)
.assert_see(r#"rowspan="2" data-col="number" data-pin="left""#)
.assert_see("12,000")
.assert_see(r#"<span class="rx-grid__tag">Promo</span>"#)
.assert_see(r#"<span class="rx-grid__yes">Yes</span>"#)
.assert_see("2026-03-05")
.assert_see(r#"<i class="chart"><svg class="rx-spark""#)
.assert_see("rx-grid__filter rx-grid__filter--on")
.assert_see(r#"name="q.number" value="SO-002""#)
.assert_see("1 row · filtered");
let html = res.text();
for prefix in ["/_renox/grid-", "/_renox/cally-"] {
let url = html
.split(prefix)
.nth(1)
.unwrap()
.split('"')
.next()
.unwrap();
app.get(&format!("{prefix}{url}")).await.assert_ok();
}
app.get("/orders?q.number=nothing")
.await
.assert_see("Nothing matches these filters.");
}
#[renox::test]
async fn a_grid_in_a_group_links_to_its_whole_path() {
let (app, _views) = app().await;
let html = app
.get("/back/exports?sort=-total")
.await
.assert_ok()
.text();
assert!(
html.contains(r#"method="get" action="/back/exports""#),
"{html}"
);
assert!(html.contains("/back/exports?"), "{html}");
assert!(!html.contains(r#"action="/exports""#), "{html}");
}
#[renox::test]
async fn guests_keep_their_columns_in_the_session() {
let (app, _views) = app().await;
let config = |html: String| -> serde_json::Value {
let attr = html
.split("data-rx-grid=\"")
.nth(1)
.unwrap()
.split('"')
.next()
.unwrap();
serde_json::from_str(&attr.replace(""", "\"")).unwrap()
};
let before = config(app.get("/orders").await.text());
assert_eq!(before["compact"], json!(["number"]));
assert!(
!before["wide"]
.as_array()
.unwrap()
.contains(&json!("placed_at"))
);
let prefs = json!({
"order": ["total", "number", "status"],
"left": ["total"],
"right": [],
"compact": ["total", "number"],
"wide": ["total", "number", "status"]
});
app.post_json("/_renox/grid/orders/prefs", &prefs)
.await
.assert_status(204);
let html = app.get("/orders").await.text();
let after = config(html.clone());
assert_eq!(after["left"], json!(["total"]));
assert_eq!(after["compact"], json!(["total", "number"]));
assert_eq!(after["order"][0], "total");
assert!(html.contains(r#"data-col="total" data-pin="left""#));
assert!(!html.contains(r#"data-col="number" data-pin"#), "unfrozen");
app.post_json(
"/_renox/grid/orders/prefs",
&json!({ "order": ["total", "number", "status"], "widths": { "total": 180 } }),
)
.await
.assert_status(204);
assert_eq!(
config(app.get("/orders").await.text())["widths"],
json!({ "total": 180 })
);
app.post_json(
"/_renox/grid/orders/prefs",
&json!({ "widths": { "total": 5 } }),
)
.await
.assert_status(422);
assert!(
app.get("/orders").await.text().contains("data-grid-resize"),
"every heading has a resize handle"
);
app.post_json("/_renox/grid/orders/prefs", &json!({ "order": ["x y"] }))
.await
.assert_status(422);
app.delete("/_renox/grid/orders/prefs")
.await
.assert_status(204);
assert_eq!(
config(app.get("/orders").await.text())["left"],
json!(["number"])
);
}
#[renox::test]
async fn users_keep_their_columns_in_the_database() {
let (app, _views) = app().await;
let user = User::register(app.db(), "Alex", "alex@example.com", "password123")
.await
.unwrap();
app.acting_as(&user);
app.post_json(
"/_renox/grid/orders/prefs",
&json!({ "compact": ["status"], "left": [] }),
)
.await
.assert_status(204);
app.assert_database_has("grid_preferences", &[("grid", &"orders")])
.await;
app.assert_database_count("grid_preferences", 1).await;
let prefs = GridPrefs::default();
renox::grid::save_prefs(app.db(), user.id, "orders", &prefs)
.await
.unwrap();
app.assert_database_count("grid_preferences", 1).await;
app.post_json("/_renox/grid/orders/prefs", &json!({ "left": [] }))
.await
.assert_status(204);
let html = app.get("/orders").await.text();
assert!(!html.contains("data-pin=\"left\""));
app.delete("/_renox/grid/orders/prefs")
.await
.assert_status(204);
app.assert_database_count("grid_preferences", 0).await;
}
#[renox::test]
async fn grid_requests_work_outside_handlers() {
let (app, _views) = app().await;
let request = GridRequest::new(
app.db(),
"/orders",
&[("in.status", "paid"), ("sort", "number")],
);
assert_eq!(request.param("sort"), Some("number"));
let page = grid().page(GridOrder::query(), &request).await.unwrap();
assert_eq!(page.total(), 3);
let numbers: Vec<&str> = page.items().iter().map(|o| o.number.as_str()).collect();
assert_eq!(numbers, ["SO-002", "SO-003", "XX-105"]);
assert!(
Grid::new("bad id")
.page(GridOrder::query(), &request)
.await
.is_err()
);
}
const TASKS: Migration = Migration::new("20300101000001_grid_tasks", "", Some("DROP TABLE grid_tasks;")).sqlite("CREATE TABLE grid_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, region TEXT NOT NULL,
city TEXT NOT NULL, title TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0,
created_by TEXT NOT NULL DEFAULT '', updated_by TEXT NOT NULL DEFAULT '',
created_at TEXT, updated_at TEXT)", None).postgres("CREATE TABLE grid_tasks (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
region TEXT NOT NULL, city TEXT NOT NULL, title TEXT NOT NULL,
position BIGINT NOT NULL DEFAULT 0, created_by TEXT NOT NULL DEFAULT '',
updated_by TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ)", None);
#[derive(Model, serde::Serialize, Default, Debug, Clone)]
#[model(table = "grid_tasks")]
struct GridTask {
id: i64,
region: String,
city: String,
title: String,
position: i64,
created_by: String,
updated_by: String,
created_at: Option<DateTime>,
updated_at: Option<DateTime>,
}
fn tasks_grid() -> Grid {
Grid::new("tasks")
.column(Column::text("region", "Region").merge())
.column(Column::text("city", "City").merge())
.column(Column::text("title", "Title").editable())
.column(Column::number("position", "#"))
.sort_by("region,city,title")
.audit()
.details()
.edit_url("/tasks/{id}")
.reorder("position", "/tasks/order")
}
struct Tasks;
impl Module for Tasks {
fn name(&self) -> &'static str {
"tasks"
}
fn routes(&self) -> Routes {
Routes::new()
.get("/tasks", |request: GridRequest| async move {
let page = tasks_grid().page(GridTask::query(), &request).await?;
Ok::<_, Error>(view("tasks.html", context! { tasks => page }))
})
.post(
"/tasks/order",
|State(db): State<Db>, Form(order): Form<renox::grid::RowOrder>| async move {
order.save::<GridTask>(&db, "position").await?;
Ok::<_, Error>(StatusCode::NO_CONTENT)
},
)
}
}
async fn tasks_app() -> (TestApp, tempfile::TempDir) {
let views = tempfile::tempdir().unwrap();
std::fs::write(
views.path().join("tasks.html"),
r#"{% from "renox/grid.html" import grid %}
{% call(row, column) grid(tasks) %}{% if column.key == "_details" %}<b class="more">{{ row.title }} more</b>{% endif %}{% endcall %}"#,
)
.unwrap();
let app = TestApp::with_config(App::new().migrations(&[TASKS]).module(Tasks), |c| {
c.views_path = views.path().to_path_buf()
})
.await;
let rows = [
("Java", "Bandung", "a"),
("Java", "Bandung", "b"),
("Java", "Jakarta", "c"),
("Bali", "Denpasar", "d"),
];
for (i, (region, city, title)) in rows.into_iter().enumerate() {
GridTask::create(
app.db(),
GridTask {
region: region.into(),
city: city.into(),
title: title.into(),
position: i as i64 + 10,
created_by: "Alex".into(),
updated_by: "Dana".into(),
..Default::default()
},
)
.await
.unwrap();
}
(app, views)
}
#[renox::test]
async fn equal_neighbours_share_a_merged_cell() {
let (app, _views) = tasks_app().await;
let html = app.get("/tasks").await.text();
let order: Vec<String> = html
.split("<td data-col=\"title\"")
.skip(1)
.filter_map(|c| {
c.split_once("</td>")
.map(|(c, _)| text_of(c.split_once('>').map_or(c, |(_, rest)| rest)))
})
.collect();
assert_eq!(order, ["d", "a", "b", "c"]);
assert!(
html.contains(r#"data-col="region" data-label="Region" rowspan="3" data-group="region-1""#),
"{html}"
);
assert!(html.contains(r#"data-col="city" data-label="City" rowspan="2" data-group="city-1""#));
assert!(html.contains(r#"data-covered="region city""#));
assert!(html.contains(r#"data-covered="region""#));
assert_eq!(
html.matches("<td data-col=\"region\"").count(),
2,
"Bali and Java"
);
assert_eq!(html.matches("<td data-col=\"city\"").count(), 3);
let by_title = app.get("/tasks?sort=title").await.text();
assert_eq!(
by_title.matches("<td data-col=\"region\"").count(),
2,
"a, b, c, d: Java×3 then Bali"
);
}
#[renox::test]
async fn rows_open_their_audit_and_details() {
let (app, _views) = tasks_app().await;
let res = app.get("/tasks").await;
res.assert_see("<template data-grid-details>")
.assert_see("<dt>Created</dt><dd>Alex")
.assert_see("<dt>Last updated</dt><dd>Dana")
.assert_see(r#"<b class="more">d more</b>"#)
.assert_see(r#"data-grid-expand aria-expanded="false""#)
.assert_see("data-grid-row");
}
#[renox::test]
async fn editable_cells_carry_their_values() {
let (app, _views) = tasks_app().await;
let res = app.get("/tasks").await;
let id = GridTask::where_eq("title", "a")
.first(app.db())
.await
.unwrap()
.unwrap()
.id;
res.assert_see(&format!(r#"data-edit="/tasks/{id}""#))
.assert_see(r#"data-editable tabindex="0" data-raw='"a"'"#)
.assert_see("data-grid-edit-row")
.assert_dont_see(r#"data-col="region" data-editable"#);
}
#[renox::test]
async fn rows_reorder_while_sorted_by_their_position() {
let (app, _views) = tasks_app().await;
let html = app.get("/tasks").await.text();
assert!(html.contains("data-grid-drag disabled"));
let html = app.get("/tasks?sort=position").await.text();
assert!(!html.contains("data-grid-drag disabled"));
assert!(!html.contains("rowspan=\"3\" data-group"));
assert!(html.contains(""reorder":"/tasks/order""));
let ids: Vec<i64> = GridTask::query()
.order_by("position")
.get(app.db())
.await
.unwrap()
.iter()
.map(|t| t.id)
.collect();
let reversed: Vec<String> = ids.iter().rev().map(i64::to_string).collect();
app.post(
"/tasks/order",
&[("ids", reversed.join(",").as_str()), ("offset", "100")],
)
.await
.assert_status(204);
let titles: Vec<String> = GridTask::query()
.order_by("position")
.get(app.db())
.await
.unwrap()
.into_iter()
.map(|t| format!("{}:{}", t.title, t.position))
.collect();
assert_eq!(titles, ["d:100", "c:101", "b:102", "a:103"]);
app.post("/tasks/order", &[("ids", "1,x"), ("offset", "0")])
.await
.assert_status(400);
}
#[renox::test]
async fn row_order_checks_its_column() {
let (app, _views) = tasks_app().await;
let order: renox::grid::RowOrder =
serde_json::from_value(json!({ "ids": "1,2", "offset": 5 })).unwrap();
assert_eq!(order.ids, ["1", "2"]);
assert!(order.save::<GridTask>(app.db(), "nope").await.is_err());
assert!(order.save::<GridTask>(app.db(), "id").await.is_err());
assert_eq!(
order.save::<GridTask>(app.db(), "position").await.unwrap(),
2
);
}
#[renox::test]
async fn exports_hold_every_filtered_row() {
let (app, _views) = app().await;
GridOrder::create(
app.db(),
GridOrder {
number: "=HYPERLINK(\"x\")".into(),
status: "paid".into(),
tags: Json(vec!["online".into(), "promo".into()]),
total: 7,
ordered_on: NaiveDate::from_ymd_opt(2026, 4, 1).unwrap(),
paid: true,
..Default::default()
},
)
.await
.unwrap();
let page = app.get("/exports?in.status=paid").await;
page.assert_see("in.status=paid&export=csv")
.assert_see("in.status=paid&export=print");
assert_eq!(
page.text().contains("export=xlsx"),
cfg!(feature = "xlsx"),
"Excel only with the feature"
);
let csv = app.get("/exports?in.status=paid&export=csv").await;
csv.assert_ok()
.assert_header("content-type", "text/csv; charset=utf-8");
let text = csv.text();
let lines: Vec<&str> = text.trim_start_matches('\u{feff}').lines().collect();
assert_eq!(
lines[0],
"Order,State / Status,State / Paid,Tags,Total,Ordered"
);
assert_eq!(lines.len(), 1 + 4);
assert!(
lines.contains(&"XX-105,Paid,Yes,Online,45000,2026-03-31"),
"{text}"
);
assert!(
lines.contains(&"\"'=HYPERLINK(\"\"x\"\")\",Paid,Yes,\"Online, Promo\",7,2026-04-01"),
"{text}"
);
let print = app.get("/exports?in.status=paid&export=print").await;
print
.assert_ok()
.assert_see("<title>Orders</title>")
.assert_see(">State</th>")
.assert_see(r#"<td class="num">45,000</td>"#)
.assert_see("4 rows")
.assert_see("data-grid-print");
let xlsx = app.get("/exports?export=xlsx").await;
if cfg!(feature = "xlsx") {
xlsx.assert_ok().assert_header(
"content-type",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
assert!(xlsx.body.starts_with(b"PK"), "a zip");
} else {
xlsx.assert_status(400);
}
app.get("/exports?export=docx").await.assert_status(400);
app.get("/exports").await.assert_see("rx-grid");
}
fn search_grid(prefix: Option<&str>) -> Grid {
let grid = Grid::new("orders")
.column(Column::text("number", "Order").searchable())
.column(Column::select("status", "Status", [("new", "New"), ("paid", "Paid")]).searchable())
.column(Column::money("total", "Total"))
.sort_by("number")
.row_url("/orders/{id}")
.empty_state("No orders yet", Some("They show up here."));
match prefix {
Some(p) => grid.prefix(p),
None => grid,
}
}
struct Searching;
impl Module for Searching {
fn name(&self) -> &'static str {
"searching"
}
fn routes(&self) -> Routes {
Routes::new()
.get("/search", |request: GridRequest| async move {
let page = search_grid(None).page(GridOrder::query(), &request).await?;
Ok::<_, Error>(view("orders.html", context! { orders => page }))
})
.get("/two", |request: GridRequest| async move {
let a = search_grid(Some("a"))
.page(GridOrder::query(), &request)
.await?;
let b = Grid::new("others")
.prefix("b")
.column(Column::text("number", "Order"))
.page(GridOrder::query(), &request)
.await?;
Ok::<_, Error>(view("two.html", context! { a, b }))
})
.get("/none", |request: GridRequest| async move {
let page = search_grid(None)
.page(GridOrder::where_eq("number", "nothing"), &request)
.await?;
Ok::<_, Error>(view("orders.html", context! { orders => page }))
})
}
}
async fn search_app() -> (TestApp, tempfile::TempDir) {
let (_, views) = app().await; std::fs::write(
views.path().join("two.html"),
r#"{% from "renox/grid.html" import grid %}{{ grid(a) }}{{ grid(b) }}"#,
)
.unwrap();
let app = TestApp::with_config(App::new().migrations(&[SCHEMA]).module(Searching), |c| {
c.views_path = views.path().to_path_buf()
})
.await;
for (number, status, total) in [
("SO-001", "new", 5),
("SO-002", "paid", 7),
("XX-003", "paid", 9),
] {
GridOrder::create(
app.db(),
GridOrder {
number: number.into(),
status: status.into(),
total,
ordered_on: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
..Default::default()
},
)
.await
.unwrap();
}
(app, views)
}
#[renox::test]
async fn the_search_box_looks_in_searchable_columns() {
let (app, _views) = search_app().await;
let found = |q: &'static str| {
let app = &app;
async move { numbers(&app.get(&format!("/search?{q}")).await.text()) }
};
assert_eq!(found("search=so-").await, ["SO-001", "SO-002"], "any case");
assert_eq!(
found("search=paid").await,
["SO-002", "XX-003"],
"the status column"
);
assert_eq!(found("search=so+paid").await, ["SO-002"], "every word");
assert_eq!(found("search=%25").await.len(), 3, "% is LIKE's wildcard");
let res = app.get("/search?search=paid&in.status=paid").await;
res.assert_see(r#"name="search" value="paid""#)
.assert_see("data-grid-clear-search")
.assert_see("Search: “paid”")
.assert_see("Status: Paid");
}
#[renox::test]
async fn rows_link_and_empty_grids_say_why() {
let (app, _views) = search_app().await;
let id = GridOrder::where_eq("number", "SO-001")
.first(app.db())
.await
.unwrap()
.unwrap()
.id;
app.get("/search")
.await
.assert_see(&format!(r#"data-href="/orders/{id}" tabindex="0""#));
app.get("/none")
.await
.assert_see("No orders yet")
.assert_see("They show up here.");
app.get("/search?search=zzz")
.await
.assert_see("Nothing matches these filters.")
.assert_dont_see("No orders yet");
}
#[renox::test]
async fn two_grids_on_a_page_keep_their_own_values() {
let (app, _views) = search_app().await;
let html = app
.get("/two?a.search=SO-002&b.sort=-number&tab=2")
.await
.text();
let (first, second) = html.split_once("id=\"grid-others\"").unwrap();
assert_eq!(numbers(first), ["SO-002"]);
assert_eq!(numbers(second), ["XX-003", "SO-002", "SO-001"]);
assert!(
first.contains(r#"<input type="hidden" name="b.sort" value="-number" data-grid-keep>"#)
);
assert!(first.contains(r#"<input type="hidden" name="tab" value="2" data-grid-keep>"#));
assert!(
second.contains(r#"<input type="hidden" name="a.search" value="SO-002" data-grid-keep>"#)
);
assert!(first.contains(r#"name="a.page""#) && second.contains(r#"name="b.page""#));
}
fn action_grid() -> Grid {
use renox::grid::Action;
Grid::new("orders")
.column(Column::text("number", "Order"))
.column(Column::select(
"status",
"Status",
[("new", "New"), ("paid", "Paid")],
))
.sort_by("number")
.bulk_action(Action::new("Mark paid", "/bulk/paid"))
.bulk_action(
Action::new("Delete", "/bulk/delete")
.confirm("Sure?")
.danger(),
)
.row_action(Action::link("Open", "/orders/{id}"))
.row_action(
Action::new("Remove", "/orders/{id}")
.method("delete")
.confirm("Remove it?"),
)
}
struct Acting;
impl Module for Acting {
fn name(&self) -> &'static str {
"acting"
}
fn routes(&self) -> Routes {
use renox::grid::Selection;
Routes::new()
.get("/acts", |request: GridRequest| async move {
let page = action_grid().page(GridOrder::query(), &request).await?;
Ok::<_, Error>(view("orders.html", context! { orders => page }))
})
.post(
"/bulk/paid",
|State(db): State<Db>, request: GridRequest, Form(selection): Form<Selection>| async move {
let n = action_grid()
.selected(GridOrder::query(), &request, &selection)?
.update(&db, &[("status", &"paid")])
.await?;
Ok::<_, Error>(n.to_string())
},
)
}
}
#[renox::test]
async fn bulk_actions_take_the_selection_or_all_matching() {
let (_, views) = app().await; let app = TestApp::with_config(App::new().migrations(&[SCHEMA]).module(Acting), |c| {
c.views_path = views.path().to_path_buf()
})
.await;
let mut ids = Vec::new();
for (number, status) in [("A1", "new"), ("A2", "new"), ("B1", "new"), ("B2", "paid")] {
let order = GridOrder::create(
app.db(),
GridOrder {
number: number.into(),
status: status.into(),
ordered_on: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
..Default::default()
},
)
.await
.unwrap();
ids.push(order.id);
}
let html = app.get("/acts").await.text();
assert!(html.contains("data-grid-select-all"));
assert!(html.contains(&format!(r#"data-grid-select value="{}""#, ids[0])));
assert!(html.contains(
r#"data-url="/bulk/delete" data-method="POST" data-confirm="Sure?" data-danger"#
));
assert!(html.contains(&format!(r#"href="/orders/{}">Open</a>"#, ids[0])));
assert!(html.contains(&format!(
r#"data-url="/orders/{}" data-method="DELETE" data-confirm="Remove it?""#,
ids[0]
)));
assert!(html.contains("<dialog class=\"rx-grid__dialog\""));
let picked = format!("{},{}", ids[0], ids[2]);
app.post("/bulk/paid", &[("ids", picked.as_str()), ("all", "false")])
.await
.assert_see("2");
app.post("/bulk/paid?q.number=A", &[("ids", ""), ("all", "true")])
.await
.assert_see("2");
let paid = GridOrder::where_eq("status", "paid")
.count(app.db())
.await
.unwrap();
assert_eq!(paid, 4);
app.post("/bulk/paid", &[("ids", "x"), ("all", "false")])
.await
.assert_status(400);
}
fn summary_grid() -> Grid {
use renox::grid::Summary;
Grid::new("orders")
.column(Column::text("number", "Order"))
.column(Column::select(
"status",
"Status",
[("new", "New"), ("paid", "Paid")],
))
.column(Column::bool("paid", "Paid"))
.column(
Column::money("total", "Total")
.summary(Summary::Sum)
.summary(Summary::Average),
)
.column(Column::text("number2", "Count").summary(Summary::Sum)) .column(
Column::date("ordered_on", "Ordered")
.summary(Summary::Count)
.summary(Summary::Range),
)
.sort_by("number")
.groups(&["status", "paid"])
}
struct Summing;
impl Module for Summing {
fn name(&self) -> &'static str {
"summing"
}
fn routes(&self) -> Routes {
Routes::new().get("/sums", |request: GridRequest| async move {
let page = summary_grid().page(GridOrder::query(), &request).await?;
Ok::<_, Error>(view("orders.html", context! { orders => page }))
})
}
}
#[renox::test]
async fn summaries_cover_every_filtered_row_and_each_group() {
let (app, views) = app().await;
drop(app);
let app = TestApp::with_config(App::new().migrations(&[SCHEMA]).module(Summing), |c| {
c.views_path = views.path().to_path_buf()
})
.await;
for (number, status, total, paid) in [
("A", "new", 10_000, false),
("B", "paid", 30_000, true),
("C", "paid", 50_000, true),
("D", "new", 100_000, false),
] {
GridOrder::create(
app.db(),
GridOrder {
number: number.into(),
status: status.into(),
total,
paid,
ordered_on: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
..Default::default()
},
)
.await
.unwrap();
}
let html = app.get("/sums?per_page=10").await.text();
assert!(
html.contains("<tfoot>"),
"{}",
&html[html.len().saturating_sub(3000)..]
);
let foot = html.split("<tfoot>").nth(1).unwrap();
assert!(foot.contains("1,900.00"), "{foot}");
assert!(foot.contains("475.00"), "the average");
assert!(foot.contains("Count</span>4"), "{foot}");
assert!(
!html.contains("rx-grid__group-row"),
"no groups until picked"
);
let filtered = app.get("/sums?in.status=paid").await.text();
assert!(filtered.split("<tfoot>").nth(1).unwrap().contains("800"));
let grouped = app.get("/sums?group=status").await.text();
assert_eq!(grouped.matches("rx-grid__group-row").count(), 2);
assert_eq!(grouped.matches("rx-grid__subtotal").count(), 2);
assert!(
grouped.contains("Status:</span> New</button><span class=\"rx-grid__group-count\">2 rows")
);
let rows: Vec<String> = numbers(&grouped)
.into_iter()
.filter(|n| n != "This group" && n != "All rows")
.collect();
assert_eq!(rows, ["A", "D", "B", "C"]);
let subtotals: Vec<&str> = grouped.split("rx-grid__subtotal").skip(1).collect();
assert!(subtotals[0].contains("1,100") && subtotals[1].contains("800"));
let by_paid = app.get("/sums?group=paid").await.text();
assert_eq!(by_paid.matches("rx-grid__group-row").count(), 2);
assert!(by_paid.contains("Paid:</span> <span class=\"rx-grid__no\">No</span>"));
assert!(
!app.get("/sums?group=number")
.await
.text()
.contains("rx-grid__group-row")
);
}
#[renox::test]
async fn column_kinds_draw_their_cells() {
let (_, views) = app().await; struct Kinds;
impl Module for Kinds {
fn name(&self) -> &'static str {
"kinds"
}
fn routes(&self) -> Routes {
Routes::new().get("/kinds", |request: GridRequest| async move {
let grid = Grid::new("kinds")
.column(Column::text("number", "Order").copyable().link("/orders/{id}"))
.column(
Column::select("status", "Status", [("new", "New"), ("paid", "Paid")])
.badges(&[("paid", "success"), ("new", "danger")])
.tooltip("number"),
)
.column(Column::bool("paid", "Paid").icons())
.column(Column::image("photo", "Photo").round())
.column(Column::color("shade", "Shade"))
.column(Column::text("note", "Note").wrap().limit(12).description("number"))
.cards_on_mobile();
let page = grid
.page(GridOrder::query(), &request)
.await?
.extend(|o| json!({ "photo": "/p.png", "shade": "#0a7d5a", "note": format!("A long note about {}", o.number) }));
Ok::<_, Error>(view("orders.html", context! { orders => page }))
})
}
}
let app = TestApp::with_config(App::new().migrations(&[SCHEMA]).module(Kinds), |c| {
c.views_path = views.path().to_path_buf()
})
.await;
let order = GridOrder::create(
app.db(),
GridOrder {
number: "SO-9".into(),
status: "new".into(),
paid: true,
ordered_on: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
..Default::default()
},
)
.await
.unwrap();
app.get("/kinds")
.await
.assert_see(r#"<form class="rx-grid rx-grid--cards""#)
.assert_see(&format!(
r#"<a class="rx-grid__cell-link" href="/orders/{}">SO-9</a>"#,
order.id
))
.assert_see(r#"data-grid-copy="SO-9""#)
.assert_see(r#"<span class="rx-badge rx-badge--error">New</span>"#)
.assert_see(r#"data-label="Status" title="SO-9""#)
.assert_see(r#"<span class="rx-grid__yes" role="img" aria-label="Yes">"#)
.assert_see(r#"<img class="rx-grid__img rx-grid__img--round" src="/p.png""#)
.assert_see(r#"<span class="rx-grid__swatch" style="background: #0a7d5a"></span>"#)
.assert_see(r#"style="max-width: 12ch""#)
.assert_see("rx-grid__wrap")
.assert_see("rx-grid__clip")
.assert_see(r#"<span class="rx-grid__desc">SO-9</span>"#)
.assert_see("data-grid-sort-pick");
}
const PETS: Migration = Migration::new("20300101000002_grid_pets", "", Some("DROP TABLE grid_pets; DROP TABLE grid_owners;")).sqlite("CREATE TABLE grid_owners (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
CREATE TABLE grid_pets (id INTEGER PRIMARY KEY AUTOINCREMENT, owner_id INTEGER,
name TEXT NOT NULL, weight INTEGER NOT NULL DEFAULT 0, born TEXT)", None).postgres("CREATE TABLE grid_owners (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE grid_pets (id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, owner_id BIGINT,
name TEXT NOT NULL, weight BIGINT NOT NULL DEFAULT 0, born DATE)", None);
#[derive(Model, serde::Serialize, Default, Debug, Clone)]
#[model(table = "grid_owners")]
struct GridOwner {
id: i64,
name: String,
}
#[derive(Model, serde::Serialize, Default, Debug, Clone)]
#[model(table = "grid_pets")]
struct GridPet {
id: i64,
owner_id: Option<i64>,
name: String,
weight: i64,
born: Option<NaiveDate>,
}
fn owners_grid() -> Grid {
Grid::new("owners")
.column(Column::text("name", "Name").searchable())
.column(Column::count_of("pets", "Pets", "grid_pets", "owner_id"))
.column(Column::sum_of(
"kilos",
"Kilos",
"grid_pets",
"owner_id",
"weight",
))
.sort_by("name")
}
fn pets_grid() -> Grid {
Grid::new("pets")
.column(Column::text("name", "Pet"))
.column(Column::related("owner", "Owner", "grid_owners", "owner_id", "name").searchable())
.column(Column::number("weight", "Weight"))
.column(Column::date("born", "Born"))
.sort_by("name")
.advanced_filter()
.remember()
.poll(20)
}
struct Pets;
impl Module for Pets {
fn name(&self) -> &'static str {
"pets"
}
fn routes(&self) -> Routes {
Routes::new()
.get("/owners", |request: GridRequest| async move {
let page = owners_grid().page(GridOwner::query(), &request).await?;
Ok::<_, Error>(view("orders.html", context! { orders => page }))
})
.get("/pets", |request: GridRequest| async move {
let page = pets_grid().page(GridPet::query(), &request).await?;
Ok::<_, Error>(view("orders.html", context! { orders => page }))
})
}
}
#[derive(Model, serde::Serialize, Default, Debug, Clone)]
#[model(table = "grid_pets", default_scope = "owners_pets")]
struct ScopedPet {
id: i64,
owner_id: Option<i64>,
name: String,
weight: i64,
born: Option<NaiveDate>,
}
#[derive(Clone)]
struct CurrentOwner(i64);
fn owners_pets(query: renox::db::Query<ScopedPet>) -> renox::db::Query<ScopedPet> {
match renox::context::get::<CurrentOwner>() {
Some(owner) => query.where_eq("owner_id", owner.0),
None => query.none(),
}
}
struct ScopedPets;
impl Module for ScopedPets {
fn name(&self) -> &'static str {
"scoped-pets"
}
fn routes(&self) -> Routes {
Routes::new()
.get("/staff/pets", |request: GridRequest| async move {
let grid = pets_grid().exports();
if let Some(file) = grid.export(ScopedPet::unscoped(), &request).await? {
return Ok::<_, Error>(file);
}
let page = grid.page(ScopedPet::unscoped(), &request).await?;
Ok(view("orders.html", context! { orders => page }).into_response())
})
.get(
"/owners/{owner}/pets",
|Path(owner): Path<i64>, request: GridRequest| async move {
renox::context::set(CurrentOwner(owner));
let page = pets_grid().page(ScopedPet::query(), &request).await?;
Ok::<_, Error>(view("orders.html", context! { orders => page }))
},
)
}
}
#[renox::test]
async fn related_columns_fill_in_on_unscoped_queries_of_scoped_models() {
let (_, views) = pets_app().await;
let app = TestApp::with_config(App::new().migrations(&[PETS]).module(ScopedPets), |c| {
c.views_path = views.path().to_path_buf()
})
.await;
let anna = GridOwner::create(
app.db(),
GridOwner {
name: "Anna".into(),
..Default::default()
},
)
.await
.unwrap();
let ben = GridOwner::create(
app.db(),
GridOwner {
name: "Ben".into(),
..Default::default()
},
)
.await
.unwrap();
for (owner, name) in [(anna.id, "Kiki"), (ben.id, "Rex")] {
GridPet::create(
app.db(),
GridPet {
owner_id: Some(owner),
name: name.into(),
weight: 1,
..Default::default()
},
)
.await
.unwrap();
}
let staff = app.get("/staff/pets").await.text();
assert_eq!(cells(&staff, "name"), ["Kiki", "Rex"]);
assert_eq!(cells(&staff, "owner"), ["Anna", "Ben"]);
let csv = app.get("/staff/pets?export=csv").await.text();
assert!(
csv.contains("Kiki,Anna") && csv.contains("Rex,Ben"),
"{csv}"
);
let own = app.get(&format!("/owners/{}/pets", anna.id)).await.text();
assert_eq!(cells(&own, "name"), ["Kiki"]);
assert_eq!(cells(&own, "owner"), ["Anna"]);
}
async fn pets_app() -> (TestApp, tempfile::TempDir) {
let (_, views) = app().await; let app = TestApp::with_config(App::new().migrations(&[PETS]).module(Pets), |c| {
c.views_path = views.path().to_path_buf()
})
.await;
let mut owners = Vec::new();
for name in ["Anna", "Ben", "Clara"] {
owners.push(
GridOwner::create(
app.db(),
GridOwner {
name: name.into(),
..Default::default()
},
)
.await
.unwrap()
.id,
);
}
let day = |d: u32| NaiveDate::from_ymd_opt(2024, 5, d);
for (owner, name, weight, born) in [
(Some(owners[0]), "Kiki", 4, day(1)),
(Some(owners[0]), "Momo", 6, day(10)),
(Some(owners[1]), "Rex", 30, day(20)),
(None, "Stray", 3, None),
] {
GridPet::create(
app.db(),
GridPet {
owner_id: owner,
name: name.into(),
weight,
born,
..Default::default()
},
)
.await
.unwrap();
}
(app, views)
}
fn cells(html: &str, column: &str) -> Vec<String> {
html.split(&format!("<td data-col=\"{column}\""))
.skip(1)
.filter_map(|cell| {
let text = cell.split_once('>')?.1.split_once("</td>")?.0;
Some(text_of(text))
})
.collect()
}
#[renox::test]
async fn related_columns_show_sort_filter_and_search() {
let (app, _views) = pets_app().await;
let html = app.get("/owners").await.text();
assert_eq!(cells(&html, "name"), ["Anna", "Ben", "Clara"]);
assert_eq!(cells(&html, "pets"), ["2", "1", "0"]);
assert_eq!(cells(&html, "kilos"), ["10", "30", "0"]);
let by_pets = app.get("/owners?sort=-pets").await.text();
assert_eq!(cells(&by_pets, "name"), ["Anna", "Ben", "Clara"]);
let heavy = app.get("/owners?min.kilos=20").await.text();
assert_eq!(cells(&heavy, "name"), ["Ben"]);
let pets = app.get("/pets?state=1").await.text();
assert_eq!(cells(&pets, "owner"), ["Anna", "Anna", "Ben", ""]);
let sorted = app.get("/pets?state=1&sort=-owner").await.text();
assert_eq!(cells(&sorted, "name")[0], "Rex");
let filtered = app.get("/pets?state=1&q.owner=ben").await.text();
assert_eq!(cells(&filtered, "name"), ["Rex"]);
let searched = app.get("/pets?state=1&search=anna").await.text();
assert_eq!(cells(&searched, "name"), ["Kiki", "Momo"]);
}
#[renox::test]
async fn the_advanced_filter_combines_rules() {
let (app, _views) = pets_app().await;
let names = |q: &'static str| {
let app = &app;
async move { cells(&app.get(&format!("/pets?state=1&{q}")).await.text(), "name") }
};
assert_eq!(
names("r.0.c=weight&r.0.o=gt&r.0.v=5").await,
["Momo", "Rex"]
);
assert_eq!(
names("r.0.c=weight&r.0.o=gt&r.0.v=5&r.1.c=owner&r.1.o=equals&r.1.v=anna").await,
["Momo"],
"all by default"
);
assert_eq!(
names("match=any&r.0.c=weight&r.0.o=lt&r.0.v=4&r.1.c=owner&r.1.o=equals&r.1.v=ben").await,
["Rex", "Stray"]
);
assert_eq!(names("r.0.c=owner&r.0.o=empty").await, ["Stray"]);
assert_eq!(
names("r.0.c=name&r.0.o=not_contains&r.0.v=k").await,
["Momo", "Rex", "Stray"]
);
assert_eq!(
names("r.0.c=born&r.0.o=before&r.0.v=2024-05-10").await,
["Kiki"]
);
assert_eq!(
names("r.0.c=born&r.0.o=on&r.0.v=2024-05-10").await,
["Momo"]
);
assert_eq!(
names("r.0.c=born&r.0.o=after&r.0.v=2024-05-10").await,
["Rex"]
);
for q in [
"r.0.c=weight&r.0.o=gt&r.0.v=heavy",
"r.0.c=nope&r.0.o=eq&r.0.v=1",
"r.0.c=weight&r.0.o=like&r.0.v=1",
"r.99.c=weight&r.99.o=gt&r.99.v=1",
] {
assert_eq!(names(q).await.len(), 4, "{q}");
}
let page = app.get("/pets?state=1&r.0.c=weight&r.0.o=gt&r.0.v=5").await;
page.assert_see("Advanced filter: 1 rule")
.assert_see(r#"name="r.0.v" value="5""#)
.assert_see(""poll":20");
}
#[renox::test]
async fn remembered_grids_come_back_as_they_were_left() {
let (app, _views) = pets_app().await;
let names = |q: &'static str| {
let app = &app;
async move { cells(&app.get(&format!("/pets{q}")).await.text(), "name") }
};
assert_eq!(
names("?state=1&q.owner=anna&sort=-name").await,
["Momo", "Kiki"]
);
assert_eq!(names("").await, ["Momo", "Kiki"]);
assert_eq!(names("?other=1").await, ["Momo", "Kiki"]);
assert_eq!(names("?state=1").await.len(), 4);
assert_eq!(names("").await.len(), 4);
}
#[renox::test]
async fn negative_rules_keep_rows_without_a_value() {
let (app, _views) = pets_app().await;
let names = |q: &'static str| {
let app = &app;
async move { cells(&app.get(&format!("/pets?state=1&{q}")).await.text(), "name") }
};
assert_eq!(
names("r.0.c=owner&r.0.o=not_empty").await,
["Kiki", "Momo", "Rex"]
);
assert_eq!(
names("r.0.c=owner&r.0.o=not_equals&r.0.v=ANNA").await,
["Rex", "Stray"],
"case doesn't matter, and no owner is 'not Anna'"
);
assert_eq!(names("r.0.c=born&r.0.o=empty").await, ["Stray"]);
assert_eq!(
names("r.0.c=weight&r.0.o=ne&r.0.v=6").await,
["Kiki", "Rex", "Stray"]
);
assert_eq!(
names("r.0.c=weight&r.0.o=lte&r.0.v=4").await,
["Kiki", "Stray"]
);
assert_eq!(names("r.0.c=name&r.0.o=is_not&r.0.v=Rex").await.len(), 4);
assert_eq!(names("r.0.c=name&r.0.o=starts&r.0.v=m").await, ["Momo"]);
assert_eq!(names("r.0.c=name&r.0.o=ends&r.0.v=X").await, ["Rex"]);
}
#[renox::test]
async fn exports_show_moments_in_the_apps_zone_decimals_and_yes_no() {
let (_, views) = app().await;
let path = views.path().to_path_buf();
let app = TestApp::with_config(App::new().migrations(&[SCHEMA]).module(Orders), move |c| {
c.views_path = path;
c.timezone = "+07:00".parse().unwrap();
})
.await;
let at = renox::chrono::DateTime::parse_from_rfc3339("2026-04-01T20:00:00Z")
.unwrap()
.with_timezone(&renox::chrono::Utc);
for (number, placed_at, total, paid) in [("A-1", Some(at), 7, true), ("A-2", None, 1250, false)]
{
GridOrder::create(
app.db(),
GridOrder {
number: number.into(),
status: "new".into(),
total,
ordered_on: NaiveDate::from_ymd_opt(2026, 4, 1).unwrap(),
placed_at,
paid,
..Default::default()
},
)
.await
.unwrap();
}
let csv = app.get("/exports-detail?export=csv").await.text();
let lines: Vec<&str> = csv.trim_start_matches('\u{feff}').lines().collect();
assert_eq!(lines[0], "Order,Placed,Total,Paid");
assert_eq!(lines[1], "A-1,2026-04-02 03:00,7.00,Yes", "{csv}");
assert_eq!(lines[2], "A-2,,1250.00,No", "{csv}");
let xlsx = app.get("/exports-detail?export=xlsx").await;
if cfg!(feature = "xlsx") {
xlsx.assert_ok();
assert!(xlsx.body.starts_with(b"PK"));
}
}
fn plain_grid() -> Grid {
Grid::new("plain")
.column(
Column::text("number", "Order")
.sortable(false)
.filterable(false),
)
.column(Column::text("status", "Status").width("12rem"))
.column(Column::text("total", "Total").numeric().decimals(1))
.group_by("status")
.per_page(7)
}
async fn plain(request: GridRequest) -> Result<renox::axum::Json<serde_json::Value>> {
let page = plain_grid().page(GridOrder::query(), &request).await?;
Ok(renox::axum::Json(serde_json::to_value(page)?))
}
#[renox::test]
async fn column_options_turn_sorting_and_filtering_off() {
let grid = plain_grid();
assert_eq!(grid.id(), "plain");
let keys: Vec<&str> = grid.columns().iter().map(Column::key).collect();
assert_eq!(keys, ["number", "status", "total"]);
let (app, _views) = app().await;
let page = app.get("/plain").await;
page.assert_ok();
let options = page.json_path("per_page_options");
assert!(options.as_array().unwrap().contains(&json!(7)), "{options}");
let column = |key: &str| {
page.json_path("columns")
.as_array()
.unwrap()
.iter()
.find(|c| c["key"] == key)
.cloned()
.unwrap()
};
assert_eq!(column("number")["sortable"], false);
assert_eq!(column("number")["filterable"], false);
assert_eq!(column("status")["width"], "12rem");
let statuses: Vec<String> = page
.json_path("rows")
.as_array()
.unwrap()
.iter()
.map(|r| r["status"].as_str().unwrap_or_default().to_owned())
.collect();
let mut sorted = statuses.clone();
sorted.sort();
assert!(!statuses.is_empty());
assert_eq!(statuses, sorted);
}
#[renox::test]
async fn row_orders_refuse_more_than_a_thousand_rows() {
let (app, _views) = app().await;
let ids: Vec<String> = (0..1001).map(|i| i.to_string()).collect();
let order: renox::grid::RowOrder =
serde_json::from_value(json!({ "ids": ids.join(","), "offset": 0 })).unwrap();
let err = order
.save::<GridOrder>(app.db(), "total")
.await
.unwrap_err();
assert!(
matches!(err, Error::BadRequest(ref m) if m == "too many rows"),
"{err:?}"
);
}