use serde::{Deserialize, Serialize};
use super::compose::CellComposition;
use super::modes::ViewMode;
use super::roles::{FieldRole, SemanticClass};
pub const VIEW_SPEC_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FieldViewSpec {
pub field_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub role: FieldRole,
#[serde(default)]
pub priority: i32,
#[serde(default)]
pub sortable: bool,
#[serde(default)]
pub filterable: bool,
#[serde(default)]
pub default_filter: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub semantic_class: Option<SemanticClass>,
}
impl FieldViewSpec {
pub fn new(field_name: impl Into<String>, role: FieldRole) -> Self {
FieldViewSpec {
field_name: field_name.into(),
label: None,
role,
priority: 0,
sortable: false,
filterable: false,
default_filter: false,
width: None,
semantic_class: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ViewSpec {
pub model: String,
pub default_mode: ViewMode,
pub allowed_modes: Vec<ViewMode>,
pub fields: Vec<FieldViewSpec>,
#[serde(default)]
pub compositions: Vec<CellComposition>,
#[serde(default)]
pub default_filters: Vec<String>,
#[serde(default = "default_version")]
pub version: u32,
}
fn default_version() -> u32 {
VIEW_SPEC_VERSION
}
impl ViewSpec {
pub fn list_fields(&self) -> Vec<&FieldViewSpec> {
let mut visible: Vec<&FieldViewSpec> = self
.fields
.iter()
.filter(|f| f.role.shows_in_list())
.collect();
visible.sort_by_key(|f| f.priority);
visible
}
pub fn primary_field(&self) -> Option<&FieldViewSpec> {
self.fields.iter().find(|f| f.role == FieldRole::Primary)
}
pub fn redacted_fields(&self) -> Vec<&str> {
self.fields
.iter()
.filter(|f| !f.role.reaches_template())
.map(|f| f.field_name.as_str())
.collect()
}
pub fn resolve_mode(&self, requested: Option<&str>) -> ViewMode {
let wanted = requested.and_then(ViewMode::from_slug);
match wanted {
Some(mode) if self.allowed_modes.contains(&mode) => mode,
_ => self.default_mode,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_spec() -> ViewSpec {
ViewSpec {
model: "customer".into(),
default_mode: ViewMode::List,
allowed_modes: vec![ViewMode::List, ViewMode::Table, ViewMode::Cards],
fields: vec![
FieldViewSpec::new("full_name", FieldRole::Primary),
{
let mut f = FieldViewSpec::new("status", FieldRole::Badge);
f.semantic_class = Some(SemanticClass::Success);
f.filterable = true;
f.default_filter = true;
f
},
FieldViewSpec::new("password_hash", FieldRole::Hidden),
],
compositions: vec![],
default_filters: vec!["status".into()],
version: VIEW_SPEC_VERSION,
}
}
#[test]
fn roundtrips_through_json() {
let spec = sample_spec();
let json = serde_json::to_string(&spec).unwrap();
let back: ViewSpec = serde_json::from_str(&json).unwrap();
assert_eq!(spec, back);
}
#[test]
fn enums_serialize_as_snake_case() {
let json = serde_json::to_string(&sample_spec()).unwrap();
assert!(json.contains("\"primary\""));
assert!(json.contains("\"badge\""));
assert!(json.contains("\"list\""));
assert!(json.contains("\"success\""));
}
#[test]
fn version_defaults_when_missing() {
let json = r#"{
"model": "thing",
"default_mode": "table",
"allowed_modes": ["table"],
"fields": []
}"#;
let spec: ViewSpec = serde_json::from_str(json).unwrap();
assert_eq!(spec.version, VIEW_SPEC_VERSION);
}
#[test]
fn list_fields_skip_hidden_and_sort_by_priority() {
let mut spec = sample_spec();
spec.fields[0].priority = 10;
spec.fields[1].priority = 1;
let listed: Vec<&str> = spec
.list_fields()
.iter()
.map(|f| f.field_name.as_str())
.collect();
assert_eq!(listed, vec!["status", "full_name"]);
assert!(!listed.contains(&"password_hash"));
}
#[test]
fn resolve_mode_rejects_disallowed() {
let spec = sample_spec();
assert_eq!(spec.resolve_mode(Some("compact")), ViewMode::List);
assert_eq!(spec.resolve_mode(Some("cards")), ViewMode::Cards);
assert_eq!(spec.resolve_mode(None), ViewMode::List);
}
}