use crate::{
field::FieldDefinition,
model::ModelDefinition,
ui::{Breadcrumb, FilterDef, Pagination, TableColumn, TableRow},
ListParams,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListView {
pub model_name: String,
pub verbose_name: String,
pub title: String,
pub breadcrumbs: Vec<Breadcrumb>,
pub columns: Vec<TableColumn>,
pub rows: Vec<TableRow>,
pub pagination: Pagination,
pub filters: Vec<FilterDef>,
pub search_query: Option<String>,
pub can_add: bool,
pub can_delete: bool,
pub can_export: bool,
pub add_url: String,
pub search_placeholder: String,
pub has_search: bool,
pub has_filters: bool,
}
impl ListView {
pub fn new(model: &ModelDefinition, params: ListParams) -> Self {
let columns = model
.display_fields()
.iter()
.map(|f| TableColumn {
field: f.name.clone(),
label: f.label.clone(),
sortable: f.sortable,
sort_direction: if params.sort.as_deref() == Some(&f.name) {
Some(match params.order {
Some(crate::SortOrder::Desc) => crate::ui::SortDirection::Desc,
_ => crate::ui::SortDirection::Asc,
})
} else {
None
},
css_class: None,
width: None,
})
.collect();
let filters = model
.filterable_fields()
.iter()
.map(|f| FilterDef {
field: f.name.clone(),
label: f.label.clone(),
filter_type: match f.field_type {
crate::field::FieldType::Boolean => crate::ui::FilterType::Boolean,
crate::field::FieldType::Enum => crate::ui::FilterType::Select,
crate::field::FieldType::Date | crate::field::FieldType::DateTime => {
crate::ui::FilterType::DateRange
}
_ => crate::ui::FilterType::Text,
},
choices: f
.choices
.as_ref()
.map(|choices| {
choices
.iter()
.map(|c| crate::ui::FilterChoice {
value: c.value.clone(),
label: c.label.clone(),
count: None,
})
.collect()
})
.unwrap_or_default(),
current: params.filters.get(&f.name).cloned(),
})
.collect();
Self {
model_name: model.name.clone(),
verbose_name: model.verbose_name.clone(),
title: model.verbose_name.clone(),
breadcrumbs: vec![
Breadcrumb::new("Dashboard").url("/admin"),
Breadcrumb::new(&model.verbose_name),
],
columns,
rows: Vec::new(), pagination: Pagination::new(params.page(), 25, 0),
filters,
search_query: params.search,
can_add: model.can_add,
can_delete: model.can_delete,
can_export: model.can_export,
add_url: format!("/admin/{}/add", model.name),
search_placeholder: format!(
"Search {}...",
model.search_fields.join(", ")
),
has_search: !model.search_fields.is_empty(),
has_filters: !model.list_filter.is_empty(),
}
}
pub fn with_rows(mut self, rows: Vec<TableRow>, total: usize) -> Self {
self.rows = rows;
self.pagination = Pagination::new(
self.pagination.page,
self.pagination.per_page,
total,
);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetailView {
pub model_name: String,
pub verbose_name: String,
pub title: String,
pub breadcrumbs: Vec<Breadcrumb>,
pub id: String,
pub fields: Vec<FieldValue>,
pub fieldsets: Vec<ViewFieldset>,
pub can_edit: bool,
pub can_delete: bool,
pub edit_url: String,
pub delete_url: String,
pub list_url: String,
pub inlines: Vec<InlineView>,
}
impl DetailView {
pub fn new(model: &ModelDefinition, id: String) -> Self {
Self {
model_name: model.name.clone(),
verbose_name: model.verbose_name_singular.clone(),
title: format!("{} #{}", model.verbose_name_singular, id),
breadcrumbs: vec![
Breadcrumb::new("Dashboard").url("/admin"),
Breadcrumb::new(&model.verbose_name).url(&format!("/admin/{}", model.name)),
Breadcrumb::new(&id),
],
id: id.clone(),
fields: Vec::new(), fieldsets: Vec::new(),
can_edit: model.can_edit,
can_delete: model.can_delete,
edit_url: format!("/admin/{}/{}/edit", model.name, id),
delete_url: format!("/admin/{}/{}/delete", model.name, id),
list_url: format!("/admin/{}", model.name),
inlines: Vec::new(),
}
}
pub fn with_data(mut self, data: serde_json::Value) -> Self {
if let Some(obj) = data.as_object() {
self.fields = obj
.iter()
.map(|(k, v)| FieldValue {
name: k.clone(),
label: k.replace('_', " "),
value: v.clone(),
rendered: render_value(v),
readonly: false,
})
.collect();
}
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateView {
pub model_name: String,
pub verbose_name: String,
pub title: String,
pub breadcrumbs: Vec<Breadcrumb>,
pub fields: Vec<FormField>,
pub fieldsets: Vec<ViewFieldset>,
pub submit_url: String,
pub cancel_url: String,
pub inlines: Vec<InlineView>,
}
impl CreateView {
pub fn new(model: &ModelDefinition) -> Self {
let fields = model
.form_fields()
.iter()
.map(|f| FormField::from_definition(f))
.collect();
Self {
model_name: model.name.clone(),
verbose_name: model.verbose_name_singular.clone(),
title: format!("Add {}", model.verbose_name_singular),
breadcrumbs: vec![
Breadcrumb::new("Dashboard").url("/admin"),
Breadcrumb::new(&model.verbose_name).url(&format!("/admin/{}", model.name)),
Breadcrumb::new("Add"),
],
fields,
fieldsets: Vec::new(),
submit_url: format!("/admin/{}/add", model.name),
cancel_url: format!("/admin/{}", model.name),
inlines: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditView {
pub model_name: String,
pub verbose_name: String,
pub title: String,
pub breadcrumbs: Vec<Breadcrumb>,
pub id: String,
pub fields: Vec<FormField>,
pub fieldsets: Vec<ViewFieldset>,
pub submit_url: String,
pub cancel_url: String,
pub delete_url: String,
pub can_delete: bool,
pub inlines: Vec<InlineView>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldValue {
pub name: String,
pub label: String,
pub value: serde_json::Value,
pub rendered: String,
pub readonly: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormField {
pub name: String,
pub label: String,
pub widget: String,
pub value: serde_json::Value,
pub required: bool,
pub readonly: bool,
pub help_text: Option<String>,
pub placeholder: Option<String>,
pub choices: Option<Vec<(String, String)>>,
pub errors: Vec<String>,
pub attrs: std::collections::HashMap<String, String>,
}
impl FormField {
pub fn from_definition(field: &FieldDefinition) -> Self {
let mut attrs = std::collections::HashMap::new();
if let Some(max_len) = field.max_length {
attrs.insert("maxlength".to_string(), max_len.to_string());
}
if let Some(min) = field.min_value {
attrs.insert("min".to_string(), min.to_string());
}
if let Some(max) = field.max_value {
attrs.insert("max".to_string(), max.to_string());
}
Self {
name: field.name.clone(),
label: field.label.clone(),
widget: format!("{:?}", field.widget).to_lowercase(),
value: serde_json::Value::Null,
required: field.required,
readonly: field.readonly,
help_text: field.help_text.clone(),
placeholder: field.placeholder.clone(),
choices: field.choices.as_ref().map(|c| {
c.iter().map(|ch| (ch.value.clone(), ch.label.clone())).collect()
}),
errors: Vec::new(),
attrs,
}
}
pub fn with_value(mut self, value: serde_json::Value) -> Self {
self.value = value;
self
}
pub fn add_error(&mut self, error: impl Into<String>) {
self.errors.push(error.into());
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViewFieldset {
pub name: Option<String>,
pub description: Option<String>,
pub fields: Vec<String>,
pub collapsible: bool,
pub collapsed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InlineView {
pub model_name: String,
pub verbose_name: String,
pub rows: Vec<InlineRow>,
pub extra: usize,
pub can_delete: bool,
pub fields: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InlineRow {
pub id: Option<String>,
pub fields: Vec<FormField>,
pub is_new: bool,
pub delete: bool,
}
fn render_value(value: &serde_json::Value) -> String {
match value {
serde_json::Value::Null => "—".to_string(),
serde_json::Value::Bool(b) => {
if *b {
r#"<span class="badge badge-success">Yes</span>"#.to_string()
} else {
r#"<span class="badge badge-error">No</span>"#.to_string()
}
}
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => html_escape(s),
serde_json::Value::Array(arr) => {
format!("[{} items]", arr.len())
}
serde_json::Value::Object(_) => "[Object]".to_string(),
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::field::FieldType;
#[test]
fn test_create_list_view() {
let model = ModelDefinition::builder("user")
.id_field()
.field(FieldDefinition::new("name", FieldType::String).searchable())
.field(FieldDefinition::new("email", FieldType::Email))
.list_display(["id", "name", "email"])
.search_fields(["name", "email"])
.build();
let view = ListView::new(&model, ListParams::default());
assert_eq!(view.model_name, "user");
assert_eq!(view.columns.len(), 3);
assert!(view.has_search);
}
#[test]
fn test_render_value() {
assert_eq!(render_value(&serde_json::Value::Null), "—");
assert!(render_value(&serde_json::Value::Bool(true)).contains("Yes"));
assert_eq!(render_value(&serde_json::json!(42)), "42");
assert_eq!(render_value(&serde_json::json!("test")), "test");
}
#[test]
fn test_html_escape() {
assert_eq!(html_escape("<script>"), "<script>");
assert_eq!(html_escape("a & b"), "a & b");
}
}