use crate::action::{Access, ActionMeta, Cursor, DataQuery, Result};
use crate::ctx::{BoxFuture, Ctx, Dep};
use crate::i18n::Text;
use crate::render::{Render, attr, slug};
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::json;
use std::any::Any;
use std::collections::HashMap;
#[diagnostic::on_unimplemented(
message = "`{Self}` is not a screen params type",
label = "the first argument of a screen is its params",
note = "mark the type with `#[params]`, or use `_: ()` as the first argument when the screen has no params"
)]
pub trait Params: Serialize + DeserializeOwned + Clone + Send + Sync + 'static {}
impl Params for () {}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct ScreenMeta {
pub name: &'static str,
pub route: Option<&'static str>,
pub access: Access,
pub sensitive: bool,
pub reason: bool,
pub doc: &'static str,
pub ai: crate::action::Ai,
pub cache: u32,
}
pub const MAX_SCREEN_CACHE: u32 = 86_400;
pub trait ScreenDef: Copy + Send + Sync + 'static {
type Params: Params;
const META: ScreenMeta;
fn deps() -> Vec<Dep>;
fn build(ctx: Ctx, params: Self::Params) -> BoxFuture<Result<Screen>>;
fn path_value(field: &str, raw: &str) -> Option<serde_json::Value> {
let _ = (field, raw);
None
}
}
#[diagnostic::on_unimplemented(
message = "`{Self}` cannot be a route path segment",
label = "this route names a params field of type `{Self}`",
note = "path segments take integers (`i64`, `u32`, ...), entity keys (`ProductId`), `String` or `zeyn::Slug`; take the field out of the route and it travels in the JSON `params` query instead"
)]
pub trait PathValue: Serialize + Sized {
fn from_segment(raw: &str) -> Option<Self>;
}
macro_rules! path_ints {
($($t:ty),*) => {$(
impl PathValue for $t {
fn from_segment(raw: &str) -> Option<Self> {
raw.parse::<$t>().ok().filter(|v| v.to_string() == raw)
}
}
)*};
}
path_ints!(i8, i16, i32, i64, u8, u16, u32, u64, usize);
impl PathValue for String {
fn from_segment(raw: &str) -> Option<Self> {
(!raw.is_empty() && raw != "." && raw != ".." && raw.len() <= MAX_SEGMENT)
.then(|| raw.to_string())
}
}
pub const MAX_SEGMENT: usize = 256;
#[doc(hidden)]
pub fn __path_value<P, T: PathValue>(
raw: &str,
field: impl Fn(&P) -> &T,
) -> Option<serde_json::Value> {
let _ = field;
T::from_segment(raw).and_then(|v| serde_json::to_value(v).ok())
}
pub(crate) fn route_segments(route: &str) -> impl Iterator<Item = &str> {
route.split('/').filter_map(|s| {
s.strip_prefix('{')
.and_then(|s| s.strip_suffix('}'))
})
}
pub const fn route_has_segments(route: Option<&str>) -> bool {
let Some(route) = route else { return false };
let bytes = route.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'{' {
return true;
}
i += 1;
}
false
}
pub fn screen_url(path: &str, params: &serde_json::Value) -> Option<String> {
if !route_has_segments(Some(path)) {
return Some(if params.is_null() {
path.to_string()
} else {
format!("{path}?params={}", encode(¶ms.to_string()))
});
}
let fields = params.as_object()?;
let mut out = String::with_capacity(path.len() + 16);
for (i, part) in path.split('/').enumerate() {
if i > 0 {
out.push('/');
}
match part
.strip_prefix('{')
.and_then(|p| p.strip_suffix('}'))
{
Some(name) => {
let value = match fields.get(name)? {
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => String::from_segment(s)?,
_ => return None,
};
out.push_str(&encode(&value));
}
None => out.push_str(part),
}
}
let segments: Vec<&str> = route_segments(path).collect();
let rest: serde_json::Map<String, serde_json::Value> = fields
.iter()
.filter(|(k, _)| !segments.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
if !rest.is_empty() {
out.push_str("?params=");
out.push_str(&encode(&serde_json::Value::Object(rest).to_string()));
}
Some(out)
}
#[derive(Clone, Default)]
pub(crate) struct Routes(
pub(crate) std::sync::Arc<HashMap<&'static str, String>>,
pub(crate) Option<std::sync::Arc<str>>,
);
impl crate::ctx::Service for Routes {}
impl Routes {
pub(crate) fn href(r: &Render, screen: &str, params: &serde_json::Value) -> Option<String> {
let ctx = r.ctx?;
let routes = ctx.service::<Routes>()?;
let url = screen_url(routes.0.get(screen)?, params)?;
Some(match routes.1.as_deref() {
Some(default) if ctx.locale() != default && url == "/" => format!("/{}/", ctx.locale()),
Some(default) if ctx.locale() != default => format!("/{}{url}", ctx.locale()),
_ => url,
})
}
}
fn encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'~' => out.push(b as char),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(tag = "effect", rename_all = "snake_case")]
pub enum Effect {
OpenWindow {
screen: &'static str,
params: serde_json::Value,
},
Navigate {
screen: &'static str,
params: serde_json::Value,
},
Toast {
message: Text,
},
CloseAndRefresh,
Confirm {
message: Text,
then: Box<Effect>,
},
Notice {
message: Text,
tone: crate::grid::Tone,
action: Option<(Text, Box<Effect>)>,
},
Go {
url: String,
frame: bool,
},
}
impl Effect {
pub fn go(url: &str, frame: bool) -> Effect {
let local = url.starts_with('/')
&& !url.starts_with("//")
&& !url.contains('\\')
&& !url.chars().any(char::is_control)
&& url.len() <= 2048;
Effect::Go {
url: if local { url.to_string() } else { "/".into() },
frame,
}
}
pub fn confirm(message: impl Into<Text>, then: Effect) -> Effect {
Effect::Confirm {
message: message.into(),
then: Box::new(then),
}
}
pub fn navigate<S: ScreenDef>(_screen: S, params: S::Params) -> Effect {
Effect::Navigate {
screen: S::META.name,
params: params_json(¶ms),
}
}
pub fn to_json(&self, r: &Render) -> serde_json::Value {
match self {
Effect::Toast { message } => {
json!({ "effect": "toast", "message": r.i18n.text(r.locale, message) })
}
Effect::Notice {
message,
tone,
action,
} => {
let mut j = json!({ "effect": "toast", "message": r.i18n.text(r.locale, message), "tone": tone });
if let Some((label, then)) = action {
j["action"] =
json!({ "label": r.i18n.text(r.locale, label), "effect": then.to_json(r) });
}
j
}
Effect::Confirm { message, then } => json!({
"effect": "confirm",
"message": r.i18n.text(r.locale, message),
"title": r.i18n.text(r.locale, &Text::key("zeyn.ui.confirm.title")),
"ok": r.i18n.text(r.locale, &Text::key("zeyn.ui.confirm.ok")),
"cancel": r.i18n.text(r.locale, &Text::key("zeyn.ui.confirm.cancel")),
"then": then.to_json(r),
}),
other => serde_json::to_value(other).unwrap_or(serde_json::Value::Null),
}
}
}
fn params_json<P: Params>(params: &P) -> serde_json::Value {
serde_json::to_value(params).expect("screen params must serialize to JSON")
}
pub const MAX_TOASTS: usize = 3;
#[derive(Clone, Debug, PartialEq)]
pub struct Toast {
message: Text,
tone: crate::grid::Tone,
action: Option<(Text, Box<Effect>)>,
}
impl Toast {
fn of(tone: crate::grid::Tone, message: impl Into<Text>) -> Self {
Toast {
message: message.into(),
tone,
action: None,
}
}
pub fn info(message: impl Into<Text>) -> Self {
Self::of(crate::grid::Tone::Info, message)
}
pub fn success(message: impl Into<Text>) -> Self {
Self::of(crate::grid::Tone::Success, message)
}
pub fn warning(message: impl Into<Text>) -> Self {
Self::of(crate::grid::Tone::Warning, message)
}
pub fn danger(message: impl Into<Text>) -> Self {
Self::of(crate::grid::Tone::Danger, message)
}
pub fn action(mut self, label: impl Into<Text>, effect: Effect) -> Self {
self.action = Some((label.into(), Box::new(effect)));
self
}
}
impl From<Toast> for Effect {
fn from(t: Toast) -> Self {
Effect::Notice {
message: t.message,
tone: t.tone,
action: t.action,
}
}
}
pub struct Window;
impl Window {
pub fn open<S: ScreenDef>(_screen: S, params: S::Params) -> Effect {
Effect::OpenWindow {
screen: S::META.name,
params: params_json(¶ms),
}
}
}
#[derive(Debug, Default)]
pub struct UiCtx {
effects: Vec<Effect>,
}
impl UiCtx {
pub fn toast(&mut self, message: impl Into<Text>) {
self.effects.push(Effect::Toast {
message: message.into(),
});
}
pub fn notify(&mut self, toast: Toast) {
self.effects.push(toast.into());
}
pub fn close_and_refresh(&mut self) {
self.effects
.push(Effect::CloseAndRefresh);
}
pub fn navigate<S: ScreenDef>(&mut self, _screen: S, params: S::Params) {
self.effects.push(Effect::Navigate {
screen: S::META.name,
params: params_json(¶ms),
});
}
pub fn effect(&mut self, effect: Effect) {
self.effects.push(effect);
}
pub fn into_effects(self) -> Vec<Effect> {
self.effects
}
}
pub struct LoadedPage {
pub items: Box<dyn Any + Send + Sync>,
pub count: usize,
pub total: Option<u64>,
pub next: Option<Cursor>,
pub offset: u64,
pub totals: Vec<crate::action::Total>,
pub view: Option<crate::grid::DataGridView>,
pub saved_views: bool,
pub depth: usize,
pub path: Vec<Option<String>>,
pub groups: Option<Vec<crate::action::Group>>,
pub parents: Vec<String>,
}
pub struct RowCall {
pub meta: ActionMeta,
pub run: Box<dyn FnOnce(Ctx) -> BoxFuture<Result<()>> + Send>,
}
pub trait GridSource: Send + Sync {
fn action(&self) -> ActionMeta;
fn first_query(&self, view: Option<&crate::grid::DataGridView>) -> DataQuery;
fn clean_view(&self, view: crate::grid::DataGridView) -> crate::grid::DataGridView;
fn head_html(&self, r: &Render, view: Option<&crate::grid::DataGridView>) -> String;
fn fetch(&self, ctx: Ctx, query: DataQuery) -> BoxFuture<Result<LoadedPage>>;
fn fetch_at(
&self,
ctx: Ctx,
query: DataQuery,
path: Vec<Option<String>>,
) -> BoxFuture<Result<LoadedPage>> {
let _ = path;
self.fetch(ctx, query)
}
fn rows_html(&self, page: &LoadedPage, r: &Render) -> String;
fn foot_html(&self, page: &LoadedPage, r: &Render) -> Option<String>;
fn render_loaded(&self, out: &mut String, r: &Render, page: &LoadedPage);
fn tool_schema(&self) -> serde_json::Value;
fn find_row(&self, ctx: Ctx, key: String) -> BoxFuture<Result<LoadedPage>> {
let _ = (ctx, key);
Box::pin(async { Err(crate::action::Error::NotFound) })
}
fn bulk_call(&self, index: usize, keys: Vec<serde_json::Value>) -> Option<RowCall> {
let _ = (index, keys);
None
}
fn bulk_meta(&self, index: usize) -> Option<ActionMeta> {
let _ = index;
None
}
fn key_json(&self, key: &str) -> Option<serde_json::Value> {
let _ = key;
None
}
fn page_keys(&self, page: &LoadedPage) -> Vec<serde_json::Value> {
let _ = page;
Vec::new()
}
fn edit_call(
&self,
page: &LoadedPage,
key: &str,
values: &serde_json::Map<String, serde_json::Value>,
locale: &str,
tz: crate::time::TimeZone,
) -> Result<RowCall> {
let _ = (page, key, values, locale, tz);
Err(crate::action::Error::NotFound)
}
fn row_html(&self, page: &LoadedPage, key: &str, r: &Render) -> Option<String> {
let _ = (page, key, r);
None
}
fn export_access(&self) -> Option<crate::action::Access> {
None
}
fn export_limit(&self) -> usize {
0
}
fn csv_head(&self, r: &Render, view: Option<&crate::grid::DataGridView>) -> String {
let _ = (r, view);
String::new()
}
fn csv_rows(&self, page: &LoadedPage, r: &Render) -> String {
let _ = (page, r);
String::new()
}
fn export_columns(
&self,
r: &Render,
view: Option<&crate::grid::DataGridView>,
) -> Vec<crate::grid::ExportColumn> {
let _ = (r, view);
Vec::new()
}
fn export_values(&self, page: &LoadedPage, r: &Render) -> Vec<Vec<crate::value::Value>> {
let _ = (page, r);
Vec::new()
}
fn export_text(&self, page: &LoadedPage, r: &Render) -> Vec<Vec<String>> {
let _ = (page, r);
Vec::new()
}
fn row_call(&self, page: &LoadedPage, key: &str, item: &str) -> Option<RowCall> {
let _ = (page, key, item);
None
}
fn fields(&self) -> &'static [crate::model::FieldMeta] {
&[]
}
fn sensitive_model(&self) -> Option<&'static str> {
None
}
fn reveal(
&self,
_ctx: Ctx,
_key: String,
_field: String,
) -> BoxFuture<Result<Option<crate::privacy::Revealed>>> {
Box::pin(async { Ok(None) })
}
}
pub trait FormSource: Send + Sync {
fn action(&self) -> ActionMeta;
fn submit(
&self,
ctx: Ctx,
values: serde_json::Map<String, serde_json::Value>,
) -> BoxFuture<Result<Vec<Effect>>>;
fn tool_schema(&self) -> serde_json::Value;
}
pub trait Component: Send + Sync {
fn render(&self, out: &mut String, r: &Render);
fn schema(&self) -> serde_json::Value;
fn grid(&self) -> Option<&dyn GridSource> {
None
}
fn form(&self) -> Option<&dyn FormSource> {
None
}
fn reveal(&self, _field: &str) -> Option<crate::privacy::Revealed> {
None
}
fn import(&self) -> Option<&dyn crate::import::ImportSource> {
None
}
#[cfg(feature = "pdf")]
fn pdf(&self) -> Option<&dyn crate::pdf::PdfSource> {
None
}
fn children(&self) -> &[Node] {
&[]
}
fn eager_children(&self) -> &[Node] {
self.children()
}
fn source(&self, _key: &str) -> Option<&dyn crate::ui::DataSource> {
None
}
}
pub struct Node(Box<dyn Component>);
impl Node {
pub fn new(component: impl Component + 'static) -> Self {
Self(Box::new(component))
}
pub fn render(&self, out: &mut String, r: &Render) {
self.0.render(out, r);
}
pub fn schema(&self) -> serde_json::Value {
self.0.schema()
}
pub fn grid(&self) -> Option<&dyn GridSource> {
self.0.grid()
}
pub fn form(&self) -> Option<&dyn FormSource> {
self.0.form()
}
pub fn reveal(&self, field: &str) -> Option<crate::privacy::Revealed> {
self.0.reveal(field)
}
pub fn import(&self) -> Option<&dyn crate::import::ImportSource> {
self.0.import()
}
#[cfg(feature = "pdf")]
pub fn pdf(&self) -> Option<&dyn crate::pdf::PdfSource> {
self.0.pdf()
}
pub fn children(&self) -> &[Node] {
self.0.children()
}
pub fn eager_children(&self) -> &[Node] {
self.0.eager_children()
}
pub fn source(&self, key: &str) -> Option<&dyn crate::ui::DataSource> {
self.0.source(key)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Icon {
Plus,
Edit,
Delete,
Save,
Search,
Box,
Cart,
Table,
Home,
Chart,
}
impl Icon {
fn css(self) -> &'static str {
match self {
Icon::Plus => "plus",
Icon::Edit => "edit",
Icon::Delete => "delete",
Icon::Save => "save",
Icon::Search => "search",
Icon::Box => "box",
Icon::Cart => "cart",
Icon::Table => "table",
Icon::Home => "home",
Icon::Chart => "chart",
}
}
pub fn html(self) -> String {
format!(
r#"<span class="z-icon z-icon-{}" aria-hidden="true"></span>"#,
self.css()
)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Export {
Xlsx,
Csv,
Pdf,
}
impl Export {
fn code(self) -> &'static str {
match self {
Export::Xlsx => "xlsx",
Export::Csv => "csv",
Export::Pdf => "pdf",
}
}
}
#[derive(Debug)]
struct ButtonSpec {
label: Text,
icon: Option<Icon>,
shortcut: Option<String>,
override_browser: bool,
effect: Option<Effect>,
tip: Option<Text>,
}
#[derive(Debug)]
#[allow(
clippy::large_enum_variant,
reason = "nearly every entry of the list is the large variant, so boxing it would add an allocation per entry and save no memory"
)]
enum ToolItem {
Button(ButtonSpec),
Export(Export),
}
#[derive(Debug, Default)]
pub struct Toolbar {
items: Vec<ToolItem>,
}
impl Toolbar {
pub fn button(self, label: impl Into<Text>) -> ToolButton {
ToolButton {
bar: self,
spec: ButtonSpec {
label: label.into(),
icon: None,
shortcut: None,
override_browser: false,
effect: None,
tip: None,
},
}
}
pub fn export(mut self, format: Export) -> Toolbar {
self.items
.push(ToolItem::Export(format));
self
}
pub(crate) fn render(&self, out: &mut String, r: &Render, title: &str) {
let scope = r.scope;
out.push_str(&format!(
r#"<div class="z-toolbar" role="toolbar" aria-label="{title}">"#
));
for (n, item) in self.items.iter().enumerate() {
match item {
ToolItem::Button(b) => {
let tip = b.tip.as_ref().map(|t| crate::ui::tip(r, &format!("{scope}-tooltip-{n}"), t));
if tip.is_some() {
out.push_str(r#"<span class="z-tip-host">"#);
}
let english = b.label.english();
out.push_str(&format!(
r#"<button type="button" class="z-button" data-testid="{scope}.toolbar.{}""#,
slug(&english)
));
if let Some(keys) = b.shortcut.as_deref().and_then(|s| crate::command::usable_shortcut(s, b.override_browser)) {
out.push_str(&format!(r#" aria-keyshortcuts="{}""#, crate::render::escape(&keys)));
}
if let Some(e) = &b.effect {
out.push_str(&format!(r#" data-zeyn-effect="{}""#, attr(e)));
}
if let Some((attrs, _)) = &tip {
out.push_str(attrs);
}
out.push('>');
if let Some(i) = b.icon {
out.push_str(&i.html());
}
out.push_str(&r.text(&b.label));
out.push_str("</button>");
if let Some((_, html)) = &tip {
out.push_str(html);
out.push_str("</span>");
}
}
ToolItem::Export(e) => out.push_str(&format!(
r#"<button type="button" class="z-button" data-testid="{scope}.toolbar.export-{code}" data-zeyn-export="{code}">{}</button>"#,
r.text(&Text::key(format!("zeyn.export.{}", e.code()))),
code = e.code()
)),
}
}
out.push_str("</div>");
}
pub(crate) fn schema(&self) -> serde_json::Value {
self.items
.iter()
.map(|item| match item {
ToolItem::Button(b) => json!({
"type": "button",
"label": b.label,
"icon": b.icon,
"shortcut": b.shortcut,
"effect": b.effect,
}),
ToolItem::Export(e) => json!({ "type": "export", "format": e }),
})
.collect()
}
}
pub struct ToolButton {
bar: Toolbar,
spec: ButtonSpec,
}
impl ToolButton {
pub fn icon(mut self, icon: Icon) -> Self {
self.spec.icon = Some(icon);
self
}
pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
self.spec.shortcut = Some(keys.into());
self
}
pub fn override_browser(mut self) -> Self {
self.spec.override_browser = true;
self
}
pub fn tip(mut self, text: impl Into<Text>) -> Self {
self.spec.tip = Some(text.into());
self
}
pub fn opens<S: ScreenDef>(mut self, screen: S, params: S::Params) -> Self {
self.spec.effect = Some(Window::open(screen, params));
self
}
pub fn button(self, label: impl Into<Text>) -> ToolButton {
self.finish().button(label)
}
pub fn export(self, format: Export) -> Toolbar {
self.finish().export(format)
}
fn finish(mut self) -> Toolbar {
self.bar
.items
.push(ToolItem::Button(self.spec));
self.bar
}
}
impl From<ToolButton> for Toolbar {
fn from(b: ToolButton) -> Self {
b.finish()
}
}
#[derive(Clone, Copy, Debug)]
enum ScreenKind {
Page,
Window { width: u32 },
}
pub struct Screen {
title: Text,
kind: ScreenKind,
toolbar: Option<Toolbar>,
body: Vec<Node>,
menu: Option<crate::menu::ContextMenu>,
moved: Option<(&'static str, serde_json::Value)>,
head: Option<Box<crate::seo::Head>>,
}
impl Screen {
pub fn new(title: impl Into<Text>) -> Self {
Self {
title: title.into(),
kind: ScreenKind::Page,
toolbar: None,
body: Vec::new(),
menu: None,
moved: None,
head: None,
}
}
pub fn moved_to<S: ScreenDef>(_screen: S, params: S::Params) -> Self {
Self {
moved: Some((S::META.name, params_json(¶ms))),
..Self::new("")
}
}
pub(crate) fn take_moved(&mut self) -> Option<(&'static str, serde_json::Value)> {
self.moved.take()
}
fn head_mut(&mut self) -> &mut crate::seo::Head {
self.head
.get_or_insert_with(Default::default)
}
pub fn description(mut self, text: impl Into<Text>) -> Self {
self.head_mut().description = Some(text.into());
self
}
pub fn canonical(mut self, url: impl Into<String>) -> Self {
self.head_mut().canonical = crate::seo::checked(url.into(), "a canonical URL");
self
}
pub fn alternate(mut self, hreflang: &str, url: impl Into<String>) -> Self {
let tag = crate::seo::valid_hreflang(hreflang);
let url = crate::seo::checked(url.into(), "an alternate URL");
let head = self.head_mut();
match (tag, url) {
(true, Some(url)) if head.alternates.len() < crate::seo::MAX_ALTERNATES => {
head.alternates
.push((hreflang.to_string(), url));
}
(false, _) => {
tracing::warn!("hreflang `{hreflang}` is not a language tag or x-default; ignored")
}
_ => {}
}
self
}
pub fn og_image(mut self, url: impl Into<String>) -> Self {
self.head_mut().og_image = crate::seo::checked(url.into(), "an og:image URL");
self
}
pub fn json_ld(mut self, data: serde_json::Value) -> Self {
self.head_mut().json_ld = Some(data);
self
}
pub fn robots(mut self, robots: crate::seo::Robots) -> Self {
self.head_mut().robots = Some(robots);
self
}
pub fn head_html(&self, r: &Render, site: &str, prefix: &str) -> String {
self.head
.as_ref()
.map(|h| h.render(r, site, prefix))
.unwrap_or_default()
}
pub fn window(title: impl Into<Text>) -> Self {
Self {
kind: ScreenKind::Window { width: 560 },
..Self::new(title)
}
}
pub fn width(mut self, px: u32) -> Self {
if let ScreenKind::Window { width } = &mut self.kind {
*width = px;
}
self
}
pub fn toolbar<F, R>(mut self, build: F) -> Self
where
F: FnOnce(Toolbar) -> R,
R: Into<Toolbar>,
{
self.toolbar = Some(build(Toolbar::default()).into());
self
}
pub fn body(mut self, node: impl Into<Node>) -> Self {
self.body.push(node.into());
self
}
pub fn context_menu(mut self, menu: crate::menu::ContextMenu) -> Self {
self.menu = Some(menu);
self
}
pub fn menu_call(&self, path: &str) -> Option<RowCall> {
self.menu.as_ref()?.take(path)
}
pub fn title(&self) -> &Text {
&self.title
}
pub fn nodes(&self) -> &[Node] {
&self.body
}
pub fn html(&self, meta: &ScreenMeta) -> String {
self.render(
&Render::english(meta.name),
meta,
&serde_json::Value::Null,
&HashMap::new(),
)
}
pub fn render(
&self,
r: &Render,
meta: &ScreenMeta,
params: &serde_json::Value,
loaded: &HashMap<String, LoadedPage>,
) -> String {
let scope = meta.name;
let r = Render {
scope,
loaded: Some(loaded),
..*r
};
let title = r.text(&self.title);
let params = match self
.menu
.as_ref()
.map(|m| m.to_json(&r))
{
Some(items)
if items
.as_array()
.is_some_and(|a| !a.is_empty()) =>
{
format!(r#"{}" data-zeyn-menu="{}"#, attr(params), attr(&items))
}
_ => attr(params),
};
let mut out = String::new();
let close = match self.kind {
ScreenKind::Page => {
out.push_str(&format!(
r#"<section class="z-screen" data-zeyn-screen="{scope}" data-zeyn-params="{params}" aria-labelledby="{scope}-title"><h1 id="{scope}-title">{title}</h1>"#
));
"</section>"
}
ScreenKind::Window { width } => {
out.push_str(&format!(
r#"<div class="z-window" role="dialog" aria-modal="true" data-zeyn-screen="{scope}" data-zeyn-params="{params}" aria-labelledby="{scope}-title" style="--z-width:{width}px"><h2 id="{scope}-title">{title}</h2>"#
));
"</div>"
}
};
if let Some(t) = &self.toolbar {
t.render(&mut out, &r, &title);
}
for (i, node) in self.body.iter().enumerate() {
crate::ui::render_node(&mut out, &r, &i.to_string(), node);
}
out.push_str(close);
out
}
pub fn schema(&self, meta: &ScreenMeta) -> serde_json::Value {
let (kind, width) = match self.kind {
ScreenKind::Page => ("page", None),
ScreenKind::Window { width } => ("window", Some(width)),
};
json!({
"screen": meta,
"title": self.title,
"kind": kind,
"width": width,
"toolbar": self.toolbar.as_ref().map(Toolbar::schema),
"body": self.body.iter().map(Node::schema).collect::<Vec<_>>(),
})
}
}