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}