armature_admin/
views.rs

1//! View structures for admin pages
2
3use crate::{
4    field::FieldDefinition,
5    model::ModelDefinition,
6    ui::{Breadcrumb, FilterDef, Pagination, TableColumn, TableRow},
7    ListParams,
8};
9use serde::{Deserialize, Serialize};
10
11/// List view for a model
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ListView {
14    /// Model name
15    pub model_name: String,
16    /// Verbose name (plural)
17    pub verbose_name: String,
18    /// Page title
19    pub title: String,
20    /// Breadcrumbs
21    pub breadcrumbs: Vec<Breadcrumb>,
22    /// Table columns
23    pub columns: Vec<TableColumn>,
24    /// Table rows
25    pub rows: Vec<TableRow>,
26    /// Pagination
27    pub pagination: Pagination,
28    /// Filters
29    pub filters: Vec<FilterDef>,
30    /// Current search query
31    pub search_query: Option<String>,
32    /// Can add new records?
33    pub can_add: bool,
34    /// Can delete records?
35    pub can_delete: bool,
36    /// Can export?
37    pub can_export: bool,
38    /// Add URL
39    pub add_url: String,
40    /// Search placeholder
41    pub search_placeholder: String,
42    /// Has search enabled?
43    pub has_search: bool,
44    /// Has filters?
45    pub has_filters: bool,
46}
47
48impl ListView {
49    /// Create a new list view
50    pub fn new(model: &ModelDefinition, params: ListParams) -> Self {
51        let columns = model
52            .display_fields()
53            .iter()
54            .map(|f| TableColumn {
55                field: f.name.clone(),
56                label: f.label.clone(),
57                sortable: f.sortable,
58                sort_direction: if params.sort.as_deref() == Some(&f.name) {
59                    Some(match params.order {
60                        Some(crate::SortOrder::Desc) => crate::ui::SortDirection::Desc,
61                        _ => crate::ui::SortDirection::Asc,
62                    })
63                } else {
64                    None
65                },
66                css_class: None,
67                width: None,
68            })
69            .collect();
70
71        let filters = model
72            .filterable_fields()
73            .iter()
74            .map(|f| FilterDef {
75                field: f.name.clone(),
76                label: f.label.clone(),
77                filter_type: match f.field_type {
78                    crate::field::FieldType::Boolean => crate::ui::FilterType::Boolean,
79                    crate::field::FieldType::Enum => crate::ui::FilterType::Select,
80                    crate::field::FieldType::Date | crate::field::FieldType::DateTime => {
81                        crate::ui::FilterType::DateRange
82                    }
83                    _ => crate::ui::FilterType::Text,
84                },
85                choices: f
86                    .choices
87                    .as_ref()
88                    .map(|choices| {
89                        choices
90                            .iter()
91                            .map(|c| crate::ui::FilterChoice {
92                                value: c.value.clone(),
93                                label: c.label.clone(),
94                                count: None,
95                            })
96                            .collect()
97                    })
98                    .unwrap_or_default(),
99                current: params.filters.get(&f.name).cloned(),
100            })
101            .collect();
102
103        Self {
104            model_name: model.name.clone(),
105            verbose_name: model.verbose_name.clone(),
106            title: model.verbose_name.clone(),
107            breadcrumbs: vec![
108                Breadcrumb::new("Dashboard").url("/admin"),
109                Breadcrumb::new(&model.verbose_name),
110            ],
111            columns,
112            rows: Vec::new(), // Would be populated from database
113            pagination: Pagination::new(params.page(), 25, 0),
114            filters,
115            search_query: params.search,
116            can_add: model.can_add,
117            can_delete: model.can_delete,
118            can_export: model.can_export,
119            add_url: format!("/admin/{}/add", model.name),
120            search_placeholder: format!(
121                "Search {}...",
122                model.search_fields.join(", ")
123            ),
124            has_search: !model.search_fields.is_empty(),
125            has_filters: !model.list_filter.is_empty(),
126        }
127    }
128
129    /// Set rows (from database query)
130    pub fn with_rows(mut self, rows: Vec<TableRow>, total: usize) -> Self {
131        self.rows = rows;
132        self.pagination = Pagination::new(
133            self.pagination.page,
134            self.pagination.per_page,
135            total,
136        );
137        self
138    }
139}
140
141/// Detail view for a model record
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct DetailView {
144    /// Model name
145    pub model_name: String,
146    /// Verbose name (singular)
147    pub verbose_name: String,
148    /// Page title
149    pub title: String,
150    /// Breadcrumbs
151    pub breadcrumbs: Vec<Breadcrumb>,
152    /// Record ID
153    pub id: String,
154    /// Field values
155    pub fields: Vec<FieldValue>,
156    /// Fieldsets
157    pub fieldsets: Vec<ViewFieldset>,
158    /// Can edit?
159    pub can_edit: bool,
160    /// Can delete?
161    pub can_delete: bool,
162    /// Edit URL
163    pub edit_url: String,
164    /// Delete URL
165    pub delete_url: String,
166    /// List URL
167    pub list_url: String,
168    /// Inlines (related data)
169    pub inlines: Vec<InlineView>,
170}
171
172impl DetailView {
173    /// Create a new detail view
174    pub fn new(model: &ModelDefinition, id: String) -> Self {
175        Self {
176            model_name: model.name.clone(),
177            verbose_name: model.verbose_name_singular.clone(),
178            title: format!("{} #{}", model.verbose_name_singular, id),
179            breadcrumbs: vec![
180                Breadcrumb::new("Dashboard").url("/admin"),
181                Breadcrumb::new(&model.verbose_name).url(&format!("/admin/{}", model.name)),
182                Breadcrumb::new(&id),
183            ],
184            id: id.clone(),
185            fields: Vec::new(), // Would be populated from database
186            fieldsets: Vec::new(),
187            can_edit: model.can_edit,
188            can_delete: model.can_delete,
189            edit_url: format!("/admin/{}/{}/edit", model.name, id),
190            delete_url: format!("/admin/{}/{}/delete", model.name, id),
191            list_url: format!("/admin/{}", model.name),
192            inlines: Vec::new(),
193        }
194    }
195
196    /// Set field values
197    pub fn with_data(mut self, data: serde_json::Value) -> Self {
198        if let Some(obj) = data.as_object() {
199            self.fields = obj
200                .iter()
201                .map(|(k, v)| FieldValue {
202                    name: k.clone(),
203                    label: k.replace('_', " "),
204                    value: v.clone(),
205                    rendered: render_value(v),
206                    readonly: false,
207                })
208                .collect();
209        }
210        self
211    }
212}
213
214/// Create view for adding a new record
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct CreateView {
217    /// Model name
218    pub model_name: String,
219    /// Verbose name (singular)
220    pub verbose_name: String,
221    /// Page title
222    pub title: String,
223    /// Breadcrumbs
224    pub breadcrumbs: Vec<Breadcrumb>,
225    /// Form fields
226    pub fields: Vec<FormField>,
227    /// Fieldsets
228    pub fieldsets: Vec<ViewFieldset>,
229    /// Submit URL
230    pub submit_url: String,
231    /// Cancel URL
232    pub cancel_url: String,
233    /// Inlines
234    pub inlines: Vec<InlineView>,
235}
236
237impl CreateView {
238    /// Create a new create view
239    pub fn new(model: &ModelDefinition) -> Self {
240        let fields = model
241            .form_fields()
242            .iter()
243            .map(|f| FormField::from_definition(f))
244            .collect();
245
246        Self {
247            model_name: model.name.clone(),
248            verbose_name: model.verbose_name_singular.clone(),
249            title: format!("Add {}", model.verbose_name_singular),
250            breadcrumbs: vec![
251                Breadcrumb::new("Dashboard").url("/admin"),
252                Breadcrumb::new(&model.verbose_name).url(&format!("/admin/{}", model.name)),
253                Breadcrumb::new("Add"),
254            ],
255            fields,
256            fieldsets: Vec::new(),
257            submit_url: format!("/admin/{}/add", model.name),
258            cancel_url: format!("/admin/{}", model.name),
259            inlines: Vec::new(),
260        }
261    }
262}
263
264/// Edit view for modifying a record
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct EditView {
267    /// Model name
268    pub model_name: String,
269    /// Verbose name
270    pub verbose_name: String,
271    /// Page title
272    pub title: String,
273    /// Breadcrumbs
274    pub breadcrumbs: Vec<Breadcrumb>,
275    /// Record ID
276    pub id: String,
277    /// Form fields
278    pub fields: Vec<FormField>,
279    /// Fieldsets
280    pub fieldsets: Vec<ViewFieldset>,
281    /// Submit URL
282    pub submit_url: String,
283    /// Cancel URL
284    pub cancel_url: String,
285    /// Delete URL
286    pub delete_url: String,
287    /// Can delete?
288    pub can_delete: bool,
289    /// Inlines
290    pub inlines: Vec<InlineView>,
291}
292
293/// Field value for display
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct FieldValue {
296    /// Field name
297    pub name: String,
298    /// Display label
299    pub label: String,
300    /// Raw value
301    pub value: serde_json::Value,
302    /// Rendered HTML
303    pub rendered: String,
304    /// Is readonly?
305    pub readonly: bool,
306}
307
308/// Form field for editing
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct FormField {
311    /// Field name
312    pub name: String,
313    /// Display label
314    pub label: String,
315    /// Widget type
316    pub widget: String,
317    /// Current value
318    pub value: serde_json::Value,
319    /// Is required?
320    pub required: bool,
321    /// Is readonly?
322    pub readonly: bool,
323    /// Help text
324    pub help_text: Option<String>,
325    /// Placeholder
326    pub placeholder: Option<String>,
327    /// Choices (for select)
328    pub choices: Option<Vec<(String, String)>>,
329    /// Validation errors
330    pub errors: Vec<String>,
331    /// HTML attributes
332    pub attrs: std::collections::HashMap<String, String>,
333}
334
335impl FormField {
336    /// Create from field definition
337    pub fn from_definition(field: &FieldDefinition) -> Self {
338        let mut attrs = std::collections::HashMap::new();
339
340        if let Some(max_len) = field.max_length {
341            attrs.insert("maxlength".to_string(), max_len.to_string());
342        }
343        if let Some(min) = field.min_value {
344            attrs.insert("min".to_string(), min.to_string());
345        }
346        if let Some(max) = field.max_value {
347            attrs.insert("max".to_string(), max.to_string());
348        }
349
350        Self {
351            name: field.name.clone(),
352            label: field.label.clone(),
353            widget: format!("{:?}", field.widget).to_lowercase(),
354            value: serde_json::Value::Null,
355            required: field.required,
356            readonly: field.readonly,
357            help_text: field.help_text.clone(),
358            placeholder: field.placeholder.clone(),
359            choices: field.choices.as_ref().map(|c| {
360                c.iter().map(|ch| (ch.value.clone(), ch.label.clone())).collect()
361            }),
362            errors: Vec::new(),
363            attrs,
364        }
365    }
366
367    /// Set value
368    pub fn with_value(mut self, value: serde_json::Value) -> Self {
369        self.value = value;
370        self
371    }
372
373    /// Add error
374    pub fn add_error(&mut self, error: impl Into<String>) {
375        self.errors.push(error.into());
376    }
377}
378
379/// Fieldset for organizing form fields
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct ViewFieldset {
382    /// Fieldset name
383    pub name: Option<String>,
384    /// Description
385    pub description: Option<String>,
386    /// Fields in this fieldset
387    pub fields: Vec<String>,
388    /// Is collapsible?
389    pub collapsible: bool,
390    /// Is collapsed?
391    pub collapsed: bool,
392}
393
394/// Inline view for related data
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct InlineView {
397    /// Model name
398    pub model_name: String,
399    /// Verbose name
400    pub verbose_name: String,
401    /// Rows
402    pub rows: Vec<InlineRow>,
403    /// Extra empty rows
404    pub extra: usize,
405    /// Can delete?
406    pub can_delete: bool,
407    /// Fields to display
408    pub fields: Vec<String>,
409}
410
411/// Inline row
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct InlineRow {
414    /// Row ID (if existing)
415    pub id: Option<String>,
416    /// Field values
417    pub fields: Vec<FormField>,
418    /// Is new?
419    pub is_new: bool,
420    /// Delete marker
421    pub delete: bool,
422}
423
424/// Render a value for display
425fn render_value(value: &serde_json::Value) -> String {
426    match value {
427        serde_json::Value::Null => "—".to_string(),
428        serde_json::Value::Bool(b) => {
429            if *b {
430                r#"<span class="badge badge-success">Yes</span>"#.to_string()
431            } else {
432                r#"<span class="badge badge-error">No</span>"#.to_string()
433            }
434        }
435        serde_json::Value::Number(n) => n.to_string(),
436        serde_json::Value::String(s) => html_escape(s),
437        serde_json::Value::Array(arr) => {
438            format!("[{} items]", arr.len())
439        }
440        serde_json::Value::Object(_) => "[Object]".to_string(),
441    }
442}
443
444/// HTML escape a string
445fn html_escape(s: &str) -> String {
446    s.replace('&', "&amp;")
447        .replace('<', "&lt;")
448        .replace('>', "&gt;")
449        .replace('"', "&quot;")
450        .replace('\'', "&#39;")
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::field::FieldType;
457
458    #[test]
459    fn test_create_list_view() {
460        let model = ModelDefinition::builder("user")
461            .id_field()
462            .field(FieldDefinition::new("name", FieldType::String).searchable())
463            .field(FieldDefinition::new("email", FieldType::Email))
464            .list_display(["id", "name", "email"])
465            .search_fields(["name", "email"])
466            .build();
467
468        let view = ListView::new(&model, ListParams::default());
469
470        assert_eq!(view.model_name, "user");
471        assert_eq!(view.columns.len(), 3);
472        assert!(view.has_search);
473    }
474
475    #[test]
476    fn test_render_value() {
477        assert_eq!(render_value(&serde_json::Value::Null), "—");
478        assert!(render_value(&serde_json::Value::Bool(true)).contains("Yes"));
479        assert_eq!(render_value(&serde_json::json!(42)), "42");
480        assert_eq!(render_value(&serde_json::json!("test")), "test");
481    }
482
483    #[test]
484    fn test_html_escape() {
485        assert_eq!(html_escape("<script>"), "&lt;script&gt;");
486        assert_eq!(html_escape("a & b"), "a &amp; b");
487    }
488}
489