1use crate::types::{FilterInfo, FilterType, ModelInfo};
12use reinhardt_pages::Signal;
13use reinhardt_pages::component::{IntoPage, Page, PageElement};
14use std::collections::HashMap;
15
16#[cfg(target_arch = "wasm32")]
17use reinhardt_pages::dom::EventType;
18
19#[cfg(target_arch = "wasm32")]
20use std::sync::Arc;
21
22pub fn dashboard(site_name: &str, models: &[ModelInfo]) -> Page {
39 PageElement::new("div")
40 .attr("class", "dashboard")
41 .child(
42 PageElement::new("h1")
43 .attr("class", "mb-4")
44 .child(format!("{} Dashboard", site_name)),
45 )
46 .child(
47 PageElement::new("div")
48 .attr("class", "row")
49 .child(models_grid(models)),
50 )
51 .into_page()
52}
53
54fn models_grid(models: &[ModelInfo]) -> Page {
56 if models.is_empty() {
57 return PageElement::new("div")
58 .attr("class", "col-12")
59 .child(
60 PageElement::new("div")
61 .attr("class", "alert alert-info")
62 .child("No models registered. Add models to AdminSite to see them here."),
63 )
64 .into_page();
65 }
66
67 let card_views: Vec<Page> = models
68 .iter()
69 .map(|model| {
70 PageElement::new("div")
71 .attr("class", "col-md-4")
72 .child(model_card(&model.name, &model.list_url))
73 .into_page()
74 })
75 .collect();
76
77 PageElement::new("div")
78 .attr("class", "col-12")
79 .child(
80 PageElement::new("div")
81 .attr("class", "row g-4")
82 .children(card_views),
83 )
84 .into_page()
85}
86
87fn model_card(name: &str, url: &str) -> Page {
89 PageElement::new("div")
90 .attr("class", "card h-100")
91 .child(
92 PageElement::new("div")
93 .attr("class", "card-body")
94 .child(
95 PageElement::new("h5")
96 .attr("class", "card-title")
97 .child(name.to_string()),
98 )
99 .child(
100 PageElement::new("p")
101 .attr("class", "card-text")
102 .child(format!("Manage {} records", name)),
103 )
104 .child(
105 PageElement::new("a")
106 .attr("class", "btn btn-primary")
107 .attr("href", url.to_string())
108 .child(format!("View {}", name)),
109 ),
110 )
111 .into_page()
112}
113
114#[derive(Debug, Clone)]
116pub struct Column {
117 pub field: String,
119 pub label: String,
121 pub sortable: bool,
123}
124
125#[derive(Debug, Clone)]
127pub struct ListViewData {
128 pub model_name: String,
130 pub columns: Vec<Column>,
132 pub records: Vec<std::collections::HashMap<String, String>>,
134 pub current_page: u64,
136 pub total_pages: u64,
138 pub total_count: u64,
140 pub filters: Vec<FilterInfo>,
142}
143
144pub fn list_view(
172 data: &ListViewData,
173 current_page_signal: reinhardt_pages::Signal<u64>,
174 filters_signal: Signal<HashMap<String, String>>,
175) -> Page {
176 PageElement::new("div")
177 .attr("class", "list-view")
178 .child(
179 PageElement::new("h1")
180 .attr("class", "mb-4")
181 .child(format!("{} List", data.model_name)),
182 )
183 .child(filters(&data.filters, filters_signal))
184 .child(PageElement::new("div").attr("class", "mb-3").child(format!(
185 "Showing {} {} (Page {} of {})",
186 data.total_count, data.model_name, data.current_page, data.total_pages
187 )))
188 .child(data_table(&data.columns, &data.records, &data.model_name))
189 .child(super::super::components::common::pagination(
190 current_page_signal,
191 data.total_pages,
192 ))
193 .into_page()
194}
195
196fn data_table(
198 columns: &[Column],
199 records: &[std::collections::HashMap<String, String>],
200 model_name: &str,
201) -> Page {
202 let header_cells: Vec<Page> = columns
204 .iter()
205 .map(|col| PageElement::new("th").child(col.label.clone()).into_page())
206 .chain(std::iter::once(
207 PageElement::new("th").child("Actions").into_page(),
208 ))
209 .collect();
210
211 let thead = PageElement::new("thead").child(PageElement::new("tr").children(header_cells));
212
213 let body_rows: Vec<Page> = records
215 .iter()
216 .map(|record| table_row(columns, record, model_name))
217 .collect();
218
219 let tbody = PageElement::new("tbody").children(body_rows);
220
221 PageElement::new("div")
222 .attr("class", "table-responsive")
223 .child(
224 PageElement::new("table")
225 .attr("class", "table table-striped table-hover")
226 .child(thead)
227 .child(tbody),
228 )
229 .into_page()
230}
231
232fn table_row(
234 columns: &[Column],
235 record: &std::collections::HashMap<String, String>,
236 model_name: &str,
237) -> Page {
238 let data_cells: Vec<Page> = columns
240 .iter()
241 .map(|col| {
242 let value = record
243 .get(&col.field)
244 .cloned()
245 .unwrap_or_else(|| "-".to_string());
246 PageElement::new("td").child(value).into_page()
247 })
248 .collect();
249
250 let record_id = record.get("id").cloned().unwrap_or_else(|| "0".to_string());
252 let actions_cell = PageElement::new("td")
253 .child(action_buttons(model_name, &record_id))
254 .into_page();
255
256 PageElement::new("tr")
257 .children(data_cells)
258 .child(actions_cell)
259 .into_page()
260}
261
262fn action_buttons(model_name: &str, record_id: &str) -> Page {
264 use reinhardt_pages::component::Component;
265 use reinhardt_pages::router::Link;
266
267 let detail_url = format!("/admin/{}/{}/", model_name.to_lowercase(), record_id);
268 let edit_url = format!("/admin/{}/{}/change/", model_name.to_lowercase(), record_id);
269
270 PageElement::new("div")
271 .attr("class", "btn-group btn-group-sm")
272 .attr("role", "group")
273 .child(
274 Link::new(detail_url.clone(), "View")
275 .class("btn btn-outline-primary")
276 .render(),
277 )
278 .child(
279 Link::new(edit_url.clone(), "Edit")
280 .class("btn btn-outline-secondary")
281 .render(),
282 )
283 .into_page()
284}
285
286#[derive(Debug, Clone)]
288pub struct FormField {
289 pub name: String,
291 pub label: String,
293 pub field_type: String,
295 pub required: bool,
297 pub value: String,
299}
300
301pub fn detail_view(
317 model_name: &str,
318 record_id: &str,
319 record: &std::collections::HashMap<String, String>,
320) -> Page {
321 use reinhardt_pages::component::Component;
322 use reinhardt_pages::router::Link;
323
324 let edit_url = format!("/admin/{}/{}/change/", model_name.to_lowercase(), record_id);
325 let list_url = format!("/admin/{}/", model_name.to_lowercase());
326
327 PageElement::new("div")
328 .attr("class", "detail-view")
329 .child(
330 PageElement::new("h1")
331 .attr("class", "mb-4")
332 .child(format!("{} Detail", model_name)),
333 )
334 .child(detail_table(record))
335 .child(
336 PageElement::new("div")
337 .attr("class", "mt-4")
338 .child(
339 Link::new(edit_url, "Edit")
340 .class("btn btn-primary me-2")
341 .render(),
342 )
343 .child(
344 Link::new(list_url, "Back to List")
345 .class("btn btn-secondary")
346 .render(),
347 ),
348 )
349 .into_page()
350}
351
352fn detail_table(record: &std::collections::HashMap<String, String>) -> Page {
354 let rows: Vec<Page> = record
355 .iter()
356 .map(|(key, value)| {
357 PageElement::new("tr")
358 .child(
359 PageElement::new("th")
360 .attr("class", "w-25")
361 .child(key.clone()),
362 )
363 .child(PageElement::new("td").child(value.clone()))
364 .into_page()
365 })
366 .collect();
367
368 PageElement::new("div")
369 .attr("class", "table-responsive")
370 .child(
371 PageElement::new("table")
372 .attr("class", "table table-bordered")
373 .child(PageElement::new("tbody").children(rows)),
374 )
375 .into_page()
376}
377
378pub fn model_form(model_name: &str, fields: &[FormField], record_id: Option<&str>) -> Page {
399 use reinhardt_pages::component::Component;
400 use reinhardt_pages::router::Link;
401
402 let form_title = if record_id.is_some() {
403 format!("Edit {}", model_name)
404 } else {
405 format!("Create {}", model_name)
406 };
407
408 let list_url = format!("/admin/{}/", model_name.to_lowercase());
409
410 let form_groups: Vec<Page> = fields.iter().map(form_group).collect();
412
413 PageElement::new("div")
414 .attr("class", "model-form")
415 .child(
416 PageElement::new("h1")
417 .attr("class", "mb-4")
418 .child(form_title),
419 )
420 .child(
421 PageElement::new("form")
422 .attr("class", "needs-validation")
423 .attr("novalidate", "true")
424 .children(form_groups)
425 .child(
426 PageElement::new("div")
427 .attr("class", "mt-4")
428 .child(
429 PageElement::new("button")
430 .attr("class", "btn btn-primary me-2")
431 .attr("type", "submit")
432 .child("Save"),
433 )
434 .child(
435 Link::new(list_url, "Cancel")
436 .class("btn btn-secondary")
437 .render(),
438 ),
439 ),
440 )
441 .into_page()
442}
443
444fn form_group(field: &FormField) -> Page {
446 let input_id = format!("field-{}", field.name);
447
448 PageElement::new("div")
449 .attr("class", "mb-3")
450 .child(
451 PageElement::new("label")
452 .attr("class", "form-label")
453 .attr("for", input_id.clone())
454 .child(field.label.clone()),
455 )
456 .child(form_element(field, &input_id))
457 .into_page()
458}
459
460fn form_element(field: &FormField, input_id: &str) -> Page {
462 let mut input_builder = PageElement::new("input")
463 .attr("class", "form-control")
464 .attr("type", field.field_type.clone())
465 .attr("id", input_id.to_string())
466 .attr("name", field.name.clone())
467 .attr("value", field.value.clone());
468
469 if field.required {
470 input_builder = input_builder.attr("required", "true");
471 }
472
473 input_builder.into_page()
474}
475
476fn filter_type_to_choices(filter_type: &FilterType) -> Vec<(String, String)> {
481 let mut choices = vec![("".to_string(), "All".to_string())];
482
483 match filter_type {
484 FilterType::Boolean => {
485 choices.push(("true".to_string(), "Yes".to_string()));
486 choices.push(("false".to_string(), "No".to_string()));
487 }
488 FilterType::Choice {
489 choices: filter_choices,
490 } => {
491 for choice in filter_choices {
492 choices.push((choice.value.clone(), choice.label.clone()));
493 }
494 }
495 FilterType::DateRange { ranges } => {
496 for range in ranges {
497 choices.push((range.value.clone(), range.label.clone()));
498 }
499 }
500 FilterType::NumberRange { ranges } => {
501 for range in ranges {
502 choices.push((range.value.clone(), range.label.clone()));
503 }
504 }
505 }
506
507 choices
508}
509
510fn create_filter_select(
515 field: &str,
516 filter_type: &FilterType,
517 current_value: Option<&str>,
518 _filters_signal: Signal<HashMap<String, String>>,
519) -> Page {
520 let choices = filter_type_to_choices(filter_type);
521 let current_val = current_value.unwrap_or("");
522
523 let options: Vec<Page> = choices
525 .iter()
526 .map(|(value, label)| {
527 let mut opt = PageElement::new("option")
528 .attr("value", value.clone())
529 .child(label.clone());
530
531 if value == current_val {
532 opt = opt.attr("selected", "true");
533 }
534
535 opt.into_page()
536 })
537 .collect();
538
539 #[cfg(target_arch = "wasm32")]
541 let select_view = {
542 use wasm_bindgen::JsCast;
543 use web_sys::HtmlSelectElement;
544
545 let field_clone = field.to_string();
546 let filters_signal = _filters_signal;
547
548 PageElement::new("select")
549 .attr("class", "form-select form-select-sm")
550 .attr("data-filter-field", field.to_string())
551 .children(options)
552 .on(
553 EventType::Change,
554 Arc::new(move |event: web_sys::Event| {
555 if let Some(target) = event.target() {
556 if let Ok(select) = target.dyn_into::<HtmlSelectElement>() {
557 let value = select.value();
558 let field_name = field_clone.clone();
559
560 filters_signal.update(move |map| {
561 if value.is_empty() {
562 map.remove(&field_name);
563 } else {
564 map.insert(field_name, value);
565 }
566 });
567 }
568 }
569 }),
570 )
571 };
572
573 #[cfg(not(target_arch = "wasm32"))]
575 let select_view = {
576 PageElement::new("select")
577 .attr("class", "form-select form-select-sm")
578 .attr("data-filter-field", field.to_string())
579 .attr("data-reactive", "true") .children(options)
581 };
582
583 select_view.into_page()
584}
585
586fn create_filter_control(
590 filter_info: &FilterInfo,
591 current_value: Option<&str>,
592 filters_signal: Signal<HashMap<String, String>>,
593) -> Page {
594 PageElement::new("div")
595 .attr("class", "col-md-3")
596 .child(
597 PageElement::new("div")
598 .attr("class", "mb-2")
599 .child(
600 PageElement::new("label")
601 .attr("class", "form-label")
602 .child(filter_info.title.clone()),
603 )
604 .child(create_filter_select(
605 &filter_info.field,
606 &filter_info.filter_type,
607 current_value,
608 filters_signal,
609 )),
610 )
611 .into_page()
612}
613
614pub fn filters(
639 filters_info: &[FilterInfo],
640 filters_signal: Signal<HashMap<String, String>>,
641) -> Page {
642 if filters_info.is_empty() {
643 return PageElement::new("div").into_page();
644 }
645
646 let current_filters = filters_signal.get();
647
648 let filter_controls: Vec<Page> = filters_info
649 .iter()
650 .map(|info| {
651 let current_value = current_filters.get(&info.field).map(|s| s.as_str());
652 create_filter_control(info, current_value, filters_signal.clone())
653 })
654 .collect();
655
656 PageElement::new("div")
657 .attr("class", "filters mb-3")
658 .child(
659 PageElement::new("h5")
660 .attr("class", "mb-2")
661 .child("Filters"),
662 )
663 .child(
664 PageElement::new("div")
665 .attr("class", "row g-2")
666 .children(filter_controls),
667 )
668 .into_page()
669}