#![cfg(feature = "projections")]
use ferro_projections::render::{field_display_name, is_system_field};
use ferro_projections::{
FieldDef, FieldMeaning, Intent, IntentScore, NavigationHint, RelationshipDef, ServiceDef,
};
use ferro_theme::IntentSlotTemplate;
use crate::action::{Action, HttpMethod};
use crate::catalog::{global_catalog, Catalog};
use crate::component::{
ActionGroupProps, ActionItem, ButtonProps, ButtonType, CardAppearance, CardProps, Column,
DataTableProps, DescriptionItem, DescriptionListProps, DropdownMenuAction, FormProps, GapSize,
GridProps, KanbanBoardProps, KanbanColumnProps, SelectionPanelProps, Size, StatCardProps, Tab,
TableProps, TabsProps, TileGridProps, TileProps, Variant,
};
use crate::spec::{Element, ElementBuilder, Spec};
use super::component_map::{
build_badge_props, build_column_for_field, build_description_item, build_input_props,
build_progress_props, build_relationship_button_props, build_relationship_text_props,
build_select_props, build_switch_props, build_text_props, lookup_meaning, lookup_relationship,
};
use super::error::ProjectionError;
use super::intent_layout::{default_template, pick_intent_template};
use super::{RenderMode, VisualContext};
impl Spec {
pub fn from_service_def(
service: &ServiceDef,
intents: &[IntentScore],
ctx: &VisualContext,
) -> Result<Spec, ProjectionError> {
if intents.is_empty() {
return Err(ProjectionError::EmptyIntents);
}
if ctx.base.intent_index >= intents.len() {
return Err(ProjectionError::IntentIndexOutOfBounds {
requested: ctx.base.intent_index,
available: intents.len(),
});
}
Self::from_service_def_with_catalog(service, intents, ctx, global_catalog())
}
pub(crate) fn from_service_def_with_catalog(
service: &ServiceDef,
intents: &[IntentScore],
ctx: &VisualContext,
catalog: &Catalog,
) -> Result<Spec, ProjectionError> {
if intents.is_empty() {
return Err(ProjectionError::EmptyIntents);
}
let intent_score =
intents
.get(ctx.base.intent_index)
.ok_or(ProjectionError::IntentIndexOutOfBounds {
requested: ctx.base.intent_index,
available: intents.len(),
})?;
let spec = if ctx.mode == RenderMode::Input {
build_input_spec(service)?
} else {
let template = ctx
.templates
.as_ref()
.and_then(|t| pick_intent_template(t, &intent_score.intent))
.cloned()
.unwrap_or_else(|| default_template(&intent_score.intent));
build_display_spec(service, &intent_score.intent, &template.display, ctx)?
};
match catalog.validate(&spec) {
Ok(()) => Ok(spec),
Err(errors) => {
#[cfg(debug_assertions)]
panic!("Projector emitted invalid spec: {errors:?}");
#[cfg(not(debug_assertions))]
{
Err(ProjectionError::CatalogValidation(errors))
}
}
}
}
}
fn resolve_title(service: &ServiceDef) -> String {
service
.display_name
.as_deref()
.unwrap_or(&service.name)
.to_string()
}
fn element_with_props(type_name: &str, props: serde_json::Value) -> ElementBuilder {
let obj = props
.as_object()
.expect("typed Props must serialize to a JSON object");
let mut el = Element::new(type_name);
for (k, v) in obj {
el = el.prop(k.clone(), v.clone());
}
el
}
fn element_with_props_and_children(
type_name: &str,
props: serde_json::Value,
children: Vec<String>,
) -> ElementBuilder {
let mut el = element_with_props(type_name, props);
for child in children {
el = el.child(child);
}
el
}
fn build_input_spec(service: &ServiceDef) -> Result<Spec, ProjectionError> {
let action = Action::new(format!("/{}", service.name));
let form_props = serde_json::to_value(FormProps {
action,
method: None,
guard: None,
max_width: None,
id: None,
enctype: None,
fill: None,
})
.expect("FormProps serialization cannot fail");
let mut children_ids: Vec<String> = Vec::new();
let mut field_elements: Vec<(String, ElementBuilder)> = Vec::new();
for field in service
.fields
.iter()
.filter(|f| f.writable && !is_system_field(&f.meaning))
{
let choice = lookup_meaning(&field.meaning);
let Some(type_name) = choice.input else {
continue;
};
let props = input_props_for(type_name, field)?;
let id = format!("field_{}", field.name);
field_elements.push((id.clone(), element_with_props(type_name, props)));
children_ids.push(id);
}
let root = element_with_props_and_children("Form", form_props, children_ids);
let mut builder = Spec::builder()
.title(resolve_title(service))
.element("root", root);
for (id, el) in field_elements {
builder = builder.element(id, el);
}
builder.build().map_err(ProjectionError::SpecBuild)
}
fn input_props_for(
type_name: &str,
field: &FieldDef,
) -> Result<serde_json::Value, ProjectionError> {
match type_name {
"Input" => Ok(build_input_props(field)),
"Select" => Ok(build_select_props(field)),
"Switch" => Ok(build_switch_props(field)),
other => Err(ProjectionError::UnknownComponent {
type_name: other.to_string(),
}),
}
}
fn build_display_spec(
service: &ServiceDef,
intent: &Intent,
template: &IntentSlotTemplate,
ctx: &VisualContext,
) -> Result<Spec, ProjectionError> {
let layout = template.layout.as_deref().unwrap_or("Card");
let mut aux_elements: Vec<(String, ElementBuilder)> = Vec::new();
let root = match layout {
"DataTable" => emit_datatable_root(service),
"Card" => emit_card_root(service, &template.slots, &mut aux_elements),
"Form" => {
return build_input_spec(service);
}
"KanbanBoard" => emit_kanban_root(service, ctx),
"StatCard" => emit_statcard_root(service, &template.slots, &mut aux_elements),
"Register" => emit_register_root(service, &mut aux_elements)?,
other => {
return Err(ProjectionError::UnknownComponent {
type_name: other.to_string(),
});
}
};
let _ = intent;
let mut builder = Spec::builder().title(resolve_title(service));
if layout == "Register" {
builder = builder.fill_viewport(true).layout("dashboard");
}
builder = builder.element("root", root);
for (id, el) in aux_elements {
builder = builder.element(id, el);
}
builder.build().map_err(ProjectionError::SpecBuild)
}
fn emit_datatable_root(service: &ServiceDef) -> ElementBuilder {
let columns: Vec<Column> = service
.fields
.iter()
.filter(|f| f.readable && !is_system_field(&f.meaning))
.filter(|f| lookup_meaning(&f.meaning).column.is_some())
.map(build_column_for_field)
.collect();
let row_actions: Option<Vec<DropdownMenuAction>> = if service.actions.is_empty() {
None
} else {
Some(
service
.actions
.iter()
.map(|a| DropdownMenuAction {
label: a.display_name.as_deref().unwrap_or(&a.name).to_string(),
action: Action::new(format!("/{}/{{row_key}}/{}", service.name, a.name)),
destructive: false,
visible_if: None,
})
.collect(),
)
};
let row_key = if service.actions.is_empty() {
None
} else {
Some("id".to_string())
};
let props = serde_json::to_value(DataTableProps {
columns,
data_path: format!("/data/{}", service.name),
row_actions,
empty_message: None,
row_key,
row_href: None,
})
.expect("DataTableProps serialization cannot fail");
element_with_props("DataTable", props)
}
fn emit_card_root(
service: &ServiceDef,
slots: &[String],
aux: &mut Vec<(String, ElementBuilder)>,
) -> ElementBuilder {
let mut children: Vec<String> = Vec::new();
for slot in slots {
match slot.as_str() {
"title" => { }
"fields" => emit_fields_as_description_list(service, aux, &mut children),
"relationships" => emit_relationships(service, aux, &mut children),
"actions" => emit_actions_placeholder(service, aux, &mut children),
"metadata" => emit_metadata(service, aux, &mut children),
"body" => emit_body_placeholder(aux, &mut children),
_ => {}
}
}
let props = serde_json::to_value(CardProps {
title: resolve_title(service),
description: None,
subtitle: None,
badge: None,
max_width: None,
footer: Vec::new(),
appearance: CardAppearance::Bordered,
})
.expect("CardProps serialization cannot fail");
let mut el = element_with_props("Card", props);
for id in children {
el = el.child(id);
}
el
}
fn emit_kanban_root(service: &ServiceDef, ctx: &VisualContext) -> ElementBuilder {
let columns: Vec<KanbanColumnProps> = service
.state_machine
.as_ref()
.map(|sm| {
sm.states
.iter()
.map(|s| KanbanColumnProps {
id: s.name.clone(),
title: s.display_name.as_deref().unwrap_or(&s.name).to_string(),
count: 0,
children: Vec::new(),
})
.collect()
})
.unwrap_or_else(|| {
vec![KanbanColumnProps {
id: "default".to_string(),
title: resolve_title(service),
count: 0,
children: Vec::new(),
}]
});
let field_name_by = |pred: fn(&FieldMeaning) -> bool| -> Option<String> {
service
.fields
.iter()
.find(|f| f.readable && pred(&f.meaning))
.map(|f| f.name.clone())
};
let has_state_machine = service.state_machine.is_some();
let items_path = has_state_machine.then(|| format!("/data/{}", service.name));
let group_by = has_state_machine
.then(|| field_name_by(|m| matches!(m, FieldMeaning::Status)))
.flatten();
let card_title_key = has_state_machine
.then(|| {
field_name_by(|m| matches!(m, FieldMeaning::EntityName))
.or_else(|| field_name_by(|m| matches!(m, FieldMeaning::Identifier)))
})
.flatten();
let card_description_key = has_state_machine
.then(|| field_name_by(|m| matches!(m, FieldMeaning::Money)))
.flatten();
let row_actions: Option<Vec<DropdownMenuAction>> =
if !has_state_machine || service.actions.is_empty() {
None
} else {
Some(
service
.actions
.iter()
.map(|a| DropdownMenuAction {
label: a.display_name.as_deref().unwrap_or(&a.name).to_string(),
action: Action::new(format!("/{}/{{row_key}}/{}", service.name, a.name)),
destructive: false,
visible_if: None,
})
.collect(),
)
};
let row_key = row_actions.as_ref().map(|_| "id".to_string());
let mobile_default_column = ctx.base.current_state.clone();
let props = serde_json::to_value(KanbanBoardProps {
columns,
items_path,
group_by,
card_title_key,
card_description_key,
row_actions,
row_key,
mobile_default_column,
empty_label: None,
})
.expect("KanbanBoardProps serialization cannot fail");
element_with_props("KanbanBoard", props)
}
fn emit_statcard_root(
service: &ServiceDef,
slots: &[String],
aux: &mut Vec<(String, ElementBuilder)>,
) -> ElementBuilder {
let mut dropped: Vec<String> = Vec::new();
for slot in slots {
if slot == "metadata" {
emit_metadata(service, aux, &mut dropped);
}
}
let primary_field = service
.fields
.iter()
.find(|f| f.readable && matches!(f.meaning, FieldMeaning::Money | FieldMeaning::Quantity));
let (label, value_path) = primary_field
.map(|f| {
(
field_display_name(&f.name),
Some(format!("/data/{}/{}", service.name, f.name)),
)
})
.unwrap_or_else(|| (resolve_title(service), None));
let props = serde_json::to_value(StatCardProps {
label,
value: String::new(),
tone: crate::component::Tone::Neutral,
value_path,
icon: None,
subtitle: None,
sse_target: None,
})
.expect("StatCardProps serialization cannot fail");
element_with_props("StatCard", props)
}
fn emit_register_root(
service: &ServiceDef,
aux: &mut Vec<(String, ElementBuilder)>,
) -> Result<ElementBuilder, ProjectionError> {
let confirm =
service
.actions
.first()
.ok_or_else(|| ProjectionError::RegisterMissingAction {
service: service.name.clone(),
})?;
let confirm_label = confirm
.display_name
.as_deref()
.unwrap_or(&confirm.name)
.to_string();
let form_action = Action::new(format!("/{}/{}", service.name, confirm.name));
let field_name_by = |pred: fn(&FieldMeaning) -> bool| -> Option<String> {
service
.fields
.iter()
.find(|f| f.readable && pred(&f.meaning))
.map(|f| f.name.clone())
};
let fallback = service
.fields
.iter()
.find(|f| f.readable && lookup_meaning(&f.meaning).display.is_some())
.map(|f| f.name.clone())
.unwrap_or_else(|| "id".to_string());
let id_field = field_name_by(|m| matches!(m, FieldMeaning::Identifier))
.unwrap_or_else(|| fallback.clone());
let name_field = field_name_by(|m| matches!(m, FieldMeaning::EntityName))
.unwrap_or_else(|| id_field.clone());
let money_field =
field_name_by(|m| matches!(m, FieldMeaning::Money)).unwrap_or_else(|| fallback.clone());
let data_path = format!("/data/{}", service.name);
let confirm_btn = element_with_props(
"Button",
serde_json::to_value(ButtonProps {
label: confirm_label,
variant: Variant::Primary,
size: Size::Md,
disabled: None,
icon: None,
icon_position: None,
button_type: Some(ButtonType::Submit),
form: Some("sale_form".into()),
disable_on_submit: Some(true),
})
.expect("ButtonProps serialization cannot fail"),
);
aux.push(("confirm_btn".into(), confirm_btn));
let selection_pane = element_with_props(
"SelectionPanel",
serde_json::to_value(SelectionPanelProps {
form_id: "sale_form".into(),
empty_message: None,
currency: None,
total_label: None,
})
.expect("SelectionPanelProps serialization cannot fail"),
)
.child("confirm_btn");
aux.push(("selection_pane".into(), selection_pane));
let tile_tmpl = element_with_props(
"Tile",
serde_json::to_value(TileProps {
item_id: String::new(),
name: String::new(),
price: String::new(),
field: String::new(),
default_quantity: None,
categories: vec![],
image_url: None,
color: None,
stock_badge: None,
price_cents: None,
})
.expect("TileProps serialization cannot fail"),
)
.prop(
"item_id",
serde_json::json!({"$data": format!("/p/{id_field}")}),
)
.prop(
"name",
serde_json::json!({"$data": format!("/p/{name_field}")}),
)
.prop(
"price",
serde_json::json!({"$data": format!("/p/{money_field}")}),
)
.prop(
"price_cents",
serde_json::json!({"$data": "/p/price_cents"}),
)
.prop("field", serde_json::json!({"$data": "/p/field"}))
.each(data_path.clone(), "p");
aux.push(("tile_tmpl".into(), tile_tmpl));
let tiles_pane = element_with_props(
"TileGrid",
serde_json::to_value(TileGridProps {
data_path: data_path.clone(),
form_id: "sale_form".into(),
categories_path: None,
columns: None,
search: Some(true),
search_placeholder: None,
all_label: None,
})
.expect("TileGridProps serialization cannot fail"),
)
.child("tile_tmpl");
aux.push(("tiles_pane".into(), tiles_pane));
let panes_grid = element_with_props(
"Grid",
serde_json::to_value(GridProps {
columns: 1,
md_columns: Some(3),
lg_columns: None,
gap: GapSize::Md,
scrollable: None,
spans: vec![2, 1],
row_weights: vec![],
fill: Some(true),
})
.expect("GridProps serialization cannot fail"),
)
.child("tiles_pane")
.child("selection_pane");
aux.push(("panes_grid".into(), panes_grid));
let sale_form = element_with_props(
"Form",
serde_json::to_value(FormProps {
action: form_action,
method: Some(HttpMethod::Post),
guard: None,
max_width: None,
id: Some("sale_form".into()),
enctype: None,
fill: Some(true),
})
.expect("FormProps serialization cannot fail"),
)
.child("panes_grid");
aux.push(("sale_form".into(), sale_form));
let register_root = element_with_props(
"Grid",
serde_json::to_value(GridProps {
columns: 1,
md_columns: None,
lg_columns: None,
gap: GapSize::Md,
scrollable: None,
spans: vec![],
row_weights: vec![],
fill: Some(true),
})
.expect("GridProps serialization cannot fail"),
)
.child("sale_form");
Ok(register_root)
}
fn emit_fields_as_description_list(
service: &ServiceDef,
aux: &mut Vec<(String, ElementBuilder)>,
children_out: &mut Vec<String>,
) {
let items: Vec<DescriptionItem> = service
.fields
.iter()
.filter(|f| f.readable && !is_system_field(&f.meaning))
.filter(|f| lookup_meaning(&f.meaning).display.is_some())
.map(build_description_item)
.collect();
if items.is_empty() {
return;
}
let props = serde_json::to_value(DescriptionListProps {
items,
columns: None,
data_path: None,
})
.expect("DescriptionListProps serialization cannot fail");
let id = "fields_list".to_string();
aux.push((id.clone(), element_with_props("DescriptionList", props)));
children_out.push(id);
}
fn emit_relationships(
service: &ServiceDef,
aux: &mut Vec<(String, ElementBuilder)>,
children_out: &mut Vec<String>,
) {
let tab_rels: Vec<&RelationshipDef> = service
.relationships
.iter()
.filter(|r| matches!(r.navigation, NavigationHint::Tab))
.collect();
if !tab_rels.is_empty() {
let tabs: Vec<Tab> = tab_rels
.iter()
.map(|r| Tab {
value: r.name.clone(),
label: field_display_name(&r.target),
children: Vec::new(),
})
.collect();
let default_tab = tabs.first().map(|t| t.value.clone()).unwrap_or_default();
let props = serde_json::to_value(TabsProps { default_tab, tabs })
.expect("TabsProps serialization cannot fail");
let id = "relationships_tabs".to_string();
aux.push((id.clone(), element_with_props("Tabs", props)));
children_out.push(id);
}
for rel in service.relationships.iter() {
if matches!(rel.navigation, NavigationHint::Tab) {
continue;
}
let Some(component) = lookup_relationship(rel.navigation) else {
continue; };
let props = match rel.navigation {
NavigationHint::Inline => build_relationship_text_props(rel),
NavigationHint::Link => build_relationship_button_props(rel),
NavigationHint::Nested => {
let col = Column {
key: "name".to_string(),
label: field_display_name(&rel.target),
format: None,
align: None,
};
serde_json::to_value(TableProps {
columns: vec![col],
data_path: format!("/data/{}", rel.name),
row_actions: None,
empty_message: None,
sortable: None,
sort_column: None,
sort_direction: None,
})
.expect("TableProps serialization cannot fail")
}
_ => continue,
};
let id = format!("rel_{}", rel.name);
aux.push((id.clone(), element_with_props(component, props)));
children_out.push(id);
}
}
fn emit_actions_placeholder(
service: &ServiceDef,
aux: &mut Vec<(String, ElementBuilder)>,
children_out: &mut Vec<String>,
) {
if service.actions.is_empty() {
return;
}
let items: Vec<ActionItem> = service
.actions
.iter()
.map(|a| ActionItem {
label: a.display_name.as_deref().unwrap_or(&a.name).to_string(),
action: Action::new(format!("/{}/{}", service.name, a.name)),
destructive: false,
variant: None,
icon: None,
visible_if: None,
})
.collect();
let props = serde_json::to_value(ActionGroupProps {
items,
menu_id: format!("actions_{}", service.name),
max_inline: None,
overflow_label: None,
row_key: None,
})
.expect("ActionGroupProps serialization cannot fail");
let id = "actions_menu".to_string();
aux.push((id.clone(), element_with_props("ActionGroup", props)));
children_out.push(id);
}
fn emit_metadata(
service: &ServiceDef,
aux: &mut Vec<(String, ElementBuilder)>,
children_out: &mut Vec<String>,
) {
let items: Vec<DescriptionItem> = service
.fields
.iter()
.filter(|f| is_system_field(&f.meaning))
.map(build_description_item)
.collect();
if items.is_empty() {
return;
}
let props = serde_json::to_value(DescriptionListProps {
items,
columns: None,
data_path: None,
})
.expect("DescriptionListProps serialization cannot fail");
let id = "metadata_list".to_string();
aux.push((id.clone(), element_with_props("DescriptionList", props)));
children_out.push(id);
}
#[allow(clippy::ptr_arg)] fn emit_body_placeholder(
_aux: &mut Vec<(String, ElementBuilder)>,
_children_out: &mut Vec<String>,
) {
}
#[allow(dead_code)]
fn _reserved_props_noop() {
let _ = build_badge_props;
let _ = build_progress_props;
let _ = build_text_props;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::Catalog;
use ferro_projections::render::BaseContext;
use ferro_projections::{
derive_intents, ActionDef, DataType, FieldMeaning, ServiceDef, StateDef, StateMachine,
Transition,
};
use ferro_theme::{IntentModeTemplates, IntentSlotTemplate, ThemeTemplates};
fn sample_service() -> ServiceDef {
ServiceDef::new("product")
.display_name("Product")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.field("price", DataType::Float, FieldMeaning::Money)
.field("created_at", DataType::DateTime, FieldMeaning::CreatedAt)
}
fn service_with_actions() -> ServiceDef {
ServiceDef::new("staff")
.display_name("Staff")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.action(ActionDef::new("view").display_name("View"))
.action(ActionDef::new("edit").display_name("Edit"))
.action(ActionDef::new("delete").display_name("Delete"))
}
fn service_with_state_machine() -> ServiceDef {
ServiceDef::new("order")
.display_name("Order")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("status", DataType::String, FieldMeaning::Status)
.state_machine(
StateMachine::new("lifecycle")
.initial("draft")
.state(StateDef::new("draft").display_name("Draft"))
.state(StateDef::new("submitted").display_name("Submitted"))
.state(StateDef::new("done").display_name("Done").final_state())
.transition(Transition::new("draft", "submit", "submitted"))
.transition(Transition::new("submitted", "complete", "done")),
)
}
#[allow(dead_code)]
fn service_with_money_field() -> ServiceDef {
ServiceDef::new("statistics")
.display_name("Statistics")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("total_revenue", DataType::Float, FieldMeaning::Money)
}
fn clean_catalog() -> Catalog {
Catalog::build_builtins_only().expect("builtins-only catalog builds clean")
}
#[test]
fn from_service_def_validates() {
let service = sample_service();
let intents = derive_intents(&service);
let ctx = VisualContext::default();
let cat = clean_catalog();
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat)
.expect("valid projection should pass validation");
assert!(cat.validate(&spec).is_ok());
}
#[test]
fn from_service_def_browse_display() {
let service = sample_service();
let intents = derive_intents(&service);
let ctx = VisualContext {
base: BaseContext {
intent_index: intents
.iter()
.position(|i| matches!(i.intent, Intent::Browse))
.unwrap_or(0),
..Default::default()
},
mode: RenderMode::Display,
..Default::default()
};
let cat = clean_catalog();
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat)
.expect("should project");
assert_eq!(spec.schema, "ferro-json-ui/v2");
let root = spec.elements.get(&spec.root).expect("root element exists");
assert_eq!(root.type_name, "DataTable");
}
#[test]
fn input_mode_always_form() {
let service = sample_service();
let intents = derive_intents(&service);
assert!(!intents.is_empty(), "sample service must derive intents");
let cat = clean_catalog();
for idx in 0..intents.len() {
let ctx = VisualContext {
base: BaseContext {
intent_index: idx,
..Default::default()
},
mode: RenderMode::Input,
..Default::default()
};
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat)
.expect("every intent should project in Input mode");
let root = spec.elements.get(&spec.root).unwrap();
assert_eq!(
root.type_name, "Form",
"intent at index {idx} did not collapse to Form in Input mode"
);
}
}
#[test]
fn system_fields_excluded() {
let service = sample_service();
let intents = derive_intents(&service);
let ctx = VisualContext {
base: BaseContext {
intent_index: intents
.iter()
.position(|i| matches!(i.intent, Intent::Browse))
.unwrap_or(0),
..Default::default()
},
mode: RenderMode::Display,
..Default::default()
};
let cat = clean_catalog();
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat).unwrap();
let root = spec.elements.get(&spec.root).unwrap();
let columns = root
.props
.get("columns")
.and_then(|c| c.as_array())
.expect("columns array present");
let keys: Vec<&str> = columns
.iter()
.filter_map(|c| c.get("key").and_then(|k| k.as_str()))
.collect();
assert!(keys.contains(&"name"), "name column expected: {keys:?}");
assert!(keys.contains(&"price"), "price column expected: {keys:?}");
assert!(
!keys.contains(&"id"),
"id column must be excluded: {keys:?}"
);
assert!(
!keys.contains(&"created_at"),
"created_at column must be excluded: {keys:?}"
);
}
#[test]
fn template_override() {
let service = sample_service();
let intents = derive_intents(&service);
let templates = ThemeTemplates {
browse: Some(IntentModeTemplates {
display: IntentSlotTemplate {
slots: vec!["title".into(), "stats".into(), "metadata".into()],
layout: Some("StatCard".into()),
},
input: IntentSlotTemplate::default(),
}),
focus: None,
collect: None,
process: None,
summarize: None,
analyze: None,
track: None,
};
let ctx = VisualContext {
base: BaseContext {
intent_index: intents
.iter()
.position(|i| matches!(i.intent, Intent::Browse))
.unwrap_or(0),
..Default::default()
},
mode: RenderMode::Display,
templates: Some(templates),
};
let cat = clean_catalog();
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat)
.expect("override projects");
let root = spec.elements.get(&spec.root).unwrap();
assert_eq!(
root.type_name, "StatCard",
"theme override must win over default_template"
);
}
#[test]
fn sensitive_field_never_appears_in_display_or_column() {
let service = ServiceDef::new("user")
.display_name("User")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.field("password_hash", DataType::String, FieldMeaning::Sensitive);
let intents = derive_intents(&service);
let cat = clean_catalog();
let browse_idx = intents
.iter()
.position(|i| matches!(i.intent, Intent::Browse))
.unwrap_or(0);
let browse_ctx = VisualContext {
base: BaseContext {
intent_index: browse_idx,
..Default::default()
},
mode: RenderMode::Display,
..Default::default()
};
let browse_spec =
Spec::from_service_def_with_catalog(&service, &intents, &browse_ctx, &cat).unwrap();
let browse_root = browse_spec.elements.get(&browse_spec.root).unwrap();
let columns = browse_root
.props
.get("columns")
.and_then(|c| c.as_array())
.expect("columns array present");
let column_keys: Vec<&str> = columns
.iter()
.filter_map(|c| c.get("key").and_then(|k| k.as_str()))
.collect();
assert!(
!column_keys.contains(&"password_hash"),
"Sensitive field leaked into DataTable columns: {column_keys:?}"
);
let focus_idx = intents
.iter()
.position(|i| matches!(i.intent, Intent::Focus))
.unwrap_or(0);
let focus_ctx = VisualContext {
base: BaseContext {
intent_index: focus_idx,
..Default::default()
},
mode: RenderMode::Display,
..Default::default()
};
let focus_spec =
Spec::from_service_def_with_catalog(&service, &intents, &focus_ctx, &cat).unwrap();
let leaked = focus_spec.elements.values().any(|el| {
el.type_name == "DescriptionList"
&& el
.props
.get("items")
.and_then(|i| i.as_array())
.map(|arr| {
arr.iter().any(|item| {
item.get("label")
.and_then(|l| l.as_str())
.map(|s| s.to_lowercase().contains("password"))
.unwrap_or(false)
})
})
.unwrap_or(false)
});
assert!(
!leaked,
"Sensitive field leaked into a DescriptionList item"
);
}
#[test]
fn input_props_for_unknown_type_returns_unknown_component_error() {
let field = ferro_projections::FieldDef {
name: "x".into(),
data_type: DataType::String,
meaning: FieldMeaning::Email,
required: false,
is_list: false,
readable: true,
writable: true,
render_hint: None,
};
let result = super::input_props_for("DatePicker", &field);
match result {
Err(ProjectionError::UnknownComponent { type_name }) => {
assert_eq!(type_name, "DatePicker");
}
other => panic!("expected UnknownComponent, got {other:?}"),
}
}
#[test]
fn statcard_metadata_is_orphan_element() {
let service = sample_service();
let intents = derive_intents(&service);
let templates = ThemeTemplates {
browse: Some(IntentModeTemplates {
display: IntentSlotTemplate {
slots: vec!["title".into(), "stats".into(), "metadata".into()],
layout: Some("StatCard".into()),
},
input: IntentSlotTemplate::default(),
}),
focus: None,
collect: None,
process: None,
summarize: None,
analyze: None,
track: None,
};
let ctx = VisualContext {
base: BaseContext {
intent_index: intents
.iter()
.position(|i| matches!(i.intent, Intent::Browse))
.unwrap_or(0),
..Default::default()
},
mode: RenderMode::Display,
templates: Some(templates),
};
let cat = clean_catalog();
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat)
.expect("StatCard+metadata projects");
assert!(
spec.elements.contains_key("metadata_list"),
"metadata DescriptionList must be emitted as a sibling element"
);
let root = spec.elements.get(&spec.root).unwrap();
assert_eq!(root.type_name, "StatCard");
assert!(
!root.children.contains(&"metadata_list".to_string()),
"StatCard root must not claim metadata_list as a child: {:?}",
root.children
);
}
#[test]
fn empty_intents_returns_error() {
let service = sample_service();
let cat = clean_catalog();
let result =
Spec::from_service_def_with_catalog(&service, &[], &VisualContext::default(), &cat);
assert!(matches!(result, Err(ProjectionError::EmptyIntents)));
}
#[test]
fn out_of_bounds_intent_index_returns_error() {
let service = sample_service();
let intents = derive_intents(&service);
let ctx = VisualContext {
base: BaseContext {
intent_index: intents.len() + 5,
..Default::default()
},
..Default::default()
};
let cat = clean_catalog();
let result = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &cat);
match result {
Err(ProjectionError::IntentIndexOutOfBounds {
requested,
available,
}) => {
assert_eq!(requested, intents.len() + 5);
assert_eq!(available, intents.len());
}
other => panic!("expected IntentIndexOutOfBounds, got {other:?}"),
}
}
#[test]
fn kanban_root_derives_columns_from_state_machine() {
use crate::component::KanbanBoardProps;
let service = service_with_state_machine();
let ctx = VisualContext::default();
let el = emit_kanban_root(&service, &ctx);
let built = el.build();
let props: KanbanBoardProps =
serde_json::from_value(built.props).expect("props decode as KanbanBoardProps");
assert_eq!(props.columns.len(), 3);
assert_eq!(props.columns[0].id, "draft");
assert_eq!(props.columns[0].title, "Draft");
assert_eq!(props.columns[1].id, "submitted");
assert_eq!(props.columns[1].title, "Submitted");
assert_eq!(props.columns[2].id, "done");
assert_eq!(props.columns[2].title, "Done");
assert_eq!(props.items_path.as_deref(), Some("/data/order"));
assert_eq!(props.group_by.as_deref(), Some("status"));
assert_eq!(props.card_title_key.as_deref(), Some("id"));
assert!(props.card_description_key.is_none());
}
#[test]
fn kanban_root_fallback_when_no_state_machine() {
use crate::component::KanbanBoardProps;
let service = sample_service(); let ctx = VisualContext::default();
let el = emit_kanban_root(&service, &ctx);
let built = el.build();
let props: KanbanBoardProps =
serde_json::from_value(built.props).expect("props decode as KanbanBoardProps");
assert_eq!(props.columns.len(), 1);
assert!(props.items_path.is_none());
assert!(props.group_by.is_none());
}
#[test]
fn actions_slot_emits_action_group_from_service_actions() {
use crate::component::ActionGroupProps;
let service = service_with_actions();
let mut aux: Vec<(String, ElementBuilder)> = Vec::new();
let mut children: Vec<String> = Vec::new();
emit_actions_placeholder(&service, &mut aux, &mut children);
assert_eq!(children, vec!["actions_menu".to_string()]);
let pos = aux
.iter()
.position(|(id, _)| id == "actions_menu")
.expect("ActionGroup must be emitted");
let (_, el) = aux.remove(pos);
let built = el.build();
let props: ActionGroupProps =
serde_json::from_value(built.props).expect("props decode as ActionGroupProps");
assert_eq!(props.items.len(), service.actions.len());
assert_eq!(props.items[0].label, "View");
}
#[test]
fn datatable_root_has_row_actions_from_service_actions() {
use crate::component::DataTableProps;
let service = service_with_actions();
let el = emit_datatable_root(&service);
let built = el.build();
let props: DataTableProps =
serde_json::from_value(built.props).expect("props decode as DataTableProps");
let ra = props.row_actions.expect("row_actions must be populated");
assert_eq!(ra.len(), service.actions.len());
}
#[test]
fn statcard_root_binds_primary_stat_field() {
use crate::component::StatCardProps;
let service = service_with_money_field();
let mut aux: Vec<(String, ElementBuilder)> = Vec::new();
let el = emit_statcard_root(&service, &[], &mut aux);
let built = el.build();
let props: StatCardProps =
serde_json::from_value(built.props).expect("props decode as StatCardProps");
assert_eq!(
props.value_path.as_deref(),
Some("/data/statistics/total_revenue"),
"value_path must bind to the primary Money field path"
);
}
#[test]
fn statcard_root_empty_when_no_stat_field() {
use crate::component::StatCardProps;
let service = ServiceDef::new("note")
.display_name("Note")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("body", DataType::String, FieldMeaning::FreeText);
let mut aux: Vec<(String, ElementBuilder)> = Vec::new();
let el = emit_statcard_root(&service, &[], &mut aux);
let built = el.build();
let props: StatCardProps =
serde_json::from_value(built.props).expect("props decode as StatCardProps");
assert!(
props.value_path.is_none(),
"value_path must be None when no Money/Quantity field exists"
);
}
#[test]
fn datatable_root_includes_image_url_column() {
use crate::component::DataTableProps;
let service = ServiceDef::new("staff")
.display_name("Staff")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.field("avatar_url", DataType::String, FieldMeaning::ImageUrl);
let el = emit_datatable_root(&service);
let built = el.build();
let props: DataTableProps =
serde_json::from_value(built.props).expect("props decode as DataTableProps");
assert!(
props.columns.iter().any(|c| c.key == "avatar_url"),
"avatar_url column must appear in DataTable columns; got: {:?}",
props.columns.iter().map(|c| &c.key).collect::<Vec<_>>()
);
}
#[test]
fn image_column_has_image_format() {
use crate::component::{ColumnFormat, DataTableProps};
let service = ServiceDef::new("staff")
.display_name("Staff")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("avatar_url", DataType::String, FieldMeaning::ImageUrl);
let el = emit_datatable_root(&service);
let built = el.build();
let props: DataTableProps =
serde_json::from_value(built.props).expect("props decode as DataTableProps");
let col = props
.columns
.iter()
.find(|c| c.key == "avatar_url")
.expect("avatar_url column must exist");
assert_eq!(
col.format,
Some(ColumnFormat::Image),
"ImageUrl column format must be Image"
);
}
fn register_service() -> ServiceDef {
use ferro_projections::{Intent, IntentHint};
ServiceDef::new("shop")
.display_name("Shop")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.field("price", DataType::String, FieldMeaning::Money)
.action(ActionDef::new("confirm").display_name("Confirm"))
.intent_hint(IntentHint::Primary(Intent::Collect))
}
#[test]
fn register_projection_is_catalog_valid() {
let service = register_service();
let intents = derive_intents(&service);
let ctx = VisualContext {
templates: Some(crate::register_template()),
..Default::default()
};
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &clean_catalog())
.expect("register spec must be catalog-valid");
assert!(spec.fill_viewport, "spec.fill_viewport must be true");
assert_eq!(
spec.layout.as_deref(),
Some("dashboard"),
"spec.layout must be dashboard"
);
let root = spec.elements.get(&spec.root).expect("root element exists");
assert_eq!(root.type_name, "Grid", "root must be a Grid");
assert_eq!(
root.props.get("fill").and_then(|v| v.as_bool()),
Some(true),
"root Grid must have fill:true"
);
let each_elements: Vec<_> = spec
.elements
.values()
.filter(|el| el.each.is_some())
.collect();
assert_eq!(each_elements.len(), 1, "exactly one $each element expected");
assert_eq!(
each_elements[0].type_name, "Tile",
"the $each element must be a Tile"
);
let type_names: Vec<&str> = spec
.elements
.values()
.map(|e| e.type_name.as_str())
.collect();
assert!(
type_names.contains(&"TileGrid"),
"spec must contain a TileGrid"
);
assert!(
type_names.contains(&"SelectionPanel"),
"spec must contain a SelectionPanel"
);
assert!(
type_names.contains(&"Form"),
"spec must contain a Form element"
);
}
#[test]
fn register_projection_is_lint_clean() {
let service = register_service();
let intents = derive_intents(&service);
let ctx = VisualContext {
templates: Some(crate::register_template()),
..Default::default()
};
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &clean_catalog())
.expect("register spec must build");
let findings = crate::lint(&spec);
let register_rules = [
"register-fill-viewport",
"register-grid-fill",
"register-selection-present",
"fill-viewport-layout-unknown",
];
let hits: Vec<_> = findings
.iter()
.filter(|f| register_rules.contains(&f.rule))
.collect();
assert!(
hits.is_empty(),
"register spec must be lint-clean for the four register rules, got: {hits:#?}"
);
}
#[test]
fn register_projection_sale_form_carries_fill() {
let service = register_service();
let intents = derive_intents(&service);
let ctx = VisualContext {
templates: Some(crate::register_template()),
..Default::default()
};
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &clean_catalog())
.expect("register spec must be catalog-valid");
let form_el = spec
.elements
.values()
.find(|el| el.type_name == "Form")
.expect("spec must contain a Form element");
assert_eq!(
form_el.props.get("fill").and_then(|v| v.as_bool()),
Some(true),
"the sale_form Form must have fill:true so the height chain propagates to the inner panes Grid"
);
}
#[test]
fn register_projection_no_actions_errors() {
use ferro_projections::{Intent, IntentHint};
let service_no_actions = ServiceDef::new("shop")
.display_name("Shop")
.field("id", DataType::Integer, FieldMeaning::Identifier)
.field("name", DataType::String, FieldMeaning::EntityName)
.field("price", DataType::String, FieldMeaning::Money)
.intent_hint(IntentHint::Primary(Intent::Collect));
let intents = derive_intents(&service_no_actions);
let ctx = VisualContext {
templates: Some(crate::register_template()),
..Default::default()
};
let err = Spec::from_service_def_with_catalog(
&service_no_actions,
&intents,
&ctx,
&clean_catalog(),
)
.expect_err("no-actions register service must error");
assert!(
matches!(err, ProjectionError::RegisterMissingAction { .. }),
"error must be RegisterMissingAction, got: {err:?}"
);
}
#[test]
fn register_projection_fallback_excludes_sensitive_fields() {
use ferro_projections::{Intent, IntentHint};
let service = ServiceDef::new("shop")
.display_name("Shop")
.field("secret", DataType::String, FieldMeaning::Sensitive)
.field("note", DataType::String, FieldMeaning::FreeText)
.action(ActionDef::new("confirm").display_name("Confirm"))
.intent_hint(IntentHint::Primary(Intent::Collect));
let intents = derive_intents(&service);
let ctx = VisualContext {
templates: Some(crate::register_template()),
..Default::default()
};
let spec = Spec::from_service_def_with_catalog(&service, &intents, &ctx, &clean_catalog())
.expect("register spec must build");
let tile = spec
.elements
.values()
.find(|el| el.each.is_some())
.expect("tile template exists");
let props = serde_json::to_string(&tile.props).expect("props serialize");
assert!(
!props.contains("/p/secret"),
"Sensitive field must not be bound into tile props: {props}"
);
assert!(
props.contains("/p/note"),
"fallback must bind the first readable display-eligible field: {props}"
);
}
#[test]
fn register_projection_populated_data_validates() {
let service = register_service();
let intents = derive_intents(&service);
let ctx = VisualContext {
templates: Some(crate::register_template()),
..Default::default()
};
let mut spec =
Spec::from_service_def_with_catalog(&service, &intents, &ctx, &clean_catalog())
.expect("register spec must build");
spec.data = serde_json::json!({
"data": {
"shop": [
{
"id": "1",
"name": "Widget",
"price": "€ 1.20",
"price_cents": 120,
"field": "qty_1"
}
]
}
});
let json = serde_json::to_string(&spec).expect("spec serializes");
let revalidated =
Spec::from_json(&json).expect("populated-data register spec must revalidate");
assert_eq!(revalidated, spec, "round-trip must preserve the spec");
let cat = clean_catalog();
assert!(
cat.validate(&spec).is_ok(),
"register spec with populated data must pass catalog validation"
);
}
}