//! DataGrid features end to end: totals, filters, sorting, views, row actions, selection, editing
//! and export, through the HTTP endpoints with permissions.
use axum::Router;
use axum::body::Body;
use axum::http::{HeaderMap, Request, StatusCode, header};
use http_body_util::BodyExt;
use std::sync::{Arc, RwLock};
use tower::ServiceExt;
use zeyn::prelude::*;
use zeyn::{Aggregate, AggregateRequest, I18n, Identity, Value};
/// Item used by the grid tests.
#[entity(table = "items")]
pub struct Item {
#[key]
pub id: ItemId,
#[rule(required, max_len = 20)]
pub code: String,
pub kind: Kind,
#[rule(min = 0)]
pub price: Money,
pub qty: i64,
pub active: bool,
pub added: Date,
pub due: Option<Date>,
#[readonly]
pub note: String,
#[version]
pub version: i64,
}
/// Item kind.
#[choice]
pub enum Kind {
Tool,
Part,
Food,
}
#[derive(Clone, Default)]
pub struct Store(Arc<RwLock<Vec<Item>>>);
impl Service for Store {}
fn item(i: i64) -> Item {
Item {
id: ItemId(i),
code: format!("C-{i:02}"),
kind: match i % 3 {
0 => Kind::Tool,
1 => Kind::Part,
_ => Kind::Food,
},
price: Money::tl(i * 1_000),
qty: i,
active: i % 2 == 0,
added: Date::new(2026, 1, i as u8).unwrap(),
due: (i % 4 == 0).then(|| Date::new(2026, 3, i as u8).unwrap()),
note: format!("note {i}"),
version: 1,
}
}
impl Store {
fn seeded() -> Self {
Store(Arc::new(RwLock::new((1..=20).map(item).collect())))
}
}
#[action(permission = "items.read")]
pub async fn search(q: DataQuery, store: Store) -> Result<Page<Item>> {
Ok(q.apply(store.0.read().unwrap().clone()))
}
#[screen(route = "/items", permission = "items.read")]
pub fn list() -> Screen {
Screen::new("Items").body(
DataGrid::of(search)
.paging(Paging::pages(5))
.column(Item::code)
.filter()
.count()
.column(Item::kind)
.filter()
.column(Item::price)
.filter()
.sum()
.avg()
.column(Item::qty)
.sum()
.avg()
.min()
.max()
.column(Item::added)
.filter()
.min()
.max()
.column(Item::active)
.filter(),
)
}
fn app(identity: Identity) -> App {
App::new("Test")
.i18n(
I18n::new("en")
.catalog("tr", "{}")
.unwrap(),
)
.service(Store::seeded())
.auth(move |h: &HeaderMap| {
h.contains_key("x-user")
.then(|| identity.clone())
})
.feature(
Feature::new("items")
.screen(list)
.api(search),
)
}
fn router(identity: Identity) -> Router {
app(identity).router().unwrap()
}
fn reader() -> Identity {
Identity::user("ayse").with_permissions(["items.read"])
}
struct Reply {
status: StatusCode,
headers: HeaderMap,
body: String,
}
impl Reply {
fn json(&self) -> serde_json::Value {
serde_json::from_str(&self.body).unwrap()
}
}
async fn send(router: &Router, req: Request<Body>) -> Reply {
let res = router
.clone()
.oneshot(req)
.await
.unwrap();
let status = res.status();
let headers = res.headers().clone();
let body = String::from_utf8(
res.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
Reply {
status,
headers,
body,
}
}
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn post_as(path: &str, body: serde_json::Value, locale: &str) -> Request<Body> {
Request::post(path)
.header("x-user", "1")
.header(header::CONTENT_TYPE, "application/json")
.header(
header::COOKIE,
format!("zeyn_csrf={TOKEN}; zeyn_locale={locale}"),
)
.header("x-zeyn-csrf", TOKEN)
.body(Body::from(body.to_string()))
.unwrap()
}
fn post(path: &str, body: serde_json::Value) -> Request<Body> {
post_as(path, body, "en")
}
fn get(path: &str) -> Request<Body> {
Request::get(path)
.header("x-user", "1")
.body(Body::empty())
.unwrap()
}
fn agg(field: &str, op: Aggregate) -> AggregateRequest {
AggregateRequest {
field: field.into(),
op,
}
}
fn query(json: serde_json::Value) -> DataQuery {
serde_json::from_value(json).unwrap()
}
#[test]
fn totals_cover_every_filtered_row_not_only_the_page() {
let rows: Vec<Item> = (1..=20).map(item).collect();
let mut q = query(serde_json::json!({ "page": { "type": "offset", "offset": 0, "limit": 2 } }));
q.aggregates = vec![
agg("qty", Aggregate::Sum),
agg("qty", Aggregate::Avg),
agg("qty", Aggregate::Min),
agg("qty", Aggregate::Max),
agg("price", Aggregate::Sum),
agg("price", Aggregate::Avg),
agg("due", Aggregate::Count),
agg("added", Aggregate::Max),
];
let page = q.apply(rows);
assert_eq!(page.items.len(), 2);
let value = |field: &str, op: Aggregate| {
page.totals
.iter()
.find(|t| t.field == field && t.op == op)
.unwrap()
.value
.clone()
};
assert_eq!(value("qty", Aggregate::Sum), Value::Int(210));
assert_eq!(value("qty", Aggregate::Avg), Value::Decimal(10.5));
assert_eq!(value("qty", Aggregate::Min), Value::Int(1));
assert_eq!(value("qty", Aggregate::Max), Value::Int(20));
assert_eq!(
value("price", Aggregate::Sum),
Value::Money(Money::tl(210_000))
);
assert_eq!(
value("price", Aggregate::Avg),
Value::Money(Money::tl(10_500))
);
assert_eq!(value("due", Aggregate::Count), Value::Int(5));
assert_eq!(
value("added", Aggregate::Max),
Value::Date(Date::new(2026, 1, 20).unwrap())
);
}
#[test]
fn totals_ignore_unknown_fields_and_ops_that_do_not_fit() {
let q = DataQuery {
aggregates: vec![
agg("nope", Aggregate::Sum),
agg("code", Aggregate::Sum),
agg("code", Aggregate::Count),
],
..DataQuery::default()
};
let page = q.apply((1..=3).map(item).collect());
assert_eq!(page.totals.len(), 1);
assert_eq!(page.totals[0].value, Value::Int(3));
}
#[test]
fn money_average_rounds_half_away_from_zero() {
let values = [Value::Money(Money::tl(1)), Value::Money(Money::tl(2))].into_iter();
assert_eq!(
zeyn::total(values, Aggregate::Avg),
Some(Value::Money(Money::tl(2)))
);
let values = [Value::Money(Money::tl(-1)), Value::Money(Money::tl(-2))].into_iter();
assert_eq!(
zeyn::total(values, Aggregate::Avg),
Some(Value::Money(Money::tl(-2)))
);
let mixed = [
Value::Money(Money::tl(1)),
Value::Money(Money::new(1, zeyn::Currency::Usd)),
]
.into_iter();
assert_eq!(zeyn::total(mixed, Aggregate::Sum), None);
assert_eq!(
zeyn::total(std::iter::empty(), Aggregate::Avg),
Some(Value::Null)
);
}
#[tokio::test]
async fn page_renders_footer_totals_with_the_first_page() {
let r = send(&router(reader()), get("/items")).await;
assert_eq!(r.status, StatusCode::OK);
assert!(
r.body
.contains(r#"<tfoot data-zeyn-foot>"#)
);
assert!(
r.body
.contains(r#"data-field="qty" data-agg="sum avg min max""#)
);
assert!(
r.body.contains(
r#"<span class="z-agg-label">Sum</span> <span class="z-agg-value">210</span>"#
)
);
assert!(r.body.contains("₺2,100.00"));
assert!(
r.body
.contains(r#"<span class="z-agg-value">10.50</span>"#)
);
}
#[tokio::test]
async fn grid_endpoint_returns_totals_of_the_filtered_set_in_the_users_language() {
let body = serde_json::json!({
"screen": "list", "node": 0,
"query": {
"page": { "type": "offset", "offset": 0, "limit": 2 },
"filters": [{ "field": "code", "contains": "c-1" }],
"with_total": true,
"aggregates": [{ "field": "price", "op": "sum" }, { "field": "qty", "op": "avg" }]
}
});
let r = send(&router(reader()), post_as("/_zeyn/grid", body, "tr")).await;
assert_eq!(r.status, StatusCode::OK);
let j = r.json();
assert_eq!(j["total"], 10);
let foot = j["foot"].as_str().unwrap();
assert!(foot.contains("1.450,00 ₺"), "{foot}");
assert!(foot.contains("14,50"), "{foot}");
assert!(foot.contains("Toplam"), "{foot}");
}
#[tokio::test]
async fn grid_endpoint_computes_only_totals_the_grid_declares() {
let body = serde_json::json!({
"screen": "list", "node": 0,
"query": { "aggregates": [{ "field": "note", "op": "count" }, { "field": "kind", "op": "count" }] }
});
let r = send(&router(reader()), post("/_zeyn/grid", body)).await;
assert_eq!(r.status, StatusCode::OK);
assert!(r.json()["foot"].is_null());
let r = send(
&router(reader()),
post(
"/_zeyn/grid",
serde_json::json!({ "screen": "list", "node": 0 }),
),
)
.await;
assert!(r.json()["foot"].is_null());
}
#[tokio::test]
async fn grid_endpoint_needs_the_read_permission() {
let r = send(
&router(Identity::user("x")),
post(
"/_zeyn/grid",
serde_json::json!({ "screen": "list", "node": 0 }),
),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let _ = r.headers;
}
/// Event with a moment, for time zone filters.
#[entity(table = "events")]
pub struct Event {
#[key]
pub id: EventId,
pub at: DateTime,
}
fn codes(page: &Page<Item>) -> Vec<String> {
page.items
.iter()
.map(|i| i.code.clone())
.collect()
}
fn filtered(filters: Vec<zeyn::Filter>) -> Page<Item> {
let q = DataQuery {
filters,
page: zeyn::PageRequest::Offset {
offset: 0,
limit: 100,
},
..DataQuery::default()
};
q.apply((1..=20).map(item).collect())
}
#[test]
fn number_and_money_ranges_are_inclusive_and_open_ended() {
use zeyn::Filter;
assert_eq!(
codes(&filtered(vec![Filter::range("qty", Some("18"), None)])),
["C-18", "C-19", "C-20"]
);
assert_eq!(
codes(&filtered(vec![Filter::range(
"price",
Some("10"),
Some("30.00")
)])),
["C-01", "C-02", "C-03"]
);
assert_eq!(
filtered(vec![Filter::range("price", Some("10,5"), Some("20"))])
.items
.len(),
1
);
assert_eq!(
filtered(vec![Filter::range("qty", Some("abc"), Some("1e9"))])
.items
.len(),
20
);
}
#[test]
fn date_ranges_and_optional_dates() {
use zeyn::Filter;
let page = filtered(vec![Filter::range(
"added",
Some("2026-01-05"),
Some("2026-01-07"),
)]);
assert_eq!(codes(&page), ["C-05", "C-06", "C-07"]);
let page = filtered(vec![Filter::range("due", None, Some("2026-03-08"))]);
assert_eq!(codes(&page), ["C-04", "C-08"]);
}
#[test]
fn moment_date_filters_use_the_users_whole_day() {
use zeyn::Filter;
let at = DateTime::parse("2026-03-01T22:30:00Z").unwrap();
let rows = vec![Event { id: EventId(1), at }];
let mut q = DataQuery {
filters: vec![Filter::range("at", Some("2026-03-02"), Some("2026-03-02"))],
..DataQuery::default()
};
assert_eq!(q.apply(rows.clone()).items.len(), 0);
q.time_zone = TimeZone::named("Europe/Istanbul").unwrap();
assert_eq!(q.apply(rows.clone()).items.len(), 1);
q.filters = vec![Filter::range(
"at",
Some("2026-03-02T01:00"),
Some("2026-03-02T01:30"),
)];
assert_eq!(q.apply(rows).items.len(), 1);
}
#[test]
fn choices_booleans_and_text_operators() {
use zeyn::Filter;
assert_eq!(
filtered(vec![Filter::any_of("kind", ["Tool", "nope"])])
.items
.len(),
6
);
assert_eq!(
filtered(vec![Filter::any_of("kind", ["nope"])])
.items
.len(),
0
);
assert_eq!(
filtered(vec![Filter::is("active", true)])
.items
.len(),
10
);
let starts = Filter {
field: "code".into(),
starts_with: Some("c-1".into()),
..Filter::default()
};
assert_eq!(filtered(vec![starts]).items.len(), 10);
let equals = Filter {
field: "code".into(),
equals: Some("C-07".into()),
..Filter::default()
};
assert_eq!(codes(&filtered(vec![equals])), ["C-07"]);
let too_long = Filter::contains("code", "x".repeat(zeyn::MAX_FILTER_TEXT + 1));
assert_eq!(filtered(vec![too_long]).items.len(), 20);
}
#[test]
fn old_contains_filters_still_read() {
let q: DataQuery =
query(serde_json::json!({ "filters": [{ "field": "code", "contains": "C-2" }] }));
assert_eq!(codes(&q.apply((1..=20).map(item).collect())), ["C-20"]);
}
fn grid_call(query: serde_json::Value) -> serde_json::Value {
serde_json::json!({ "screen": "list", "node": 0, "query": query })
}
#[tokio::test]
async fn grid_endpoint_reads_typed_filters() {
let q = serde_json::json!({
"with_total": true,
"filters": [
{ "field": "kind", "any_of": ["Part", "Food"] },
{ "field": "price", "from": "50", "to": "150" },
{ "field": "active", "is": true }
]
});
let r = send(&router(reader()), post("/_zeyn/grid", grid_call(q))).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.json()["total"], 3, "{}", r.body);
}
#[tokio::test]
async fn grid_endpoint_ignores_filters_and_sorts_on_fields_it_does_not_offer() {
let q = serde_json::json!({
"with_total": true,
"filters": [{ "field": "note", "contains": "note 1" }, { "field": "qty", "from": "1" }, { "field": "secret", "contains": "x" }],
"sort": [{ "field": "note", "desc": true }]
});
let r = send(&router(reader()), post("/_zeyn/grid", grid_call(q))).await;
assert_eq!(r.status, StatusCode::OK);
let j = r.json();
assert_eq!(j["total"], 20);
let rows = j["rows"].as_str().unwrap();
assert!(rows.find("C-01").unwrap() < rows.find("C-02").unwrap());
}
#[tokio::test]
async fn grid_endpoint_sorts_by_several_columns() {
let q = serde_json::json!({
"page": { "type": "offset", "offset": 0, "limit": 4 },
"sort": [{ "field": "kind", "desc": false }, { "field": "qty", "desc": true }]
});
let r = send(&router(reader()), post("/_zeyn/grid", grid_call(q))).await;
let rows = r.json()["rows"]
.as_str()
.unwrap()
.to_string();
let order: Vec<usize> = ["C-20", "C-17", "C-14", "C-11"]
.iter()
.map(|c| rows.find(c).unwrap())
.collect();
assert!(order.windows(2).all(|w| w[0] < w[1]), "{rows}");
}
#[tokio::test]
async fn header_names_filter_kinds_and_localized_choices() {
let req = Request::get("/items")
.header("x-user", "1")
.header(header::COOKIE, "zeyn_locale=tr")
.body(Body::empty())
.unwrap();
let r = send(&router(reader()), req).await;
assert!(
r.body.contains(
r#"data-field="price" aria-sort="none" aria-colindex="3" data-filter="money""#
),
"{}",
r.body
);
assert!(
r.body
.contains(r#"data-filter="choice" data-options=""#)
);
assert!(r.body.contains(r#"data-filter="bool""#));
assert!(r.body.contains(r#"data-filter="date""#));
assert!(r.body.contains("data-zeyn-texts="));
assert!(r.body.contains("Tümü"));
}
fn views_router(store: zeyn::MemoryViewStore) -> Router {
App::new("Test")
.service(Store::seeded())
.datagrid_views(store)
.auth(|h: &HeaderMap| {
let user = h
.get("x-user")?
.to_str()
.ok()?
.to_string();
let perms: Vec<&str> = if user == "guest" {
vec![]
} else {
vec!["items.read"]
};
Some(Identity::user(user).with_permissions(perms))
})
.feature(Feature::new("items").screen(list))
.router()
.unwrap()
}
fn as_user(mut req: Request<Body>, user: &str) -> Request<Body> {
req.headers_mut()
.insert("x-user", user.parse().unwrap());
req
}
fn save_view(view: serde_json::Value) -> serde_json::Value {
serde_json::json!({ "screen": "list", "node": 0, "view": view })
}
#[tokio::test]
async fn saved_view_comes_back_on_the_next_page_load_with_its_sort_and_filters() {
let store = zeyn::MemoryViewStore::default();
let router = views_router(store.clone());
let view = serde_json::json!({
"columns": [{ "field": "qty", "width": 5000 }, { "field": "code", "pin": "start" }, { "field": "secret" }, { "field": "qty" }],
"sort": [{ "field": "qty", "desc": true }, { "field": "note", "desc": false }],
"filters": [{ "field": "kind", "any_of": ["Tool"] }, { "field": "note", "contains": "x" }]
});
let r = send(
&router,
as_user(post("/_zeyn/grid/view", save_view(view)), "ayse"),
)
.await;
assert_eq!(r.status, StatusCode::NO_CONTENT, "{}", r.body);
let page = send(&router, as_user(get("/items"), "ayse")).await;
assert!(
page.body
.contains(r#"data-zeyn-views="server""#)
);
let head = page
.body
.split("<thead>")
.nth(1)
.unwrap()
.split("</thead>")
.next()
.unwrap();
assert!(
head.find(r#"data-field="code""#)
.unwrap()
< head
.find(r#"data-field="qty""#)
.unwrap()
);
assert!(head.contains(r#"data-pin="start""#));
assert!(head.contains("width:1200px;"), "{head}");
assert!(!head.contains(r#"data-field="kind""#));
assert!(
page.body
.contains(r#"data-zeyn-total="6""#),
"only tools, the note filter is not offered"
);
let rows = page
.body
.split("<tbody data-zeyn-rows>")
.nth(1)
.unwrap();
assert!(
rows.starts_with("<tr data-zeyn-key=\"18\"><td>C-18</td><td class=\"z-num\">18</td></tr>"),
"{}",
&rows[..120]
);
let other = send(&router, as_user(get("/items"), "mert")).await;
assert!(!other.body.contains("data-zeyn-view="));
assert!(
other
.body
.contains(r#"data-zeyn-total="20""#)
);
let r = send(
&router,
as_user(
post("/_zeyn/grid/view", save_view(serde_json::Value::Null)),
"ayse",
),
)
.await;
assert_eq!(r.status, StatusCode::NO_CONTENT);
let page = send(&router, as_user(get("/items"), "ayse")).await;
assert!(!page.body.contains("data-zeyn-view="));
}
#[tokio::test]
async fn saving_a_view_needs_a_store_a_user_and_read_access() {
let views = views_router(zeyn::MemoryViewStore::default());
let r = send(
&views,
as_user(
post("/_zeyn/grid/view", save_view(serde_json::json!({}))),
"guest",
),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
let r = send(
&router(reader()),
post("/_zeyn/grid/view", save_view(serde_json::json!({}))),
)
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
let r = send(
&views,
as_user(
post(
"/_zeyn/grid/view",
serde_json::json!({ "screen": "list", "node": 7, "view": {} }),
),
"ayse",
),
)
.await;
assert_eq!(r.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn grid_endpoint_renders_rows_and_header_in_the_requested_layout() {
let body = serde_json::json!({
"screen": "list", "node": 0, "head": true,
"query": { "page": { "type": "offset", "offset": 0, "limit": 1 }, "aggregates": [{ "field": "qty", "op": "sum" }, { "field": "price", "op": "sum" }] },
"view": { "columns": [{ "field": "price", "pin": "end" }, { "field": "qty" }, { "field": "note" }] }
});
let r = send(&router(reader()), post("/_zeyn/grid", body)).await;
let j = r.json();
assert_eq!(
j["rows"],
"<tr data-zeyn-key=\"1\"><td class=\"z-num\">1</td><td class=\"z-num\">₺10.00</td></tr>"
);
let head = j["head"].as_str().unwrap();
assert!(head.find("qty").unwrap() < head.find("price").unwrap());
assert!(head.contains(r#"data-pin="end""#));
let foot = j["foot"].as_str().unwrap();
assert!(
foot.find(">210<").unwrap() < foot.find("₺2,100.00").unwrap(),
"{foot}"
);
}
#[test]
fn at_most_three_columns_stay_pinned_per_side() {
use zeyn::{DataGridView, GridSource};
let grid = DataGrid::of(search)
.column(Item::code)
.column(Item::kind)
.column(Item::price)
.column(Item::qty)
.column(Item::added)
.done();
let columns: Vec<serde_json::Value> = ["code", "kind", "price", "qty", "added"]
.iter()
.map(|f| serde_json::json!({ "field": f, "pin": "start" }))
.collect();
let view: DataGridView =
serde_json::from_value(serde_json::json!({ "columns": columns })).unwrap();
let clean = GridSource::clean_view(&grid, view);
assert_eq!(
clean
.columns
.iter()
.filter(|c| c.pin.is_some())
.count(),
3
);
assert_eq!(clean.columns.len(), 5);
}
#[action(permission = "items.write")]
pub async fn bump(id: ItemId, store: Store) -> Result<()> {
let mut rows = store.0.write().unwrap();
let row = rows
.iter_mut()
.find(|r| r.id == id)
.ok_or(Error::NotFound)?;
if row.qty > 100 {
return Err(Error::Denied("too many".into()));
}
row.qty += 100;
Ok(())
}
#[screen(route = "/act", permission = "items.read")]
pub fn act() -> Screen {
Screen::new("Act").body(
DataGrid::of(search)
.column(Item::code)
.column(Item::kind)
.badge(|k| match k {
Kind::Tool => zeyn::Tone::Info,
_ => zeyn::Tone::Neutral,
})
.column(Item::qty)
.progress(40)
.column(Item::active)
.check_icon()
.row_action("Bump", bump, |i| i.id),
)
}
#[screen(route = "/act-nested", permission = "items.read")]
pub fn act_nested() -> Screen {
Screen::new("Act nested").body(
Panel::new("Items").add(
DataGrid::of(search)
.column(Item::code)
.row_action("Bump", bump, |i| i.id),
),
)
}
#[screen(route = "/sorted", permission = "items.read")]
pub fn sorted() -> Screen {
Screen::new("Sorted").body(
DataGrid::of(search)
.sort_by(Item::qty, true)
.column(Item::code)
.column(Item::qty),
)
}
fn act_router(identity: Identity) -> (Router, Store) {
let store = Store::seeded();
let router = App::new("Test")
.service(store.clone())
.auth(move |h: &HeaderMap| {
h.contains_key("x-user")
.then(|| identity.clone())
})
.feature(
Feature::new("items")
.screen(act)
.screen(act_nested)
.screen(sorted),
)
.router()
.unwrap();
(router, store)
}
fn run(key: &str, item: &str) -> Request<Body> {
post(
"/_zeyn/grid/run",
serde_json::json!({ "screen": "act", "node": 0, "key": key, "item": item }),
)
}
#[tokio::test]
async fn cells_render_badges_progress_and_icons() {
let (router, _) = act_router(reader());
let r = send(&router, get("/act")).await;
assert!(
r.body
.contains(r#"<span class="z-badge z-tone-neutral">Part</span>"#)
);
assert!(
r.body
.contains(r#"<span class="z-badge z-tone-info">Tool</span>"#)
);
assert!(
r.body
.contains(r#"style="--z-pct:2.5%""#),
"1 of 40"
);
assert!(
r.body
.contains(r#"<span class="z-bool z-bool-no" role="img" aria-label="No"></span>"#)
);
assert!(
r.body
.contains(r#"<tr data-zeyn-key="1">"#)
);
}
#[tokio::test]
async fn row_action_buttons_show_only_with_the_actions_permission() {
let (router, _) = act_router(reader());
let r = send(&router, get("/act")).await;
assert!(
r.body
.contains(r#"<td class="z-row-actions"></td>"#)
);
assert!(!r.body.contains("data-zeyn-run="));
let writer = Identity::user("w").with_permissions(["items.read", "items.write"]);
let (router, _) = act_router(writer);
let r = send(&router, get("/act")).await;
assert!(r.body.contains(r#"data-zeyn-run="a0""#));
}
#[tokio::test]
async fn row_action_runs_with_input_built_from_the_row_on_the_server() {
let writer = Identity::user("w").with_permissions(["items.read", "items.write"]);
let (router, store) = act_router(writer);
let r = send(&router, run("7", "a0")).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.body);
assert_eq!(store.0.read().unwrap()[6].qty, 107);
let r = send(&router, run("7", "a0")).await;
assert_eq!(
r.status,
StatusCode::FORBIDDEN,
"the action's own error comes back"
);
}
#[tokio::test]
async fn a_default_sort_orders_the_first_page_and_reaches_the_browser() {
let (router, _) = act_router(reader());
let r = send(&router, get("/sorted")).await;
assert!(
r.body.contains(r#"data-zeyn-sort-default="[{"field":"qty","desc":true}]""#),
"{}",
r.body
);
let first = r
.body
.find(r#"<tr data-zeyn-key="#)
.map(|i| &r.body[i..i + 30])
.unwrap_or_default();
let max = Store::seeded()
.0
.read()
.unwrap()
.iter()
.max_by_key(|i| i.qty)
.map(|i| i.id.0)
.unwrap();
assert!(
first.contains(&format!(r#"data-zeyn-key="{max}""#)),
"{first}"
);
}
#[tokio::test]
async fn grid_endpoints_take_node_paths_as_the_browser_sends_them() {
let writer = Identity::user("w").with_permissions(["items.read", "items.write"]);
let (router, store) = act_router(writer);
let top = post(
"/_zeyn/grid/run",
serde_json::json!({ "screen": "act", "node": "0", "key": "7", "item": "a0" }),
);
assert_eq!(send(&router, top).await.status, StatusCode::OK);
let page = send(&router, get("/act-nested")).await;
assert!(
page.body
.contains(r#"data-zeyn-node="0.0""#),
"{}",
page.body
);
let nested = post(
"/_zeyn/grid/run",
serde_json::json!({ "screen": "act_nested", "node": "0.0", "key": "8", "item": "a0" }),
);
assert_eq!(send(&router, nested).await.status, StatusCode::OK);
assert_eq!(
(
store.0.read().unwrap()[6].qty,
store.0.read().unwrap()[7].qty
),
(107, 108)
);
let missing = post(
"/_zeyn/grid/run",
serde_json::json!({ "screen": "act_nested", "node": "0.5", "key": "7", "item": "a0" }),
);
assert_eq!(send(&router, missing).await.status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn row_action_is_refused_without_permission_unknown_items_or_rows() {
let (router, store) = act_router(reader());
assert_eq!(
send(&router, run("7", "a0"))
.await
.status,
StatusCode::FORBIDDEN
);
assert_eq!(store.0.read().unwrap()[6].qty, 7);
let writer = Identity::user("w").with_permissions(["items.read", "items.write"]);
let (router, _) = act_router(writer.clone());
assert_eq!(
send(&router, run("7", "a1"))
.await
.status,
StatusCode::NOT_FOUND
);
assert_eq!(
send(&router, run("7", "x"))
.await
.status,
StatusCode::NOT_FOUND
);
assert_eq!(
send(&router, run("999", "a0"))
.await
.status,
StatusCode::NOT_FOUND
);
let (router, _) = act_router(Identity::user("w").with_permissions(["items.write"]));
assert_eq!(
send(&router, run("7", "a0"))
.await
.status,
StatusCode::FORBIDDEN,
"screen access first"
);
}
#[action(permission = "items.write")]
pub async fn deactivate(ids: Vec<ItemId>, store: Store) -> Result<usize> {
let mut rows = store.0.write().unwrap();
let mut n = 0;
for r in rows
.iter_mut()
.filter(|r| ids.contains(&r.id))
{
r.active = false;
n += 1;
}
Ok(n)
}
#[screen(route = "/pick", permission = "items.read")]
pub fn pick() -> Screen {
Screen::new("Pick").body(
DataGrid::of(search)
.paging(Paging::pages(5))
.column(Item::code)
.column(Item::kind)
.filter()
.column(Item::active)
.bulk_action("Deactivate", deactivate, Item::id),
)
}
fn pick_router(identity: Identity) -> (Router, Store) {
let store = Store::seeded();
let router = App::new("Test")
.service(store.clone())
.auth(move |h: &HeaderMap| {
h.contains_key("x-user")
.then(|| identity.clone())
})
.feature(Feature::new("items").screen(pick))
.router()
.unwrap();
(router, store)
}
fn bulk(body: serde_json::Value) -> Request<Body> {
let mut call = serde_json::json!({ "screen": "pick", "node": 0, "action": 0 });
call.as_object_mut()
.unwrap()
.extend(body.as_object().unwrap().clone());
post("/_zeyn/grid/bulk", call)
}
fn active_ids(store: &Store) -> Vec<i64> {
store
.0
.read()
.unwrap()
.iter()
.filter(|r| r.active)
.map(|r| r.id.0)
.collect()
}
fn writer_items() -> Identity {
Identity::user("w").with_permissions(["items.read", "items.write"])
}
#[tokio::test]
async fn selectable_grid_renders_checkboxes_and_a_bar_with_permitted_actions_only() {
let (router, _) = pick_router(writer_items());
let r = send(&router, get("/pick")).await;
assert!(r.body.contains("data-zeyn-select-all"));
assert!(
r.body
.contains(r#"<td class="z-row-select"><input type="checkbox" data-zeyn-select"#)
);
assert!(
r.body
.contains(r#"data-zeyn-bulk-run="0""#)
);
assert!(r.body.contains(r#"aria-colcount="4""#));
let (router, _) = pick_router(reader());
let r = send(&router, get("/pick")).await;
assert!(r.body.contains("data-zeyn-bulk "));
assert!(!r.body.contains("data-zeyn-bulk-run="));
}
#[tokio::test]
async fn bulk_action_runs_with_picked_keys() {
let (router, store) = pick_router(writer_items());
let r = send(
&router,
bulk(serde_json::json!({ "keys": ["2", "4", "4"] })),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.body);
assert_eq!(r.json()["count"], 2);
assert!(!active_ids(&store).contains(&2) && !active_ids(&store).contains(&4));
assert_eq!(active_ids(&store).len(), 8);
}
#[tokio::test]
async fn bulk_action_on_every_matching_row_collects_keys_on_the_server() {
let (router, store) = pick_router(writer_items());
let q = serde_json::json!({ "all": true, "keys": ["1"], "query": { "filters": [{ "field": "kind", "any_of": ["Tool"] }, { "field": "note", "contains": "zzz" }] } });
let r = send(&router, bulk(q)).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.body);
assert_eq!(
r.json()["count"],
6,
"every tool, the note filter is not offered"
);
assert_eq!(active_ids(&store), [2, 4, 8, 10, 14, 16, 20]);
}
#[tokio::test]
async fn bulk_action_is_refused_without_permission_bad_keys_unknown_actions_or_too_many() {
let (router, store) = pick_router(reader());
assert_eq!(
send(&router, bulk(serde_json::json!({ "keys": ["2"] })))
.await
.status,
StatusCode::FORBIDDEN
);
assert_eq!(active_ids(&store).len(), 10);
let (router, store) = pick_router(writer_items());
assert_eq!(
send(&router, bulk(serde_json::json!({ "keys": ["x"] })))
.await
.status,
StatusCode::NOT_FOUND
);
assert_eq!(
send(
&router,
bulk(serde_json::json!({ "action": 3, "keys": ["2"] }))
)
.await
.status,
StatusCode::NOT_FOUND
);
let many: Vec<String> = (0..=zeyn::MAX_BULK)
.map(|i| i.to_string())
.collect();
assert_eq!(
send(&router, bulk(serde_json::json!({ "keys": many })))
.await
.status,
StatusCode::FORBIDDEN
);
assert_eq!(active_ids(&store).len(), 10);
}
#[action(permission = "items.admin")]
pub async fn wipe(id: ItemId, store: Store) -> Result<()> {
store
.0
.write()
.unwrap()
.retain(|r| r.id != id);
Ok(())
}
#[action(permission = "items.write")]
pub async fn bump_all(_: (), store: Store) -> Result<()> {
for r in store.0.write().unwrap().iter_mut() {
r.qty += 1;
}
Ok(())
}
#[screen(route = "/menus", permission = "items.read")]
pub fn menus() -> Screen {
Screen::new("Menus")
.context_menu(
ContextMenu::new()
.item(
"Open",
Effect::Toast {
message: "hi".into(),
},
)
.action("Bump all", bump_all, ()),
)
.body(
DataGrid::of(search)
.paging(Paging::pages(5))
.column(Item::code)
.column(Item::qty)
.row_menu(|i| {
ContextMenu::new()
.item(
"Toast",
Effect::Toast {
message: "x".into(),
},
)
.separator()
.action("Bump", bump, i.id)
.disabled(i.qty > 3)
.submenu("Danger", ContextMenu::new().action("Wipe", wipe, i.id))
.separator()
}),
)
}
fn menu_router(identity: Identity) -> (Router, Store) {
let store = Store::seeded();
let router = App::new("Test")
.service(store.clone())
.auth(move |h: &HeaderMap| {
h.contains_key("x-user")
.then(|| identity.clone())
})
.feature(Feature::new("items").screen(menus))
.router()
.unwrap();
(router, store)
}
fn menu_run(key: &str, item: &str) -> Request<Body> {
post(
"/_zeyn/grid/run",
serde_json::json!({ "screen": "menus", "node": 0, "key": key, "item": item }),
)
}
#[tokio::test]
async fn row_menus_leave_out_items_the_user_may_not_run() {
let (router, _) = menu_router(writer_items());
let r = send(&router, get("/menus")).await;
let first = r
.body
.split(r#"<tr data-zeyn-key="1" data-zeyn-menu=""#)
.nth(1)
.unwrap()
.split('"')
.next()
.unwrap()
.replace(""", "\"");
let items: serde_json::Value = serde_json::from_str(&first).unwrap();
assert_eq!(
items.as_array().unwrap().len(),
3,
"toast, separator, bump; no empty submenu, no dangling separator: {items}"
);
assert_eq!(items[2]["run"], "2");
let r = send(&router, get("/menus")).await;
assert!(
r.body
.contains(r#"data-zeyn-screen="menus" data-zeyn-params="null" data-zeyn-menu=""#)
);
let admin = Identity::user("a").with_permissions(["items.read", "items.write", "items.admin"]);
let (router, _) = menu_router(admin);
let r = send(&router, get("/menus")).await;
assert!(r.body.contains("Wipe"));
}
#[tokio::test]
async fn row_menu_actions_run_from_the_row_on_the_server_and_check_permission_again() {
let (router, store) = menu_router(writer_items());
let r = send(&router, menu_run("2", "m2")).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.body);
assert_eq!(store.0.read().unwrap()[1].qty, 102);
assert_eq!(
send(&router, menu_run("2", "m2"))
.await
.status,
StatusCode::NOT_FOUND,
"disabled now that qty > 3"
);
assert_eq!(
send(&router, menu_run("1", "m3.0"))
.await
.status,
StatusCode::FORBIDDEN,
"admin only"
);
assert_eq!(store.0.read().unwrap().len(), 20);
assert_eq!(
send(&router, menu_run("1", "m0"))
.await
.status,
StatusCode::NOT_FOUND,
"effects run in the browser"
);
assert_eq!(
send(&router, menu_run("1", "m9"))
.await
.status,
StatusCode::NOT_FOUND
);
assert_eq!(
send(&router, menu_run("1", "m3"))
.await
.status,
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn screen_menu_actions_run_with_permission_checked() {
let (router, store) = menu_router(writer_items());
let call = |item: &str| {
post(
"/_zeyn/menu",
serde_json::json!({ "screen": "menus", "item": item }),
)
};
assert_eq!(send(&router, call("1")).await.status, StatusCode::OK);
assert_eq!(store.0.read().unwrap()[0].qty, 2);
assert_eq!(send(&router, call("0")).await.status, StatusCode::NOT_FOUND);
assert_eq!(send(&router, call("7")).await.status, StatusCode::NOT_FOUND);
let (router, store) = menu_router(reader());
assert_eq!(send(&router, call("1")).await.status, StatusCode::FORBIDDEN);
assert_eq!(store.0.read().unwrap()[0].qty, 1);
}
#[tokio::test]
async fn browser_menu_stays_unless_the_app_turns_it_off() {
let (router, _) = menu_router(reader());
assert!(
!send(&router, get("/menus"))
.await
.body
.contains("data-zeyn-native-menu")
);
let router = App::new("Test")
.service(Store::seeded())
.browser_context_menu(false)
.auth(|h: &HeaderMap| h.contains_key("x-user").then(reader))
.feature(Feature::new("items").screen(menus))
.router()
.unwrap();
assert!(
send(&router, get("/menus"))
.await
.body
.contains(r#"data-zeyn-native-menu="off""#)
);
}
#[action(permission = "items.write")]
pub async fn save_item(item: Valid<Item>, store: Store) -> Result<Item> {
let mut rows = store.0.write().unwrap();
let current = rows
.iter_mut()
.find(|r| r.id == item.id)
.ok_or(Error::NotFound)?;
if current.version != item.version {
return Err(Error::Conflict);
}
let mut saved = item.into_inner();
saved.version += 1;
*current = saved.clone();
Ok(saved)
}
#[screen(route = "/edit", permission = "items.read")]
pub fn edit_list() -> Screen {
Screen::new("Edit").body(
DataGrid::of(search)
.paging(Paging::pages(5))
.column(Item::code)
.editable()
.column(Item::price)
.editable()
.column(Item::qty)
.column(Item::note)
.editable()
.column(Item::active)
.editable()
.edit_with(save_item),
)
}
fn edit_router(identity: Identity) -> (Router, Store) {
let store = Store::seeded();
let router = App::new("Test")
.service(store.clone())
.i18n(
I18n::new("en")
.catalog("tr", "{}")
.unwrap(),
)
.auth(move |h: &HeaderMap| {
h.contains_key("x-user")
.then(|| identity.clone())
})
.feature(Feature::new("items").screen(edit_list))
.router()
.unwrap();
(router, store)
}
fn edit(key: &str, values: serde_json::Value) -> Request<Body> {
post(
"/_zeyn/grid/edit",
serde_json::json!({ "screen": "edit_list", "node": 0, "key": key, "values": values }),
)
}
#[tokio::test]
async fn editable_cells_carry_input_values_only_for_users_who_may_save() {
let (router, _) = edit_router(writer_items());
let r = send(&router, get("/edit")).await;
assert!(
r.body
.contains(r#"data-field="code" aria-sort="none" aria-colindex="1" data-edit="text""#),
"{}",
r.body
);
assert!(
r.body
.contains(r#"data-zeyn-version-field="version""#)
);
assert!(r.body.contains(
r#"<tr data-zeyn-key="1" data-zeyn-version="1"><td data-v="C-01">C-01</td><td class="z-num" data-v="10.00">"#
));
assert!(
!r.body
.contains(r#"data-field="note" aria-sort="none" aria-colindex="4" data-edit"#),
"read only"
);
let (router, _) = edit_router(reader());
let r = send(&router, get("/edit")).await;
assert!(!r.body.contains("data-edit=") && !r.body.contains("data-v="));
}
#[tokio::test]
async fn inline_edit_saves_through_valid_and_returns_the_row() {
let (router, store) = edit_router(writer_items());
let r = send(
&router,
edit("3", serde_json::json!({ "code": "NEW-3", "price": "12.50", "active": true, "version": "1" })),
)
.await;
assert_eq!(r.status, StatusCode::OK, "{}", r.body);
assert!(
r.json()["row"]
.as_str()
.unwrap()
.contains(r#"data-zeyn-version="2"><td data-v="NEW-3">NEW-3</td>"#)
);
let saved = store.0.read().unwrap()[2].clone();
assert_eq!(
(
saved.code.as_str(),
saved.price,
saved.active,
saved.qty,
saved.version
),
("NEW-3", Money::tl(1250), true, 3, 2)
);
}
#[tokio::test]
async fn inline_edit_reports_validation_and_conflicts() {
let (router, store) = edit_router(writer_items());
let r = send(
&router,
edit(
"3",
serde_json::json!({ "code": "", "price": "abc", "version": "1" }),
),
)
.await;
assert_eq!(r.status, StatusCode::UNPROCESSABLE_ENTITY);
let fields: Vec<String> = r.json()["errors"]
.as_array()
.unwrap()
.iter()
.map(|e| e["field"].as_str().unwrap().to_string())
.collect();
assert_eq!(fields, ["price", "code"]);
let r = send(
&router,
edit("3", serde_json::json!({ "code": "X", "version": "7" })),
)
.await;
assert_eq!(r.status, StatusCode::CONFLICT);
assert_eq!(store.0.read().unwrap()[2].code, "C-03");
}
#[tokio::test]
async fn inline_edit_refuses_read_only_and_unknown_columns_and_missing_permission() {
let (router, store) = edit_router(writer_items());
for values in [
serde_json::json!({ "note": "hacked", "version": "1" }),
serde_json::json!({ "qty": "999", "version": "1" }),
serde_json::json!({ "id": "77", "code": "X", "version": "1" }),
serde_json::json!({ "secret": "1" }),
serde_json::json!({}),
] {
assert_eq!(
send(&router, edit("3", values))
.await
.status,
StatusCode::FORBIDDEN
);
}
assert_eq!(
send(&router, edit("999", serde_json::json!({ "code": "X" })))
.await
.status,
StatusCode::NOT_FOUND
);
let (router, _) = edit_router(reader());
assert_eq!(
send(
&router,
edit("3", serde_json::json!({ "code": "X", "version": "1" }))
)
.await
.status,
StatusCode::FORBIDDEN
);
let row = store.0.read().unwrap()[2].clone();
assert_eq!(
(row.note.as_str(), row.qty, row.code.as_str()),
("note 3", 3, "C-03")
);
}
#[screen(route = "/export", permission = "items.read")]
pub fn export_list() -> Screen {
Screen::new("Export")
.body(
DataGrid::of(search)
.paging(Paging::pages(5))
.column(Item::code)
.filter()
.column(Item::kind)
.column(Item::price)
.column(Item::active)
.exportable(),
)
.body(
DataGrid::of(search)
.column(Item::code)
.done()
.export_permission("items.export")
.export_limit(7),
)
.body(DataGrid::of(search).column(Item::code))
}
fn export_router(identity: Identity) -> (Router, Store) {
let store = Store::seeded();
store.0.write().unwrap()[0].code = "=HYPERLINK(\"x\")".into();
store.0.write().unwrap()[1].code = "x;=cmd|' /C calc'!A0\t+1;-2".into();
let router = App::new("Test")
.service(store.clone())
.i18n(
I18n::new("en")
.catalog(
"tr",
r#"{ "Item": { "code": "Kod" }, "Kind": { "Tool": "Alet" } }"#,
)
.unwrap(),
)
.auth(move |h: &HeaderMap| {
h.contains_key("x-user")
.then(|| identity.clone())
})
.feature(Feature::new("items").screen(export_list))
.router()
.unwrap();
(router, store)
}
fn export_get(node: usize, q: serde_json::Value, locale: &str) -> Request<Body> {
let q = q.to_string();
let enc: String = q
.bytes()
.map(|b| {
if b.is_ascii_alphanumeric() {
(b as char).to_string()
} else {
format!("%{b:02X}")
}
})
.collect();
Request::get(format!(
"/_zeyn/grid/export?screen=export_list&node={node}&q={enc}"
))
.header("x-user", "1")
.header(header::COOKIE, format!("zeyn_locale={locale}"))
.body(Body::empty())
.unwrap()
}
#[tokio::test]
async fn export_streams_every_matching_row_in_the_users_layout_and_language() {
let (router, _) = export_router(reader());
let q = serde_json::json!({ "filters": [{ "field": "code", "starts_with": "C-1" }], "sort": [{ "field": "price", "desc": true }], "columns": [{ "field": "price" }, { "field": "code" }, { "field": "kind" }, { "field": "nope" }] });
let r = send(&router, export_get(0, q, "tr")).await;
assert_eq!(r.status, StatusCode::OK);
assert!(
r.headers
.get(header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap()
.starts_with("text/csv")
);
assert!(
r.headers
.get(header::CONTENT_DISPOSITION)
.unwrap()
.to_str()
.unwrap()
.contains("export_list.csv")
);
let lines: Vec<&str> = r
.body
.trim_start_matches('\u{feff}')
.lines()
.collect();
assert_eq!(lines[0], "Price,Kod,Kind");
assert_eq!(
lines.len(),
11,
"every C-1x row, not only the first page of 5"
);
assert_eq!(lines[1], "\"190,00 ₺\",C-19,Part");
assert!(
lines
.iter()
.any(|l| l.ends_with(",Alet"))
);
}
#[tokio::test]
async fn export_defuses_formulas_and_respects_the_limit_and_permissions() {
let (router, _) = export_router(reader());
let r = send(&router, export_get(0, serde_json::json!({}), "en")).await;
assert!(
r.body
.contains("\"'=HYPERLINK(\"\"x\"\")\""),
"{}",
&r.body[..200]
);
assert!(
r.body
.contains("\"x;'=cmd|' /C calc'!A0\t'+1;-2\""),
"every part is defused: {}",
&r.body[..300]
);
assert_eq!(
send(&router, export_get(1, serde_json::json!({}), "en"))
.await
.status,
StatusCode::FORBIDDEN
);
assert_eq!(
send(&router, export_get(2, serde_json::json!({}), "en"))
.await
.status,
StatusCode::NOT_FOUND
);
let (router, _) =
export_router(Identity::user("x").with_permissions(["items.read", "items.export"]));
let r = send(&router, export_get(1, serde_json::json!({}), "en")).await;
assert_eq!(r.status, StatusCode::OK);
assert_eq!(r.body.lines().count(), 8, "header and the limit of 7 rows");
let (router, _) = export_router(Identity::user("x"));
assert_eq!(
send(&router, export_get(0, serde_json::json!({}), "en"))
.await
.status,
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn exportable_grids_tell_the_runtime() {
let (router, _) = export_router(reader());
let r = send(&router, get("/export")).await;
assert_eq!(
r.body
.matches(r#"data-zeyn-exportable="csv"#)
.count(),
1,
"the second needs items.export"
);
}
/// Which item a detail screen shows.
#[params]
pub struct ItemParams {
pub id: ItemId,
}
#[screen(route = "/master", permission = "items.read")]
pub fn master() -> Screen {
Screen::new("Master")
.body(
DataGrid::of(search)
.paging(Paging::pages(3))
.column(Item::code)
.detail(|i| (item_detail, ItemParams { id: i.id })),
)
.body(
DataGrid::of(search)
.paging(Paging::virtual_scroll())
.column(Item::code)
.detail(|i| (item_detail, ItemParams { id: i.id })),
)
}
#[screen(permission = "items.detail")]
pub fn item_detail(params: ItemParams) -> Screen {
Screen::window("Detail").body(
DataGrid::of(search)
.scope(Item::id, params.id)
.column(Item::code)
.column(Item::qty),
)
}
fn master_router(identity: Identity) -> Router {
App::new("Test")
.service(Store::seeded())
.auth(move |h: &HeaderMap| {
h.contains_key("x-user")
.then(|| identity.clone())
})
.feature(
Feature::new("items")
.screen(master)
.screen(item_detail),
)
.router()
.unwrap()
}
#[tokio::test]
async fn rows_expand_into_a_typed_detail_screen_only_for_users_who_may_open_it() {
let router =
master_router(Identity::user("d").with_permissions(["items.read", "items.detail"]));
let r = send(&router, get("/master")).await;
let paged = &r.body[..r
.body
.find(r#"data-zeyn-node="1""#)
.unwrap()];
assert!(
paged.contains(r#"<th scope="col" class="z-expand-col" data-pin="start">"#),
"{paged}"
);
assert!(paged.contains(r#"data-field="code" aria-sort="none" aria-colindex="2""#));
assert!(paged.contains(r#"aria-expanded="false" data-zeyn-detail="{"params":{"id":1},"screen":"item_detail"}""#), "{paged}");
assert_eq!(
paged
.matches("data-zeyn-expand")
.count(),
3
);
let virtual_grid = &r.body[r
.body
.find(r#"data-zeyn-node="1""#)
.unwrap()..];
assert!(
!virtual_grid.contains("data-zeyn-detail") && !virtual_grid.contains("z-expand"),
"virtual grids have no details"
);
let router = master_router(reader());
let r = send(&router, get("/master")).await;
assert!(!r.body.contains("data-zeyn-detail") && !r.body.contains("z-expand-col"));
}
#[tokio::test]
async fn detail_screens_check_their_permission_and_keep_their_scope() {
let router =
master_router(Identity::user("d").with_permissions(["items.read", "items.detail"]));
let r = send(
&router,
get("/_zeyn/screen/item_detail?params=%7B%22id%22%3A4%7D"),
)
.await;
assert_eq!(r.status, StatusCode::OK);
assert!(
r.body.contains("C-04") && !r.body.contains("C-05"),
"{}",
r.body
);
let mut filters: Vec<serde_json::Value> = (0..40)
.map(|_| serde_json::json!({ "field": "code", "contains": "C" }))
.collect();
filters.push(serde_json::json!({ "field": "id", "from": "1", "to": "20" }));
let call = serde_json::json!({ "screen": "item_detail", "params": { "id": 4 }, "node": 0, "query": { "page": { "type": "offset", "offset": 0, "limit": 50 }, "filters": filters } });
let r = send(&router, post("/_zeyn/grid", call)).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.body);
assert_eq!(
r.json()["count"],
1,
"the scope stays whatever the client sends"
);
let router = master_router(reader());
let r = send(
&router,
get("/_zeyn/screen/item_detail?params=%7B%22id%22%3A4%7D"),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
}
#[test]
fn blank_filters_keep_empty_or_set_values() {
let rows: Vec<Item> = (1..=20).map(item).collect();
let empty = DataQuery {
filters: vec![zeyn::Filter::blank("due", true)],
..DataQuery::default()
}
.apply(rows.clone());
let set = DataQuery {
filters: vec![zeyn::Filter::blank("due", false)],
..DataQuery::default()
}
.apply(rows);
assert_eq!((empty.total, set.total), (Some(15), Some(5)));
}
#[test]
fn apply_groups_rows_with_counts_totals_sort_and_paging() {
let rows: Vec<Item> = (1..=20).map(item).collect();
let mut q = query(
serde_json::json!({ "page": { "type": "offset", "offset": 0, "limit": 2 }, "sort": [{ "field": "kind", "desc": true }], "aggregates": [{ "field": "qty", "op": "sum" }] }),
);
q.group = Some("kind".into());
let page = q.apply(rows.clone());
assert!(page.items.is_empty());
assert_eq!(page.total, Some(3));
let seen: Vec<(Value, u64)> = page
.groups
.iter()
.map(|g| (g.value.clone(), g.count))
.collect();
assert_eq!(
seen,
[(Value::Choice("Tool"), 6), (Value::Choice("Part"), 7)]
);
assert_eq!(
page.groups[0].totals[0].value,
Value::Int(3 + 6 + 9 + 12 + 15 + 18)
);
assert!(page.next.is_some());
let q = DataQuery {
group: Some("due".into()),
..DataQuery::default()
};
let page = q.apply(rows);
assert_eq!(
(page.groups[0].value.clone(), page.groups[0].count),
(Value::Null, 15),
"empty values form one group, first"
);
}
#[test]
fn any_of_matches_keys() {
let rows: Vec<Item> = (1..=20).map(item).collect();
let page = DataQuery {
filters: vec![zeyn::Filter::any_of("id", ["3", "7", "99"])],
..DataQuery::default()
}
.apply(rows);
assert_eq!(codes(&page), ["C-03", "C-07"]);
}
#[screen(route = "/grouped", permission = "items.read")]
pub fn grouped() -> Screen {
Screen::new("Grouped").body(
DataGrid::of(search)
.paging(Paging::pages(2))
.group_by(Item::kind)
.group_by(Item::active)
.column(Item::code)
.column(Item::qty)
.sum(),
)
}
/// A node of a tree.
#[entity(table = "nodes")]
pub struct Node {
#[key]
pub id: NodeId,
pub name: String,
pub parent: Option<NodeId>,
}
#[action(permission = "items.read")]
pub async fn search_nodes(q: DataQuery) -> Result<Page<Node>> {
let parent = |i: i64| match i {
1 | 2 => None,
3..=5 => Some(NodeId(1)),
_ => Some(NodeId(3)),
};
Ok(q.apply(
(1..=8)
.map(|i| Node {
id: NodeId(i),
name: format!("N{i}"),
parent: parent(i),
})
.collect(),
))
}
#[screen(route = "/tree", permission = "items.read")]
pub fn tree() -> Screen {
Screen::new("Tree").body(
DataGrid::of(search_nodes)
.paging(Paging::pages(2))
.tree(Node::parent)
.column(Node::name),
)
}
fn nested_router(identity: Identity) -> Router {
App::new("Test")
.service(Store::seeded())
.auth(move |h: &HeaderMap| {
h.contains_key("x-user")
.then(|| identity.clone())
})
.feature(
Feature::new("items")
.screen(grouped)
.screen(tree),
)
.router()
.unwrap()
}
fn branch(screen: &str, path: serde_json::Value) -> Request<Body> {
post(
"/_zeyn/grid",
serde_json::json!({ "screen": screen, "node": 0, "path": path, "query": { "page": { "type": "offset", "offset": 0, "limit": 2 }, "with_total": true } }),
)
}
#[tokio::test]
async fn grouped_grids_page_over_groups_and_open_them_level_by_level() {
let router = nested_router(reader());
let r = send(&router, get("/grouped")).await;
assert!(
r.body
.contains(r#"<table role="treegrid""#)
);
assert!(r.body.contains(r#"<tr class="z-group" aria-level="1" data-zeyn-level="0" aria-expanded="false" data-zeyn-path="["Food"]"><td colspan="1">"#), "{}", r.body);
assert!(
r.body.contains("7 rows")
&& r.body
.contains(r#"data-zeyn-total="3""#)
);
let r = send(&router, branch("grouped", serde_json::json!(["Tool"]))).await;
assert_eq!(r.status, StatusCode::OK, "{}", r.body);
let rows = r.json()["rows"]
.as_str()
.unwrap()
.to_string();
assert!(
rows.contains(r#"data-zeyn-path="["Tool","false"]""#)
&& rows.contains(r#"aria-level="2""#),
"{rows}"
);
assert_eq!(r.json()["total"], 2);
let r = send(
&router,
branch("grouped", serde_json::json!(["Tool", "true"])),
)
.await;
let rows = r.json()["rows"]
.as_str()
.unwrap()
.to_string();
assert!(
rows.contains(r#"<tr aria-level="3" data-zeyn-level="2" data-zeyn-key="6">"#),
"{rows}"
);
assert_eq!(r.json()["total"], 3, "tools 6, 12, 18 are active");
assert_eq!(
send(
&router,
branch("grouped", serde_json::json!(["Tool", "true", "x"]))
)
.await
.status,
StatusCode::NOT_FOUND
);
let long: Vec<&str> = vec!["a"; 33];
assert_eq!(
send(&router, branch("grouped", serde_json::json!(long)))
.await
.status,
StatusCode::NOT_FOUND
);
let router = nested_router(Identity::user("n"));
assert_eq!(
send(&router, branch("grouped", serde_json::json!(["Tool"])))
.await
.status,
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn tree_grids_show_top_rows_and_load_children_lazily() {
let router = nested_router(reader());
let r = send(&router, get("/tree")).await;
assert!(r.body.contains(r#"<tr aria-level="1" data-zeyn-level="0" aria-expanded="false" data-zeyn-path="["1"]" data-zeyn-key="1">"#), "{}", r.body);
assert!(r.body.contains(r#"<tr aria-level="1" data-zeyn-level="0" data-zeyn-key="2"><td class="z-tree-cell"><span class="z-tree" style="--z-level:0"><span class="z-tree-spacer""#), "N2 has no children");
let r = send(&router, branch("tree", serde_json::json!(["1"]))).await;
let rows = r.json()["rows"]
.as_str()
.unwrap()
.to_string();
assert_eq!(r.json()["total"], 3);
assert!(
rows.contains(r#"data-zeyn-path="["1","3"]" data-zeyn-key="3""#)
&& !rows.contains("N5"),
"{rows}"
);
let r = send(&router, branch("tree", serde_json::json!(["1", "3"]))).await;
assert_eq!(r.json()["total"], 3, "6, 7 and 8 report to 3");
assert_eq!(
send(&router, branch("tree", serde_json::json!([null])))
.await
.status,
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn pasted_rows_are_saved_one_by_one_and_each_is_checked_on_its_own() {
let (router, store) = edit_router(writer_items());
let pastes = [
(
"1",
serde_json::json!({ "code": "P-1", "price": "1.234,50", "version": "1" }),
StatusCode::OK,
),
(
"2",
serde_json::json!({ "code": "", "version": "1" }),
StatusCode::UNPROCESSABLE_ENTITY,
),
(
"3",
serde_json::json!({ "code": "P-3", "qty": "99", "version": "1" }),
StatusCode::FORBIDDEN,
),
(
"4",
serde_json::json!({ "code": "P-4", "version": "0" }),
StatusCode::CONFLICT,
),
];
for (key, values, status) in pastes {
let r = send(
&router,
post_as(
"/_zeyn/grid/edit",
serde_json::json!({ "screen": "edit_list", "node": 0, "key": key, "values": values }),
"tr",
),
)
.await;
assert_eq!(r.status, status, "row {key}: {}", r.body);
}
let rows = store.0.read().unwrap().clone();
assert_eq!(
(rows[0].code.as_str(), rows[0].price),
("P-1", Money::tl(123_450)),
"Turkish money as a spreadsheet copies it"
);
assert_eq!(
(
rows[1].code.as_str(),
rows[2].code.as_str(),
rows[2].qty,
rows[3].code.as_str()
),
("C-02", "C-03", 3, "C-04")
);
}
#[tokio::test]
async fn grid_writes_count_against_the_write_limit() {
let store = Store::seeded();
let identity = writer_items();
let router = App::new("Test")
.service(store)
.write_limit(zeyn::RateLimit::per_user(
2,
std::time::Duration::from_secs(60),
))
.auth(move |h: &HeaderMap| {
h.contains_key("x-user")
.then(|| identity.clone())
})
.feature(Feature::new("items").screen(edit_list))
.router()
.unwrap();
let mut statuses = Vec::new();
for i in 1..=3 {
statuses.push(
send(
&router,
edit(
&i.to_string(),
serde_json::json!({ "code": format!("W-{i}"), "version": "1" }),
),
)
.await
.status,
);
}
assert_eq!(
statuses,
[
StatusCode::OK,
StatusCode::OK,
StatusCode::TOO_MANY_REQUESTS
]
);
let r = send(
&router,
post(
"/_zeyn/grid",
serde_json::json!({ "screen": "edit_list", "node": 0 }),
),
)
.await;
assert_eq!(r.status, StatusCode::OK, "reading rows is not a write");
}
/// Refuses every call in `before` and counts the calls `after` sees.
#[derive(Clone, Default)]
struct RefuseAll(Arc<RwLock<Vec<bool>>>);
impl zeyn::Interceptor for RefuseAll {
fn before(&self, _ctx: &Ctx, _call: &zeyn::server::Call) -> Result<()> {
Err(Error::Forbidden)
}
fn after(&self, _ctx: &Ctx, _call: &zeyn::server::Call, outcome: &zeyn::server::CallOutcome) {
self.0.write().unwrap().push(outcome.ok);
}
}
#[tokio::test]
async fn an_export_refused_by_an_interceptor_still_reaches_its_after() {
let refuse = RefuseAll::default();
let identity = reader();
let router = App::new("Test")
.service(Store::seeded())
.auth(move |h: &HeaderMap| {
h.contains_key("x-user")
.then(|| identity.clone())
})
.interceptor(refuse.clone())
.feature(Feature::new("items").screen(export_list))
.router()
.unwrap();
let r = send(&router, export_get(0, serde_json::json!({}), "en")).await;
assert_eq!(r.status, StatusCode::FORBIDDEN);
assert_eq!(
*refuse.0.read().unwrap(),
[false],
"audit interceptors record the refused export"
);
}
#[screen(route = "/long", permission = "items.read")]
pub fn long_list() -> Screen {
Screen::new("Long").body(
DataGrid::of(search)
.paging(Paging::virtual_scroll())
.column(Item::code),
)
}
#[tokio::test]
async fn virtual_grids_render_one_screen_of_rows_live_and_keep_the_rest_of_the_block_inert() {
let rows = (1..=150)
.map(|i| Item {
id: ItemId(i),
code: format!("R-{i:03}"),
..item(i % 28 + 1)
})
.collect();
let router = App::new("Test")
.service(Store(Arc::new(RwLock::new(rows))))
.auth(|h: &HeaderMap| h.contains_key("x-user").then(reader))
.feature(Feature::new("items").screen(long_list))
.router()
.unwrap();
let r = send(&router, get("/long")).await;
let (live, later) = r
.body
.split_once("<template data-zeyn-rows-later>")
.expect("rows after the first screen wait in a template");
let later = &later[..later.find("</template>").unwrap()];
assert_eq!(
live.matches("R-").count(),
zeyn::VIRTUAL_LIVE_ROWS,
"{live}"
);
assert!(live.contains("R-064") && !live.contains("R-065"));
assert_eq!(later.matches("R-").count(), 150 - zeyn::VIRTUAL_LIVE_ROWS);
assert!(later.starts_with("<tr") && later.contains("R-150"));
}
#[screen(route = "/unsorted", permission = "items.read")]
pub fn unsorted() -> Screen {
Screen::new("Unsorted").body(
DataGrid::of(search)
.column(Item::code)
.column(Item::qty)
.no_sort(),
)
}
#[tokio::test]
async fn columns_without_sorting_get_no_sort_button_and_the_server_drops_their_sort_keys() {
let router = App::new("Test")
.service(Store::seeded())
.auth(|h: &HeaderMap| h.contains_key("x-user").then(reader))
.feature(Feature::new("items").screen(unsorted))
.router()
.unwrap();
let page = send(&router, get("/unsorted"))
.await
.body;
assert!(
page.contains(r#"data-field="code" aria-sort="none""#),
"{page}"
);
assert!(
page.contains(r#"data-field="qty" data-sort="off""#),
"{page}"
);
let q = serde_json::json!({ "sort": [{ "field": "qty", "desc": true }] });
let call = serde_json::json!({ "screen": "unsorted", "node": 0, "query": q });
let rows = send(&router, post("/_zeyn/grid", call))
.await
.json()["rows"]
.as_str()
.unwrap()
.to_string();
assert!(
rows.find("C-01").unwrap() < rows.find("C-20").unwrap(),
"the action never saw the sort: {rows}"
);
}
/// Rows as `rows_html` rendered them before the row renderer wrote straight into its output:
/// every value kind, escaping, editable cells with `data-v`, keys, versions, an open effect and
/// a row action.
const ROWS_BEFORE: &str = r##"<tr tabindex="-1" data-zeyn-effect="{"effect":"toast","message":"open <a href=\"x\">&'</a>"}" data-zeyn-key="3" data-zeyn-version="1"><td class="z-num">3</td><td data-v="<a href="x">&'</a>"><a href="x">&'</a></td><td>Tool</td><td class="z-num" data-v="30.00">₺30.00</td><td class="z-num">3</td><td data-v="false">No</td><td><time datetime="2026-01-03">1/3/2026</time></td><td></td><td>note 3</td><td class="z-row-actions"><button type="button" class="z-row-action" data-zeyn-run="a0" tabindex="-1"><span>Bump</span></button></td></tr><tr tabindex="-1" data-zeyn-effect="{"effect":"toast","message":"open C-04"}" data-zeyn-key="4" data-zeyn-version="1"><td class="z-num">4</td><td data-v="C-04">C-04</td><td>Part</td><td class="z-num" data-v="40.00">₺40.00</td><td class="z-num">4</td><td data-v="true">Yes</td><td><time datetime="2026-01-04">1/4/2026</time></td><td><time datetime="2026-03-04">3/4/2026</time></td><td>ünïcode ✓ & "quoted"</td><td class="z-row-actions"><button type="button" class="z-row-action" data-zeyn-run="a0" tabindex="-1"><span>Bump</span></button></td></tr><tr tabindex="-1" data-zeyn-effect="{"effect":"toast","message":"open C-05"}" data-zeyn-key="5" data-zeyn-version="1"><td class="z-num">5</td><td data-v="C-05">C-05</td><td>Food</td><td class="z-num" data-v="-12,345.67">-₺12,345.67</td><td class="z-num">5</td><td data-v="false">No</td><td><time datetime="2026-01-05">1/5/2026</time></td><td></td><td>note 5</td><td class="z-row-actions"><button type="button" class="z-row-action" data-zeyn-run="a0" tabindex="-1"><span>Bump</span></button></td></tr>"##;
#[test]
fn row_html_stays_byte_for_byte_the_same() {
let grid = DataGrid::of(search)
.column(Item::id)
.column(Item::code)
.editable()
.column(Item::kind)
.column(Item::price)
.editable()
.column(Item::qty)
.column(Item::active)
.editable()
.column(Item::added)
.column(Item::due)
.column(Item::note)
.editable()
.on_open(|i| Effect::Toast {
message: Text::from(format!("open {}", i.code)),
})
.row_action("Bump", bump, |i| i.id)
.edit_with(save_item);
let mut rows: Vec<Item> = (3..=5).map(item).collect();
rows[0].code = r#"<a href="x">&'</a>"#.into();
rows[1].note = "ünïcode ✓ & \"quoted\"".into();
rows[2].price = Money::tl(-1_234_567);
assert_eq!(grid.rows_html(&rows), ROWS_BEFORE);
}
#[screen(route = "/wide", permission = "items.read")]
pub fn wide_list() -> Screen {
let mut grid = DataGrid::of(search)
.paging(Paging::virtual_scroll())
.column(Item::code)
.done();
for _ in 1..zeyn::VIRTUAL_WIDE_COLUMNS {
grid = grid.column(Item::qty).done();
}
Screen::new("Wide").body(grid)
}
#[tokio::test]
async fn wide_virtual_grids_keep_every_row_of_the_first_block_in_the_template() {
let rows = (1..=150)
.map(|i| Item {
id: ItemId(i),
code: format!("R-{i:03}"),
..item(i % 28 + 1)
})
.collect();
let router = App::new("Test")
.service(Store(Arc::new(RwLock::new(rows))))
.auth(|h: &HeaderMap| h.contains_key("x-user").then(reader))
.feature(
Feature::new("items")
.screen(wide_list)
.screen(long_list),
)
.router()
.unwrap();
let r = send(&router, get("/wide")).await;
let (live, later) = r
.body
.split_once("<template data-zeyn-rows-later>")
.expect("the block waits in a template");
let later = &later[..later.find("</template>").unwrap()];
let body = &live[live
.find("<tbody data-zeyn-rows>")
.unwrap()..];
assert!(
body.starts_with("<tbody data-zeyn-rows></tbody>"),
"no live rows: {}",
&body[..80]
);
assert_eq!(later.matches("R-").count(), 150);
assert!(later.starts_with("<tr") && later.contains("R-001") && later.contains("R-150"));
let narrow = send(&router, get("/long")).await.body;
assert!(
narrow.contains("R-001</td>") && !narrow.contains("<tbody data-zeyn-rows></tbody>"),
"narrow grids keep live rows"
);
}