use ferro_projections::render::field_display_name;
use ferro_projections::{FieldDef, FieldMeaning, NavigationHint, RelationshipDef};
use crate::component::{
AvatarProps, BadgeProps, BadgeVariant, ButtonProps, ButtonVariant, Column, ColumnFormat,
DescriptionItem, InputProps, InputType, ProgressProps, SelectOption, SelectProps, SwitchProps,
TextElement, TextProps,
};
#[derive(Debug, Clone, Copy)]
pub struct ComponentChoice {
pub display: Option<&'static str>,
pub input: Option<&'static str>,
pub column: Option<()>,
}
const FALLBACK: ComponentChoice = ComponentChoice {
display: Some("Text"),
input: Some("Input"),
column: Some(()),
};
pub fn lookup_meaning(meaning: &FieldMeaning) -> ComponentChoice {
match meaning {
FieldMeaning::Identifier => ComponentChoice {
display: Some("Text"),
input: Some("Input"),
column: None,
},
FieldMeaning::ForeignKey => ComponentChoice {
display: None,
input: Some("Select"),
column: None,
},
FieldMeaning::EntityName => ComponentChoice {
display: Some("Text"),
input: Some("Input"),
column: Some(()),
},
FieldMeaning::Email => ComponentChoice {
display: Some("Text"),
input: Some("Input"),
column: Some(()),
},
FieldMeaning::Phone => ComponentChoice {
display: Some("Text"),
input: Some("Input"),
column: Some(()),
},
FieldMeaning::Url => ComponentChoice {
display: Some("Text"),
input: Some("Input"),
column: Some(()),
},
FieldMeaning::ImageUrl => ComponentChoice {
display: Some("Avatar"),
input: Some("Input"),
column: None,
},
FieldMeaning::Money => ComponentChoice {
display: Some("Text"),
input: Some("Input"),
column: Some(()),
},
FieldMeaning::Percentage => ComponentChoice {
display: Some("Progress"),
input: Some("Input"),
column: Some(()),
},
FieldMeaning::Quantity => ComponentChoice {
display: Some("Text"),
input: Some("Input"),
column: Some(()),
},
FieldMeaning::Status => ComponentChoice {
display: Some("Badge"),
input: Some("Select"),
column: Some(()),
},
FieldMeaning::Category => ComponentChoice {
display: Some("Badge"),
input: Some("Select"),
column: Some(()),
},
FieldMeaning::Boolean => ComponentChoice {
display: Some("Badge"),
input: Some("Switch"),
column: Some(()),
},
FieldMeaning::FreeText => ComponentChoice {
display: Some("Text"),
input: Some("Input"),
column: Some(()),
},
FieldMeaning::CreatedAt => ComponentChoice {
display: Some("Text"),
input: Some("Input"),
column: Some(()),
},
FieldMeaning::UpdatedAt => ComponentChoice {
display: Some("Text"),
input: Some("Input"),
column: Some(()),
},
FieldMeaning::DateTime => ComponentChoice {
display: Some("Text"),
input: Some("Input"),
column: Some(()),
},
FieldMeaning::Sensitive => ComponentChoice {
display: None,
input: Some("Input"),
column: None,
},
FieldMeaning::Custom(_) => FALLBACK,
}
}
fn input_type_for(meaning: &FieldMeaning) -> InputType {
match meaning {
FieldMeaning::Email => InputType::Email,
FieldMeaning::Phone => InputType::Tel,
FieldMeaning::Url | FieldMeaning::ImageUrl => InputType::Url,
FieldMeaning::Sensitive => InputType::Password,
FieldMeaning::Money | FieldMeaning::Percentage | FieldMeaning::Quantity => {
InputType::Number
}
FieldMeaning::FreeText => InputType::Textarea,
_ => InputType::Text,
}
}
fn badge_variant_for(meaning: &FieldMeaning) -> BadgeVariant {
match meaning {
FieldMeaning::Status => BadgeVariant::Default,
FieldMeaning::Category => BadgeVariant::Secondary,
FieldMeaning::Boolean => BadgeVariant::Outline,
_ => BadgeVariant::Default,
}
}
pub fn build_text_props(_field: &FieldDef) -> serde_json::Value {
serde_json::to_value(TextProps {
content: String::new(),
element: TextElement::Span,
})
.expect("TextProps serialization cannot fail")
}
pub fn build_badge_props(field: &FieldDef) -> serde_json::Value {
serde_json::to_value(BadgeProps {
label: field_display_name(&field.name),
variant: badge_variant_for(&field.meaning),
})
.expect("BadgeProps serialization cannot fail")
}
pub fn build_avatar_props(field: &FieldDef) -> serde_json::Value {
serde_json::to_value(AvatarProps {
src: None,
alt: field_display_name(&field.name),
fallback: None,
size: None,
})
.expect("AvatarProps serialization cannot fail")
}
pub fn build_progress_props(_field: &FieldDef) -> serde_json::Value {
serde_json::to_value(ProgressProps {
value: 0,
max: None,
label: None,
})
.expect("ProgressProps serialization cannot fail")
}
pub fn build_input_props(field: &FieldDef) -> serde_json::Value {
let input_type = input_type_for(&field.meaning);
let is_sensitive = matches!(field.meaning, FieldMeaning::Sensitive);
let data_path = if is_sensitive {
None
} else {
Some(format!("/data/{}", field.name))
};
serde_json::to_value(InputProps {
field: field.name.clone(),
label: field_display_name(&field.name),
input_type,
placeholder: None,
required: Some(field.required),
disabled: None,
error: None,
description: None,
default_value: None,
data_path,
step: None,
list: None,
accept: None,
})
.expect("InputProps serialization cannot fail")
}
pub fn build_select_props(field: &FieldDef) -> serde_json::Value {
serde_json::to_value(SelectProps {
field: field.name.clone(),
label: field_display_name(&field.name),
options: Vec::<SelectOption>::new(),
placeholder: None,
required: Some(field.required),
disabled: None,
error: None,
description: None,
default_value: None,
data_path: Some(format!("/data/{}", field.name)),
})
.expect("SelectProps serialization cannot fail")
}
pub fn build_switch_props(field: &FieldDef) -> serde_json::Value {
serde_json::to_value(SwitchProps {
field: field.name.clone(),
label: field_display_name(&field.name),
description: None,
checked: None,
data_path: Some(format!("/data/{}", field.name)),
required: Some(field.required),
disabled: None,
error: None,
action: None,
compact: None,
})
.expect("SwitchProps serialization cannot fail")
}
pub fn build_column_for_field(field: &FieldDef) -> Column {
let format = match &field.meaning {
FieldMeaning::Money => Some(ColumnFormat::Currency),
FieldMeaning::CreatedAt | FieldMeaning::UpdatedAt | FieldMeaning::DateTime => {
Some(ColumnFormat::DateTime)
}
FieldMeaning::Boolean => Some(ColumnFormat::Boolean),
_ => None,
};
Column {
key: field.name.clone(),
label: field_display_name(&field.name),
format,
}
}
pub fn build_description_item(field: &FieldDef) -> DescriptionItem {
let format = match &field.meaning {
FieldMeaning::Money => Some(ColumnFormat::Currency),
FieldMeaning::CreatedAt | FieldMeaning::UpdatedAt | FieldMeaning::DateTime => {
Some(ColumnFormat::DateTime)
}
FieldMeaning::Boolean => Some(ColumnFormat::Boolean),
_ => None,
};
DescriptionItem {
label: field_display_name(&field.name),
value: String::new(),
format,
}
}
pub static RELATIONSHIP_COMPONENT_TABLE: &[(NavigationHint, Option<&'static str>)] = &[
(NavigationHint::Inline, Some("Text")),
(NavigationHint::Link, Some("Button")),
(NavigationHint::Tab, Some("Tabs")),
(NavigationHint::Nested, Some("Table")),
(NavigationHint::Hidden, None),
];
pub fn lookup_relationship(hint: NavigationHint) -> Option<&'static str> {
RELATIONSHIP_COMPONENT_TABLE
.iter()
.find(|(h, _)| *h == hint)
.and_then(|(_, name)| *name)
}
pub fn build_relationship_text_props(_rel: &RelationshipDef) -> serde_json::Value {
serde_json::to_value(TextProps {
content: String::new(),
element: TextElement::Span,
})
.expect("TextProps serialization cannot fail")
}
pub fn build_relationship_button_props(rel: &RelationshipDef) -> serde_json::Value {
serde_json::to_value(ButtonProps {
label: format!("{} \u{2192}", field_display_name(&rel.target)),
variant: ButtonVariant::Link,
size: crate::component::Size::default(),
disabled: None,
icon: None,
icon_position: None,
button_type: None,
form: None,
})
.expect("ButtonProps serialization cannot fail")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::Catalog;
#[test]
fn meaning_table_components_exist_in_catalog() {
let cat = Catalog::build_builtins_only().expect("build builtins");
let all_meanings = [
FieldMeaning::Identifier,
FieldMeaning::ForeignKey,
FieldMeaning::EntityName,
FieldMeaning::Email,
FieldMeaning::Phone,
FieldMeaning::Url,
FieldMeaning::ImageUrl,
FieldMeaning::Money,
FieldMeaning::Percentage,
FieldMeaning::Quantity,
FieldMeaning::Status,
FieldMeaning::Category,
FieldMeaning::Boolean,
FieldMeaning::FreeText,
FieldMeaning::CreatedAt,
FieldMeaning::UpdatedAt,
FieldMeaning::DateTime,
FieldMeaning::Sensitive,
];
for meaning in &all_meanings {
let choice = lookup_meaning(meaning);
for name in [choice.display, choice.input].into_iter().flatten() {
assert!(
cat.components.contains_key(name),
"MEANING_COMPONENT_TABLE references unknown component '{name}' \
for meaning {meaning:?}"
);
}
}
for (hint, name_opt) in RELATIONSHIP_COMPONENT_TABLE {
if let Some(name) = name_opt {
assert!(
cat.components.contains_key(*name),
"RELATIONSHIP_COMPONENT_TABLE references unknown component '{name}' \
for hint {hint:?}"
);
}
}
}
#[test]
fn custom_meaning_fallback_uses_catalog_components() {
let cat = Catalog::build_builtins_only().expect("build builtins");
let choice = lookup_meaning(&FieldMeaning::Custom("anything".into()));
assert_eq!(choice.display, Some("Text"));
assert_eq!(choice.input, Some("Input"));
assert!(cat.components.contains_key("Text"));
assert!(cat.components.contains_key("Input"));
}
}