1use crate::{
4 field::FieldDefinition,
5 model::ModelDefinition,
6 ui::{Breadcrumb, FilterDef, Pagination, TableColumn, TableRow},
7 ListParams,
8};
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ListView {
14 pub model_name: String,
16 pub verbose_name: String,
18 pub title: String,
20 pub breadcrumbs: Vec<Breadcrumb>,
22 pub columns: Vec<TableColumn>,
24 pub rows: Vec<TableRow>,
26 pub pagination: Pagination,
28 pub filters: Vec<FilterDef>,
30 pub search_query: Option<String>,
32 pub can_add: bool,
34 pub can_delete: bool,
36 pub can_export: bool,
38 pub add_url: String,
40 pub search_placeholder: String,
42 pub has_search: bool,
44 pub has_filters: bool,
46}
47
48impl ListView {
49 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(), 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct DetailView {
144 pub model_name: String,
146 pub verbose_name: String,
148 pub title: String,
150 pub breadcrumbs: Vec<Breadcrumb>,
152 pub id: String,
154 pub fields: Vec<FieldValue>,
156 pub fieldsets: Vec<ViewFieldset>,
158 pub can_edit: bool,
160 pub can_delete: bool,
162 pub edit_url: String,
164 pub delete_url: String,
166 pub list_url: String,
168 pub inlines: Vec<InlineView>,
170}
171
172impl DetailView {
173 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(), 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct CreateView {
217 pub model_name: String,
219 pub verbose_name: String,
221 pub title: String,
223 pub breadcrumbs: Vec<Breadcrumb>,
225 pub fields: Vec<FormField>,
227 pub fieldsets: Vec<ViewFieldset>,
229 pub submit_url: String,
231 pub cancel_url: String,
233 pub inlines: Vec<InlineView>,
235}
236
237impl CreateView {
238 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#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct EditView {
267 pub model_name: String,
269 pub verbose_name: String,
271 pub title: String,
273 pub breadcrumbs: Vec<Breadcrumb>,
275 pub id: String,
277 pub fields: Vec<FormField>,
279 pub fieldsets: Vec<ViewFieldset>,
281 pub submit_url: String,
283 pub cancel_url: String,
285 pub delete_url: String,
287 pub can_delete: bool,
289 pub inlines: Vec<InlineView>,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct FieldValue {
296 pub name: String,
298 pub label: String,
300 pub value: serde_json::Value,
302 pub rendered: String,
304 pub readonly: bool,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct FormField {
311 pub name: String,
313 pub label: String,
315 pub widget: String,
317 pub value: serde_json::Value,
319 pub required: bool,
321 pub readonly: bool,
323 pub help_text: Option<String>,
325 pub placeholder: Option<String>,
327 pub choices: Option<Vec<(String, String)>>,
329 pub errors: Vec<String>,
331 pub attrs: std::collections::HashMap<String, String>,
333}
334
335impl FormField {
336 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 pub fn with_value(mut self, value: serde_json::Value) -> Self {
369 self.value = value;
370 self
371 }
372
373 pub fn add_error(&mut self, error: impl Into<String>) {
375 self.errors.push(error.into());
376 }
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct ViewFieldset {
382 pub name: Option<String>,
384 pub description: Option<String>,
386 pub fields: Vec<String>,
388 pub collapsible: bool,
390 pub collapsed: bool,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct InlineView {
397 pub model_name: String,
399 pub verbose_name: String,
401 pub rows: Vec<InlineRow>,
403 pub extra: usize,
405 pub can_delete: bool,
407 pub fields: Vec<String>,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct InlineRow {
414 pub id: Option<String>,
416 pub fields: Vec<FormField>,
418 pub is_new: bool,
420 pub delete: bool,
422}
423
424fn 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
444fn html_escape(s: &str) -> String {
446 s.replace('&', "&")
447 .replace('<', "<")
448 .replace('>', ">")
449 .replace('"', """)
450 .replace('\'', "'")
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>"), "<script>");
486 assert_eq!(html_escape("a & b"), "a & b");
487 }
488}
489