use crate::{
ListParams,
config::AdminConfig,
data::value_to_plain_string,
field::{FieldDefinition, FieldType},
model::ModelDefinition,
ui::{Breadcrumb, CellType, FilterDef, Pagination, TableCell, TableColumn, TableRow},
};
use crate::render::escape as html_escape;
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, per_page: usize) -> 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(), per_page.max(1), 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
}
pub fn with_json_rows(
self,
model: &ModelDefinition,
config: &AdminConfig,
records: &[serde_json::Value],
total: usize,
) -> Self {
let rows = records
.iter()
.map(|record| build_table_row(model, config, record))
.collect();
self.with_rows(rows, total)
}
}
fn build_table_row(
model: &ModelDefinition,
config: &AdminConfig,
record: &serde_json::Value,
) -> TableRow {
let id = record
.get(&model.primary_key)
.map(value_to_plain_string)
.unwrap_or_default();
let cells = model
.display_fields()
.iter()
.map(|field| {
let value = record
.get(&field.name)
.cloned()
.unwrap_or(serde_json::Value::Null);
let (rendered, cell_type) = render_cell(field, config, &value);
TableCell {
field: field.name.clone(),
value,
rendered,
cell_type,
}
})
.collect();
TableRow {
id,
cells,
selected: false,
css_class: None,
}
}
fn render_cell(
field: &FieldDefinition,
config: &AdminConfig,
value: &serde_json::Value,
) -> (String, CellType) {
match field.field_type {
FieldType::Date => {
let raw = value_to_plain_string(value);
(html_escape(&config.format_date(&raw)), CellType::Date)
}
FieldType::DateTime => {
let raw = value_to_plain_string(value);
(
html_escape(&config.format_datetime(&raw)),
CellType::DateTime,
)
}
FieldType::Boolean => (render_value(value), CellType::Boolean),
FieldType::Integer | FieldType::BigInteger | FieldType::Float | FieldType::Decimal => {
(render_value(value), CellType::Number)
}
FieldType::Email => (html_escape(&value_to_plain_string(value)), CellType::Email),
FieldType::Url => (html_escape(&value_to_plain_string(value)), CellType::Url),
_ => (render_value(value), CellType::Text),
}
}
#[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>,
}
impl EditView {
pub fn new(model: &ModelDefinition, id: String) -> 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!("Edit {} #{}", 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).url(format!("/admin/{}/{}", model.name, id)),
Breadcrumb::new("Edit"),
],
id: id.clone(),
fields,
fieldsets: Vec::new(),
submit_url: format!("/admin/{}/{}/edit", model.name, id),
cancel_url: format!("/admin/{}/{}", model.name, id),
delete_url: format!("/admin/{}/{}/delete", model.name, id),
can_delete: model.can_delete,
inlines: Vec::new(),
}
}
pub fn with_data(mut self, data: serde_json::Value) -> Self {
if let Some(obj) = data.as_object() {
for field in &mut self.fields {
if let Some(v) = obj.get(&field.name) {
field.value = v.clone();
}
}
}
self
}
}
#[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(),
}
}
#[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(), 25);
assert_eq!(view.model_name, "user");
assert_eq!(view.columns.len(), 3);
assert!(view.has_search);
assert_eq!(view.pagination.per_page, 25);
}
#[test]
fn test_list_view_json_rows() {
let model = ModelDefinition::builder("user")
.id_field()
.field(FieldDefinition::new("name", FieldType::String))
.list_display(["id", "name"])
.build();
let records = vec![
serde_json::json!({ "id": 1, "name": "Alice" }),
serde_json::json!({ "id": 2, "name": "Bob" }),
];
let view = ListView::new(&model, ListParams::default(), 25).with_json_rows(
&model,
&AdminConfig::default(),
&records,
2,
);
assert_eq!(view.rows.len(), 2);
assert_eq!(view.rows[0].id, "1");
assert_eq!(view.rows[0].cells[1].rendered, "Alice");
assert_eq!(view.pagination.total_items, 2);
}
#[test]
fn test_edit_view_with_data() {
let model = ModelDefinition::builder("user")
.id_field()
.field(FieldDefinition::new("name", FieldType::String))
.build();
let view = EditView::new(&model, "5".to_string())
.with_data(serde_json::json!({ "name": "Carol" }));
assert_eq!(view.id, "5");
let name = view.fields.iter().find(|f| f.name == "name").unwrap();
assert_eq!(name.value, serde_json::json!("Carol"));
}
#[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");
}
}