Skip to main content

rustio_admin/view_layer/
roles.rs

1//! Field roles and badge colour intent — the vocabulary of *visual
2//! importance* the database schema can't express on its own.
3
4use serde::{Deserialize, Serialize};
5
6// public:
7/// How a field participates in the visual layout of a generated admin view.
8///
9/// The schema gives us names and types; it does not tell us what matters
10/// visually. `FieldRole` is where that intent is recorded, once, in a
11/// [`ViewSpec`](super::spec::ViewSpec).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum FieldRole {
15    /// The strongest field. Becomes the row title in list/card modes.
16    Primary,
17    /// Muted supporting information shown next to the primary.
18    Secondary,
19    /// Rendered as a pill/chip, usually an enum or status.
20    Badge,
21    /// A date/time field, formatted consistently and kept visually quiet.
22    Timestamp,
23    /// Only shown on the detail page, never in list/table/card.
24    DetailOnly,
25    /// Never rendered anywhere in the visible UI.
26    Hidden,
27}
28
29impl FieldRole {
30    // public:
31    /// Whether a field with this role should appear in list/table/card views.
32    pub fn shows_in_list(self) -> bool {
33        !matches!(self, FieldRole::DetailOnly | FieldRole::Hidden)
34    }
35
36    // public:
37    /// Whether the field should reach the template context at all. `Hidden`
38    /// fields are stripped before rendering so they never leak into HTML.
39    pub fn reaches_template(self) -> bool {
40        self != FieldRole::Hidden
41    }
42
43    // public:
44    /// The stable slug used in forms and serde, e.g. `detail_only`. Matches
45    /// the `snake_case` serde representation.
46    pub fn slug(self) -> &'static str {
47        match self {
48            FieldRole::Primary => "primary",
49            FieldRole::Secondary => "secondary",
50            FieldRole::Badge => "badge",
51            FieldRole::Timestamp => "timestamp",
52            FieldRole::DetailOnly => "detail_only",
53            FieldRole::Hidden => "hidden",
54        }
55    }
56
57    // public:
58    /// Parse a role slug coming from the designer form. Unknown values return
59    /// `None` so the caller can keep the field's previous role.
60    pub fn from_slug(slug: &str) -> Option<Self> {
61        match slug {
62            "primary" => Some(FieldRole::Primary),
63            "secondary" => Some(FieldRole::Secondary),
64            "badge" => Some(FieldRole::Badge),
65            "timestamp" => Some(FieldRole::Timestamp),
66            "detail_only" => Some(FieldRole::DetailOnly),
67            "hidden" => Some(FieldRole::Hidden),
68            _ => None,
69        }
70    }
71
72    // public:
73    /// Every role and its slug, in display order — for building the role
74    /// `<select>` in the designer without hard-coding the list in a template.
75    pub fn all() -> &'static [FieldRole] {
76        &[
77            FieldRole::Primary,
78            FieldRole::Secondary,
79            FieldRole::Badge,
80            FieldRole::Timestamp,
81            FieldRole::DetailOnly,
82            FieldRole::Hidden,
83        ]
84    }
85}
86
87// public:
88/// Semantic colour intent for badge fields. Kept deliberately small so
89/// templates can map these to a fixed set of CSS classes.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum SemanticClass {
93    /// No colour intent — the default grey pill.
94    #[default]
95    Neutral,
96    /// Informational (blue) intent.
97    Info,
98    /// Positive / success (green) intent.
99    Success,
100    /// Caution (amber) intent.
101    Warning,
102    /// Negative / destructive (red) intent.
103    Danger,
104}
105
106impl SemanticClass {
107    // public:
108    /// The CSS modifier suffix templates use, e.g. `badge--success`.
109    pub fn css_suffix(self) -> &'static str {
110        match self {
111            SemanticClass::Neutral => "neutral",
112            SemanticClass::Info => "info",
113            SemanticClass::Success => "success",
114            SemanticClass::Warning => "warning",
115            SemanticClass::Danger => "danger",
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn role_slug_roundtrips_for_every_variant() {
126        for role in FieldRole::all() {
127            assert_eq!(FieldRole::from_slug(role.slug()), Some(*role));
128        }
129    }
130
131    #[test]
132    fn role_slug_matches_serde_repr() {
133        // slug() must equal the snake_case serde tag the renderer/spec use.
134        for role in FieldRole::all() {
135            let json = serde_json::to_string(role).unwrap();
136            assert_eq!(json, format!("\"{}\"", role.slug()));
137        }
138    }
139
140    #[test]
141    fn unknown_role_slug_is_none() {
142        assert_eq!(FieldRole::from_slug("nope"), None);
143    }
144}