use axum::body::Body;
use axum::http::{HeaderMap, Request, StatusCode, header};
use http_body_util::BodyExt;
use tower::ServiceExt;
use zeyn::prelude::*;
use zeyn::{Component, FieldKind, FieldType, Filter, I18n, Identity, Render, Rule};
#[choice]
pub enum Channel {
Email,
Phone,
#[label("Text message")]
Sms,
Post,
}
#[entity(table = "customers")]
pub struct Customer {
#[key]
pub id: CustomerId,
pub name: String,
}
#[model]
pub struct Contact {
#[rule(required)]
pub channels: Vec<Channel>,
pub also: Vec<Channel>,
pub customer: Option<CustomerId>,
}
#[action(permission = "customers.read")]
pub async fn find_customers(q: LookupQuery) -> Result<Page<LookupItem>> {
let all = [
("C-1", "Ayşe Yılmaz", "İzmir"),
("C-2", "Mert Kaya", "Ankara"),
("C-3", "İnci Gıda", "Bursa"),
];
Ok(q.apply(
all.iter()
.enumerate()
.map(|(i, (code, name, city))| {
LookupItem::new(i as i64 + 1, *name).cells([*code, *name, *city])
})
.collect(),
))
}
#[action(public)]
pub async fn save_contact(contact: Valid<Contact>) -> Result<Contact> {
Ok(contact.into_inner())
}
fn form(contact: Contact) -> zeyn::SubmitForm<Contact, save_contact> {
Form::of(contact)
.field(Contact::channels)
.field(Contact::also)
.lookup_columns(Contact::customer, find_customers, ["Code", "Name", "City"])
.submit("Save", save_contact)
}
#[test]
fn vec_of_a_choice_is_a_multi_select_over_a_native_multiple_select() {
assert!(matches!(<Vec<Channel> as FieldType>::KIND, FieldKind::Choices(o) if o.len() == 4));
let mut out = String::new();
form(Contact {
channels: vec![Channel::Email, Channel::Sms],
..Contact::default()
})
.render(&mut out, &Render::new("c", "tr", I18n::builtin()));
assert!(
out.contains(r#"<div class="z-multi" data-zeyn-multi data-texts="#),
"{out}"
);
assert!(out.contains(r#"<select multiple size="4" id="c-channels" name="channels""#));
assert!(out.contains(r#"<option value="Email" selected>Email</option>"#));
assert!(out.contains(r#"<option value="Sms" selected>Text message</option>"#));
assert!(out.contains(r#"<option value="Phone">Phone</option>"#));
assert!(
out.contains("{n} seçili"),
"texts come in the user's language"
);
}
#[test]
fn posted_choices_are_checked_one_by_one() {
let f = form(Contact::default());
let values = serde_json::json!({ "channels": "Sms,Email,Sms", "also": ["Post"] });
let c = f
.form()
.read(values.as_object().unwrap(), "en", TimeZone::UTC)
.unwrap();
assert_eq!(
c.channels,
vec![Channel::Sms, Channel::Email],
"repeats are dropped, order is kept"
);
assert_eq!(c.also, vec![Channel::Post]);
let bad = serde_json::json!({ "channels": "Email,Fax", "also": [1] });
let errors = f
.form()
.read(bad.as_object().unwrap(), "en", TimeZone::UTC)
.unwrap_err();
let got: Vec<(&str, Rule)> = errors
.errors
.iter()
.map(|e| (e.field, e.rule))
.collect();
assert_eq!(got, [("channels", Rule::Format), ("also", Rule::Format)]);
let empty = serde_json::json!({ "channels": "", "also": "" });
let errors = f
.form()
.read(empty.as_object().unwrap(), "en", TimeZone::UTC)
.unwrap_err();
assert_eq!(errors.errors[0].rule, Rule::Required);
}
#[test]
fn several_choices_show_their_labels_and_describe_themselves() {
let r = Render::english("c");
let v = <Vec<Channel> as FieldType>::to_value(&vec![Channel::Sms, Channel::Post]);
assert_eq!(v, zeyn::Value::Text("Sms,Post".into()));
assert_eq!(
r.value(&v, &<Vec<Channel> as FieldType>::KIND),
"Text message, Post"
);
let schema = zeyn::model_schema::<Contact>();
assert_eq!(schema["properties"]["channels"]["type"], "array");
assert_eq!(schema["properties"]["channels"]["items"]["enum"][2], "Sms");
let json = serde_json::to_value(Contact {
channels: vec![Channel::Phone],
..Contact::default()
})
.unwrap();
assert_eq!(json["channels"], serde_json::json!(["Phone"]));
let rows = vec![
Contact {
channels: vec![Channel::Phone],
..Contact::default()
},
Contact {
channels: vec![Channel::Email, Channel::Post],
..Contact::default()
},
];
let page = DataQuery {
filters: vec![Filter::contains("channels", "post")],
..DataQuery::default()
}
.apply(rows);
assert_eq!(
page.items.len(),
1,
"a contains filter looks into the chosen values"
);
}
#[test]
fn multi_column_lookups_send_headers_and_search_every_cell() {
let mut out = String::new();
form(Contact::default()).render(&mut out, &Render::new("c", "en", I18n::builtin()));
assert!(
out.contains(r#"data-zeyn-lookup="customer" data-multiple="false" data-columns="["Code","Name","City"]""#),
"{out}"
);
let q = LookupQuery {
text: "bursa".into(),
limit: 10,
..Default::default()
};
let items = vec![LookupItem::new(3, "İnci Gıda").cells(["C-3", "İnci Gıda", "Bursa"])];
assert_eq!(q.apply(items).items.len(), 1, "cells are searched too");
let many = LookupItem::new(1, "x").cells(["1", "2", "3", "4", "5", "6", "7", "8"]);
assert_eq!(many.cells.len(), zeyn::ui::MAX_LOOKUP_COLUMNS);
let plain = serde_json::to_value(LookupItem::new(1, "x")).unwrap();
assert!(plain.get("cells").is_none(), "plain lookups send no cells");
}
#[screen(route = "/contact", public)]
pub fn contacts() -> Screen {
Screen::new("Contact").body(Panel::new("Contact").add(form(Contact::default())))
}
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
async fn post(path: &str, perms: &str, body: serde_json::Value) -> (StatusCode, serde_json::Value) {
let app = App::new("Test")
.i18n(I18n::new("en"))
.auth(|h: &HeaderMap| {
let perms: Vec<String> = h
.get("x-perms")?
.to_str()
.ok()?
.split(',')
.map(String::from)
.collect();
Some(Identity::user("u").with_permissions(perms))
})
.feature(Feature::new("contacts").screen(contacts))
.router()
.unwrap();
let req = Request::post(path)
.header("x-perms", perms)
.header(header::CONTENT_TYPE, "application/json")
.header(header::COOKIE, format!("zeyn_csrf={TOKEN}"))
.header("x-zeyn-csrf", TOKEN)
.body(Body::from(body.to_string()))
.unwrap();
let res = app.oneshot(req).await.unwrap();
let status = res.status();
let bytes = res
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
(
status,
serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null),
)
}
#[tokio::test]
async fn the_column_lookup_searches_through_the_actions_permission() {
let call = serde_json::json!({ "screen": "contacts", "node": "0.0", "key": "customer", "input": { "text": "ank", "limit": 5 } });
let (status, body) = post("/_zeyn/data", "customers.read", call.clone()).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(
body["items"][0]["cells"],
serde_json::json!(["C-2", "Mert Kaya", "Ankara"])
);
let (status, _) = post("/_zeyn/data", "", call).await;
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn the_form_endpoint_takes_the_joined_values_the_runtime_posts() {
let ok = serde_json::json!({ "screen": "contacts", "node": "0.0", "values": { "channels": "Phone,Post", "also": "" } });
let (status, body) = post("/_zeyn/form", "", ok).await;
assert_eq!(status, StatusCode::OK, "{body}");
let bad = serde_json::json!({ "screen": "contacts", "node": "0.0", "values": { "channels": "Phone,<b>", "also": "" } });
let (status, body) = post("/_zeyn/form", "", bad).await;
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(body["errors"][0]["field"], "channels");
}