Skip to main content

armature_admin/
views.rs

1//! View structures for admin pages
2
3use crate::{
4    ListParams,
5    config::AdminConfig,
6    data::value_to_plain_string,
7    field::{FieldDefinition, FieldType},
8    model::ModelDefinition,
9    ui::{Breadcrumb, CellType, FilterDef, Pagination, TableCell, TableColumn, TableRow},
10};
11// Single shared HTML-escape helper lives in `render`; alias it here so the
12// existing call sites keep reading naturally.
13use crate::render::escape as html_escape;
14use serde::{Deserialize, Serialize};
15
16/// List view for a model
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ListView {
19    /// Model name
20    pub model_name: String,
21    /// Verbose name (plural)
22    pub verbose_name: String,
23    /// Page title
24    pub title: String,
25    /// Breadcrumbs
26    pub breadcrumbs: Vec<Breadcrumb>,
27    /// Table columns
28    pub columns: Vec<TableColumn>,
29    /// Table rows
30    pub rows: Vec<TableRow>,
31    /// Pagination
32    pub pagination: Pagination,
33    /// Filters
34    pub filters: Vec<FilterDef>,
35    /// Current search query
36    pub search_query: Option<String>,
37    /// Can add new records?
38    pub can_add: bool,
39    /// Can delete records?
40    pub can_delete: bool,
41    /// Can export?
42    pub can_export: bool,
43    /// Add URL
44    pub add_url: String,
45    /// Search placeholder
46    pub search_placeholder: String,
47    /// Has search enabled?
48    pub has_search: bool,
49    /// Has filters?
50    pub has_filters: bool,
51}
52
53impl ListView {
54    /// Create a new list view.
55    ///
56    /// `per_page` is the already-resolved page size (see
57    /// [`ListParams::resolve_per_page`]); rows are filled in separately from a
58    /// [`crate::data::DataSource`] via [`ListView::with_json_rows`].
59    pub fn new(model: &ModelDefinition, params: ListParams, per_page: usize) -> Self {
60        let columns = model
61            .display_fields()
62            .iter()
63            .map(|f| TableColumn {
64                field: f.name.clone(),
65                label: f.label.clone(),
66                sortable: f.sortable,
67                sort_direction: if params.sort.as_deref() == Some(&f.name) {
68                    Some(match params.order {
69                        Some(crate::SortOrder::Desc) => crate::ui::SortDirection::Desc,
70                        _ => crate::ui::SortDirection::Asc,
71                    })
72                } else {
73                    None
74                },
75                css_class: None,
76                width: None,
77            })
78            .collect();
79
80        let filters = model
81            .filterable_fields()
82            .iter()
83            .map(|f| FilterDef {
84                field: f.name.clone(),
85                label: f.label.clone(),
86                filter_type: match f.field_type {
87                    crate::field::FieldType::Boolean => crate::ui::FilterType::Boolean,
88                    crate::field::FieldType::Enum => crate::ui::FilterType::Select,
89                    crate::field::FieldType::Date | crate::field::FieldType::DateTime => {
90                        crate::ui::FilterType::DateRange
91                    }
92                    _ => crate::ui::FilterType::Text,
93                },
94                choices: f
95                    .choices
96                    .as_ref()
97                    .map(|choices| {
98                        choices
99                            .iter()
100                            .map(|c| crate::ui::FilterChoice {
101                                value: c.value.clone(),
102                                label: c.label.clone(),
103                                count: None,
104                            })
105                            .collect()
106                    })
107                    .unwrap_or_default(),
108                current: params.filters.get(&f.name).cloned(),
109            })
110            .collect();
111
112        Self {
113            model_name: model.name.clone(),
114            verbose_name: model.verbose_name.clone(),
115            title: model.verbose_name.clone(),
116            breadcrumbs: vec![
117                Breadcrumb::new("Dashboard").url("/admin"),
118                Breadcrumb::new(&model.verbose_name),
119            ],
120            columns,
121            rows: Vec::new(), // Populated from the data source via `with_json_rows`.
122            pagination: Pagination::new(params.page(), per_page.max(1), 0),
123            filters,
124            search_query: params.search,
125            can_add: model.can_add,
126            can_delete: model.can_delete,
127            can_export: model.can_export,
128            add_url: format!("/admin/{}/add", model.name),
129            search_placeholder: format!("Search {}...", model.search_fields.join(", ")),
130            has_search: !model.search_fields.is_empty(),
131            has_filters: !model.list_filter.is_empty(),
132        }
133    }
134
135    /// Set pre-built rows (from a data source), recomputing pagination.
136    pub fn with_rows(mut self, rows: Vec<TableRow>, total: usize) -> Self {
137        self.rows = rows;
138        self.pagination = Pagination::new(self.pagination.page, self.pagination.per_page, total);
139        self
140    }
141
142    /// Populate rows from raw JSON records returned by a
143    /// [`crate::data::DataSource`], rendering each display column's cell and
144    /// honoring the configured date/datetime formats.
145    pub fn with_json_rows(
146        self,
147        model: &ModelDefinition,
148        config: &AdminConfig,
149        records: &[serde_json::Value],
150        total: usize,
151    ) -> Self {
152        let rows = records
153            .iter()
154            .map(|record| build_table_row(model, config, record))
155            .collect();
156        self.with_rows(rows, total)
157    }
158}
159
160/// Build a [`TableRow`] for one record, one cell per display field.
161fn build_table_row(
162    model: &ModelDefinition,
163    config: &AdminConfig,
164    record: &serde_json::Value,
165) -> TableRow {
166    let id = record
167        .get(&model.primary_key)
168        .map(value_to_plain_string)
169        .unwrap_or_default();
170
171    let cells = model
172        .display_fields()
173        .iter()
174        .map(|field| {
175            let value = record
176                .get(&field.name)
177                .cloned()
178                .unwrap_or(serde_json::Value::Null);
179            let (rendered, cell_type) = render_cell(field, config, &value);
180            TableCell {
181                field: field.name.clone(),
182                value,
183                rendered,
184                cell_type,
185            }
186        })
187        .collect();
188
189    TableRow {
190        id,
191        cells,
192        selected: false,
193        css_class: None,
194    }
195}
196
197/// Render a single cell's HTML + classify its cell type, applying the
198/// configured date/datetime formats for temporal fields.
199fn render_cell(
200    field: &FieldDefinition,
201    config: &AdminConfig,
202    value: &serde_json::Value,
203) -> (String, CellType) {
204    match field.field_type {
205        FieldType::Date => {
206            let raw = value_to_plain_string(value);
207            (html_escape(&config.format_date(&raw)), CellType::Date)
208        }
209        FieldType::DateTime => {
210            let raw = value_to_plain_string(value);
211            (
212                html_escape(&config.format_datetime(&raw)),
213                CellType::DateTime,
214            )
215        }
216        FieldType::Boolean => (render_value(value), CellType::Boolean),
217        FieldType::Integer | FieldType::BigInteger | FieldType::Float | FieldType::Decimal => {
218            (render_value(value), CellType::Number)
219        }
220        FieldType::Email => (html_escape(&value_to_plain_string(value)), CellType::Email),
221        FieldType::Url => (html_escape(&value_to_plain_string(value)), CellType::Url),
222        _ => (render_value(value), CellType::Text),
223    }
224}
225
226/// Detail view for a model record
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct DetailView {
229    /// Model name
230    pub model_name: String,
231    /// Verbose name (singular)
232    pub verbose_name: String,
233    /// Page title
234    pub title: String,
235    /// Breadcrumbs
236    pub breadcrumbs: Vec<Breadcrumb>,
237    /// Record ID
238    pub id: String,
239    /// Field values
240    pub fields: Vec<FieldValue>,
241    /// Fieldsets
242    pub fieldsets: Vec<ViewFieldset>,
243    /// Can edit?
244    pub can_edit: bool,
245    /// Can delete?
246    pub can_delete: bool,
247    /// Edit URL
248    pub edit_url: String,
249    /// Delete URL
250    pub delete_url: String,
251    /// List URL
252    pub list_url: String,
253    /// Inlines (related data)
254    pub inlines: Vec<InlineView>,
255}
256
257impl DetailView {
258    /// Create a new detail view
259    pub fn new(model: &ModelDefinition, id: String) -> Self {
260        Self {
261            model_name: model.name.clone(),
262            verbose_name: model.verbose_name_singular.clone(),
263            title: format!("{} #{}", model.verbose_name_singular, id),
264            breadcrumbs: vec![
265                Breadcrumb::new("Dashboard").url("/admin"),
266                Breadcrumb::new(&model.verbose_name).url(format!("/admin/{}", model.name)),
267                Breadcrumb::new(&id),
268            ],
269            id: id.clone(),
270            fields: Vec::new(), // Would be populated from database
271            fieldsets: Vec::new(),
272            can_edit: model.can_edit,
273            can_delete: model.can_delete,
274            edit_url: format!("/admin/{}/{}/edit", model.name, id),
275            delete_url: format!("/admin/{}/{}/delete", model.name, id),
276            list_url: format!("/admin/{}", model.name),
277            inlines: Vec::new(),
278        }
279    }
280
281    /// Set field values
282    pub fn with_data(mut self, data: serde_json::Value) -> Self {
283        if let Some(obj) = data.as_object() {
284            self.fields = obj
285                .iter()
286                .map(|(k, v)| FieldValue {
287                    name: k.clone(),
288                    label: k.replace('_', " "),
289                    value: v.clone(),
290                    rendered: render_value(v),
291                    readonly: false,
292                })
293                .collect();
294        }
295        self
296    }
297}
298
299/// Create view for adding a new record
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct CreateView {
302    /// Model name
303    pub model_name: String,
304    /// Verbose name (singular)
305    pub verbose_name: String,
306    /// Page title
307    pub title: String,
308    /// Breadcrumbs
309    pub breadcrumbs: Vec<Breadcrumb>,
310    /// Form fields
311    pub fields: Vec<FormField>,
312    /// Fieldsets
313    pub fieldsets: Vec<ViewFieldset>,
314    /// Submit URL
315    pub submit_url: String,
316    /// Cancel URL
317    pub cancel_url: String,
318    /// Inlines
319    pub inlines: Vec<InlineView>,
320}
321
322impl CreateView {
323    /// Create a new create view
324    pub fn new(model: &ModelDefinition) -> Self {
325        let fields = model
326            .form_fields()
327            .iter()
328            .map(|f| FormField::from_definition(f))
329            .collect();
330
331        Self {
332            model_name: model.name.clone(),
333            verbose_name: model.verbose_name_singular.clone(),
334            title: format!("Add {}", model.verbose_name_singular),
335            breadcrumbs: vec![
336                Breadcrumb::new("Dashboard").url("/admin"),
337                Breadcrumb::new(&model.verbose_name).url(format!("/admin/{}", model.name)),
338                Breadcrumb::new("Add"),
339            ],
340            fields,
341            fieldsets: Vec::new(),
342            submit_url: format!("/admin/{}/add", model.name),
343            cancel_url: format!("/admin/{}", model.name),
344            inlines: Vec::new(),
345        }
346    }
347}
348
349/// Edit view for modifying a record
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct EditView {
352    /// Model name
353    pub model_name: String,
354    /// Verbose name
355    pub verbose_name: String,
356    /// Page title
357    pub title: String,
358    /// Breadcrumbs
359    pub breadcrumbs: Vec<Breadcrumb>,
360    /// Record ID
361    pub id: String,
362    /// Form fields
363    pub fields: Vec<FormField>,
364    /// Fieldsets
365    pub fieldsets: Vec<ViewFieldset>,
366    /// Submit URL
367    pub submit_url: String,
368    /// Cancel URL
369    pub cancel_url: String,
370    /// Delete URL
371    pub delete_url: String,
372    /// Can delete?
373    pub can_delete: bool,
374    /// Inlines
375    pub inlines: Vec<InlineView>,
376}
377
378impl EditView {
379    /// Create a new edit view (form fields default to empty values).
380    pub fn new(model: &ModelDefinition, id: String) -> Self {
381        let fields = model
382            .form_fields()
383            .iter()
384            .map(|f| FormField::from_definition(f))
385            .collect();
386
387        Self {
388            model_name: model.name.clone(),
389            verbose_name: model.verbose_name_singular.clone(),
390            title: format!("Edit {} #{}", model.verbose_name_singular, id),
391            breadcrumbs: vec![
392                Breadcrumb::new("Dashboard").url("/admin"),
393                Breadcrumb::new(&model.verbose_name).url(format!("/admin/{}", model.name)),
394                Breadcrumb::new(&id).url(format!("/admin/{}/{}", model.name, id)),
395                Breadcrumb::new("Edit"),
396            ],
397            id: id.clone(),
398            fields,
399            fieldsets: Vec::new(),
400            submit_url: format!("/admin/{}/{}/edit", model.name, id),
401            cancel_url: format!("/admin/{}/{}", model.name, id),
402            delete_url: format!("/admin/{}/{}/delete", model.name, id),
403            can_delete: model.can_delete,
404            inlines: Vec::new(),
405        }
406    }
407
408    /// Populate the form fields with the current record's values.
409    pub fn with_data(mut self, data: serde_json::Value) -> Self {
410        if let Some(obj) = data.as_object() {
411            for field in &mut self.fields {
412                if let Some(v) = obj.get(&field.name) {
413                    field.value = v.clone();
414                }
415            }
416        }
417        self
418    }
419}
420
421/// Field value for display
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct FieldValue {
424    /// Field name
425    pub name: String,
426    /// Display label
427    pub label: String,
428    /// Raw value
429    pub value: serde_json::Value,
430    /// Rendered HTML
431    pub rendered: String,
432    /// Is readonly?
433    pub readonly: bool,
434}
435
436/// Form field for editing
437#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct FormField {
439    /// Field name
440    pub name: String,
441    /// Display label
442    pub label: String,
443    /// Widget type
444    pub widget: String,
445    /// Current value
446    pub value: serde_json::Value,
447    /// Is required?
448    pub required: bool,
449    /// Is readonly?
450    pub readonly: bool,
451    /// Help text
452    pub help_text: Option<String>,
453    /// Placeholder
454    pub placeholder: Option<String>,
455    /// Choices (for select)
456    pub choices: Option<Vec<(String, String)>>,
457    /// Validation errors
458    pub errors: Vec<String>,
459    /// HTML attributes
460    pub attrs: std::collections::HashMap<String, String>,
461}
462
463impl FormField {
464    /// Create from field definition
465    pub fn from_definition(field: &FieldDefinition) -> Self {
466        let mut attrs = std::collections::HashMap::new();
467
468        if let Some(max_len) = field.max_length {
469            attrs.insert("maxlength".to_string(), max_len.to_string());
470        }
471        if let Some(min) = field.min_value {
472            attrs.insert("min".to_string(), min.to_string());
473        }
474        if let Some(max) = field.max_value {
475            attrs.insert("max".to_string(), max.to_string());
476        }
477
478        Self {
479            name: field.name.clone(),
480            label: field.label.clone(),
481            widget: format!("{:?}", field.widget).to_lowercase(),
482            value: serde_json::Value::Null,
483            required: field.required,
484            readonly: field.readonly,
485            help_text: field.help_text.clone(),
486            placeholder: field.placeholder.clone(),
487            choices: field.choices.as_ref().map(|c| {
488                c.iter()
489                    .map(|ch| (ch.value.clone(), ch.label.clone()))
490                    .collect()
491            }),
492            errors: Vec::new(),
493            attrs,
494        }
495    }
496
497    /// Set value
498    pub fn with_value(mut self, value: serde_json::Value) -> Self {
499        self.value = value;
500        self
501    }
502
503    /// Add error
504    pub fn add_error(&mut self, error: impl Into<String>) {
505        self.errors.push(error.into());
506    }
507}
508
509/// Fieldset for organizing form fields
510#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct ViewFieldset {
512    /// Fieldset name
513    pub name: Option<String>,
514    /// Description
515    pub description: Option<String>,
516    /// Fields in this fieldset
517    pub fields: Vec<String>,
518    /// Is collapsible?
519    pub collapsible: bool,
520    /// Is collapsed?
521    pub collapsed: bool,
522}
523
524/// Inline view for related data
525#[derive(Debug, Clone, Serialize, Deserialize)]
526pub struct InlineView {
527    /// Model name
528    pub model_name: String,
529    /// Verbose name
530    pub verbose_name: String,
531    /// Rows
532    pub rows: Vec<InlineRow>,
533    /// Extra empty rows
534    pub extra: usize,
535    /// Can delete?
536    pub can_delete: bool,
537    /// Fields to display
538    pub fields: Vec<String>,
539}
540
541/// Inline row
542#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct InlineRow {
544    /// Row ID (if existing)
545    pub id: Option<String>,
546    /// Field values
547    pub fields: Vec<FormField>,
548    /// Is new?
549    pub is_new: bool,
550    /// Delete marker
551    pub delete: bool,
552}
553
554/// Render a value for display
555fn render_value(value: &serde_json::Value) -> String {
556    match value {
557        serde_json::Value::Null => "—".to_string(),
558        serde_json::Value::Bool(b) => {
559            if *b {
560                r#"<span class="badge badge-success">Yes</span>"#.to_string()
561            } else {
562                r#"<span class="badge badge-error">No</span>"#.to_string()
563            }
564        }
565        serde_json::Value::Number(n) => n.to_string(),
566        serde_json::Value::String(s) => html_escape(s),
567        serde_json::Value::Array(arr) => {
568            format!("[{} items]", arr.len())
569        }
570        serde_json::Value::Object(_) => "[Object]".to_string(),
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use crate::field::FieldType;
578
579    #[test]
580    fn test_create_list_view() {
581        let model = ModelDefinition::builder("user")
582            .id_field()
583            .field(FieldDefinition::new("name", FieldType::String).searchable())
584            .field(FieldDefinition::new("email", FieldType::Email))
585            .list_display(["id", "name", "email"])
586            .search_fields(["name", "email"])
587            .build();
588
589        let view = ListView::new(&model, ListParams::default(), 25);
590
591        assert_eq!(view.model_name, "user");
592        assert_eq!(view.columns.len(), 3);
593        assert!(view.has_search);
594        assert_eq!(view.pagination.per_page, 25);
595    }
596
597    #[test]
598    fn test_list_view_json_rows() {
599        let model = ModelDefinition::builder("user")
600            .id_field()
601            .field(FieldDefinition::new("name", FieldType::String))
602            .list_display(["id", "name"])
603            .build();
604
605        let records = vec![
606            serde_json::json!({ "id": 1, "name": "Alice" }),
607            serde_json::json!({ "id": 2, "name": "Bob" }),
608        ];
609        let view = ListView::new(&model, ListParams::default(), 25).with_json_rows(
610            &model,
611            &AdminConfig::default(),
612            &records,
613            2,
614        );
615
616        assert_eq!(view.rows.len(), 2);
617        assert_eq!(view.rows[0].id, "1");
618        assert_eq!(view.rows[0].cells[1].rendered, "Alice");
619        assert_eq!(view.pagination.total_items, 2);
620    }
621
622    #[test]
623    fn test_edit_view_with_data() {
624        let model = ModelDefinition::builder("user")
625            .id_field()
626            .field(FieldDefinition::new("name", FieldType::String))
627            .build();
628
629        let view = EditView::new(&model, "5".to_string())
630            .with_data(serde_json::json!({ "name": "Carol" }));
631
632        assert_eq!(view.id, "5");
633        let name = view.fields.iter().find(|f| f.name == "name").unwrap();
634        assert_eq!(name.value, serde_json::json!("Carol"));
635    }
636
637    #[test]
638    fn test_render_value() {
639        assert_eq!(render_value(&serde_json::Value::Null), "—");
640        assert!(render_value(&serde_json::Value::Bool(true)).contains("Yes"));
641        assert_eq!(render_value(&serde_json::json!(42)), "42");
642        assert_eq!(render_value(&serde_json::json!("test")), "test");
643    }
644
645    #[test]
646    fn test_html_escape() {
647        assert_eq!(html_escape("<script>"), "&lt;script&gt;");
648        assert_eq!(html_escape("a & b"), "a &amp; b");
649    }
650}