Skip to main content

rustio_admin/view_layer/
spec.rs

1//! The saved `ViewSpec` — the single source of truth at render time.
2//!
3//! Once a `ViewSpec` exists, rendering reads it and nothing else: no
4//! inference, no AI, no guessing. Inference ([`super::infer`]) and the future
5//! view designer only ever *produce* one of these.
6
7use serde::{Deserialize, Serialize};
8
9use super::compose::CellComposition;
10use super::modes::ViewMode;
11use super::roles::{FieldRole, SemanticClass};
12
13// public:
14/// Current `ViewSpec` schema version. Bumped when the on-disk shape changes so
15/// older saved specs can be migrated rather than silently misread.
16pub const VIEW_SPEC_VERSION: u32 = 1;
17
18// public:
19/// Per-field display configuration. One of these exists for every column the
20/// admin knows about, even hidden ones (we still need to know to hide them).
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct FieldViewSpec {
23    /// The schema column this spec governs.
24    pub field_name: String,
25    /// Human label shown in headers. Falls back to a humanized `field_name`
26    /// at the template level when absent.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub label: Option<String>,
29    /// The field's visual role.
30    pub role: FieldRole,
31    /// Lower numbers render first. Ties break on declaration order.
32    #[serde(default)]
33    pub priority: i32,
34    /// Whether the column offers a sort control.
35    #[serde(default)]
36    pub sortable: bool,
37    /// Whether the column offers a filter control.
38    #[serde(default)]
39    pub filterable: bool,
40    /// If true and `filterable`, this filter is shown without the user having
41    /// to open an "advanced filters" panel.
42    #[serde(default)]
43    pub default_filter: bool,
44    /// Optional width hint for table mode, e.g. `"120px"` or `"20%"`.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub width: Option<String>,
47    /// Badge colour intent. Only meaningful when `role == Badge`.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub semantic_class: Option<SemanticClass>,
50}
51
52impl FieldViewSpec {
53    // public:
54    /// Convenience constructor for the common "just a name and a role" case.
55    pub fn new(field_name: impl Into<String>, role: FieldRole) -> Self {
56        FieldViewSpec {
57            field_name: field_name.into(),
58            label: None,
59            role,
60            priority: 0,
61            sortable: false,
62            filterable: false,
63            default_filter: false,
64            width: None,
65            semantic_class: None,
66        }
67    }
68}
69
70// public:
71/// The full visual contract for one model. This is the single source of truth
72/// at render time — no inference, no AI, no guessing happens once a `ViewSpec`
73/// exists. Inference and the (future) designer only ever *produce* one of these.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct ViewSpec {
76    /// The model (admin name) this spec describes.
77    pub model: String,
78    /// The mode rendered when no `?view=` slug is supplied.
79    pub default_mode: ViewMode,
80    /// Modes offered in the switcher. Should always contain `default_mode`.
81    pub allowed_modes: Vec<ViewMode>,
82    /// Per-field display configuration, in declaration order.
83    pub fields: Vec<FieldViewSpec>,
84    /// Composed cells that merge several fields into one visual unit.
85    #[serde(default)]
86    pub compositions: Vec<CellComposition>,
87    /// Field names whose filters are open by default. Redundant with the
88    /// per-field flag but convenient for the designer to reorder.
89    #[serde(default)]
90    pub default_filters: Vec<String>,
91    /// Schema version of this saved spec; see [`VIEW_SPEC_VERSION`].
92    #[serde(default = "default_version")]
93    pub version: u32,
94}
95
96fn default_version() -> u32 {
97    VIEW_SPEC_VERSION
98}
99
100impl ViewSpec {
101    // public:
102    /// Fields that should appear in list/table/card layouts, sorted by
103    /// priority then declaration order. `Hidden` and `DetailOnly` are dropped.
104    pub fn list_fields(&self) -> Vec<&FieldViewSpec> {
105        let mut visible: Vec<&FieldViewSpec> = self
106            .fields
107            .iter()
108            .filter(|f| f.role.shows_in_list())
109            .collect();
110        // stable sort keeps declaration order for equal priorities
111        visible.sort_by_key(|f| f.priority);
112        visible
113    }
114
115    // public:
116    /// The primary field, if one is declared. Used as the row/card title.
117    pub fn primary_field(&self) -> Option<&FieldViewSpec> {
118        self.fields.iter().find(|f| f.role == FieldRole::Primary)
119    }
120
121    // public:
122    /// Field names that must never reach a template (sensitive/hidden). The
123    /// renderer uses this to strip values before building any context.
124    pub fn redacted_fields(&self) -> Vec<&str> {
125        self.fields
126            .iter()
127            .filter(|f| !f.role.reaches_template())
128            .map(|f| f.field_name.as_str())
129            .collect()
130    }
131
132    // public:
133    /// Resolve the active mode from an optional query slug, falling back to
134    /// the default and rejecting modes not in `allowed_modes`.
135    pub fn resolve_mode(&self, requested: Option<&str>) -> ViewMode {
136        let wanted = requested.and_then(ViewMode::from_slug);
137        match wanted {
138            Some(mode) if self.allowed_modes.contains(&mode) => mode,
139            _ => self.default_mode,
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn sample_spec() -> ViewSpec {
149        ViewSpec {
150            model: "customer".into(),
151            default_mode: ViewMode::List,
152            allowed_modes: vec![ViewMode::List, ViewMode::Table, ViewMode::Cards],
153            fields: vec![
154                FieldViewSpec::new("full_name", FieldRole::Primary),
155                {
156                    let mut f = FieldViewSpec::new("status", FieldRole::Badge);
157                    f.semantic_class = Some(SemanticClass::Success);
158                    f.filterable = true;
159                    f.default_filter = true;
160                    f
161                },
162                FieldViewSpec::new("password_hash", FieldRole::Hidden),
163            ],
164            compositions: vec![],
165            default_filters: vec!["status".into()],
166            version: VIEW_SPEC_VERSION,
167        }
168    }
169
170    #[test]
171    fn roundtrips_through_json() {
172        let spec = sample_spec();
173        let json = serde_json::to_string(&spec).unwrap();
174        let back: ViewSpec = serde_json::from_str(&json).unwrap();
175        assert_eq!(spec, back);
176    }
177
178    #[test]
179    fn enums_serialize_as_snake_case() {
180        let json = serde_json::to_string(&sample_spec()).unwrap();
181        assert!(json.contains("\"primary\""));
182        assert!(json.contains("\"badge\""));
183        assert!(json.contains("\"list\""));
184        assert!(json.contains("\"success\""));
185    }
186
187    #[test]
188    fn version_defaults_when_missing() {
189        // an older spec saved without a version field should still load
190        let json = r#"{
191            "model": "thing",
192            "default_mode": "table",
193            "allowed_modes": ["table"],
194            "fields": []
195        }"#;
196        let spec: ViewSpec = serde_json::from_str(json).unwrap();
197        assert_eq!(spec.version, VIEW_SPEC_VERSION);
198    }
199
200    #[test]
201    fn list_fields_skip_hidden_and_sort_by_priority() {
202        let mut spec = sample_spec();
203        spec.fields[0].priority = 10;
204        spec.fields[1].priority = 1;
205        let listed: Vec<&str> = spec
206            .list_fields()
207            .iter()
208            .map(|f| f.field_name.as_str())
209            .collect();
210        assert_eq!(listed, vec!["status", "full_name"]);
211        assert!(!listed.contains(&"password_hash"));
212    }
213
214    #[test]
215    fn resolve_mode_rejects_disallowed() {
216        let spec = sample_spec();
217        // compact is not allowed -> fall back to default (list)
218        assert_eq!(spec.resolve_mode(Some("compact")), ViewMode::List);
219        assert_eq!(spec.resolve_mode(Some("cards")), ViewMode::Cards);
220        assert_eq!(spec.resolve_mode(None), ViewMode::List);
221    }
222}