Skip to main content

reinhardt_admin/pages/components/
features.rs

1//! Feature-specific components
2//!
3//! Provides feature-specific UI components:
4//! - `Dashboard` - Dashboard view
5//! - `ListView` - List view with filters and pagination
6//! - `DetailView` - Detail view for a single record
7//! - `ModelForm` - Form for creating/editing records
8//! - `Filters` - Filter panel
9//! - `DataTable` - Data table component
10
11use 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
22/// Dashboard component
23///
24/// Displays the admin dashboard with model cards.
25///
26/// # Example
27///
28/// ```ignore
29/// use reinhardt_admin::pages::components::features::dashboard;
30/// use reinhardt_admin::types::ModelInfo;
31///
32/// let models = vec![
33///     ModelInfo { name: "Users".to_string(), list_url: "/admin/users/".to_string() },
34///     ModelInfo { name: "Posts".to_string(), list_url: "/admin/posts/".to_string() },
35/// ];
36/// dashboard("My Admin Panel", &models)
37/// ```
38pub 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
54/// Generates a grid of model cards
55fn 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
87/// Generates a single model card
88fn 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/// Column definition for list view
115#[derive(Debug, Clone)]
116pub struct Column {
117	/// Column field name
118	pub field: String,
119	/// Column display label
120	pub label: String,
121	/// Whether this column is sortable
122	pub sortable: bool,
123}
124
125/// List view data structure
126#[derive(Debug, Clone)]
127pub struct ListViewData {
128	/// Model name
129	pub model_name: String,
130	/// Column definitions
131	pub columns: Vec<Column>,
132	/// Record data (each record is a HashMap of field -> value)
133	pub records: Vec<std::collections::HashMap<String, String>>,
134	/// Current page number (1-indexed)
135	pub current_page: u64,
136	/// Total number of pages
137	pub total_pages: u64,
138	/// Total number of records
139	pub total_count: u64,
140	/// Filter information
141	pub filters: Vec<FilterInfo>,
142}
143
144/// List view component
145///
146/// Displays a paginated list of records with filters and search.
147///
148/// # Example
149///
150/// ```ignore
151/// use reinhardt_admin::pages::components::features::{list_view, ListViewData, Column};
152/// use reinhardt_pages::Signal;
153/// use std::collections::HashMap;
154///
155/// let data = ListViewData {
156///     model_name: "User".to_string(),
157///     columns: vec![
158///         Column { field: "id".to_string(), label: "ID".to_string(), sortable: true },
159///         Column { field: "username".to_string(), label: "Username".to_string(), sortable: true },
160///     ],
161///     records: vec![/* ... */],
162///     current_page: 1,
163///     total_pages: 5,
164///     total_count: 42,
165///     filters: vec![],
166/// };
167/// let page_signal = Signal::new(1u64);
168/// let filters_signal = Signal::new(HashMap::new());
169/// list_view(&data, page_signal, filters_signal)
170/// ```
171pub 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
196/// Generates a data table
197fn data_table(
198	columns: &[Column],
199	records: &[std::collections::HashMap<String, String>],
200	model_name: &str,
201) -> Page {
202	// Table header
203	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	// Table body
214	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
232/// Generates a table row for a single record
233fn table_row(
234	columns: &[Column],
235	record: &std::collections::HashMap<String, String>,
236	model_name: &str,
237) -> Page {
238	// Data cells
239	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	// Actions cell
251	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
262/// Generates action buttons for a record
263fn 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/// Form field definition for model forms
287#[derive(Debug, Clone)]
288pub struct FormField {
289	/// Field name (corresponds to database column)
290	pub name: String,
291	/// Field display label
292	pub label: String,
293	/// HTML input type (text, email, number, etc.)
294	pub field_type: String,
295	/// Whether this field is required
296	pub required: bool,
297	/// Current field value (for edit forms)
298	pub value: String,
299}
300
301/// Detail view component
302///
303/// Displays detailed information about a single record.
304///
305/// # Example
306///
307/// ```ignore
308/// use reinhardt_admin::pages::components::features::detail_view;
309/// use std::collections::HashMap;
310///
311/// let mut record = HashMap::new();
312/// record.insert("id".to_string(), "1".to_string());
313/// record.insert("username".to_string(), "john_doe".to_string());
314/// detail_view("User", "1", &record)
315/// ```
316pub 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
352/// Generates a detail table for record fields
353fn 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
378/// Model form component
379///
380/// Displays a form for creating or editing a record.
381///
382/// # Example
383///
384/// ```ignore
385/// use reinhardt_admin::pages::components::features::{model_form, FormField};
386///
387/// let fields = vec![
388///     FormField {
389///         name: "username".to_string(),
390///         label: "Username".to_string(),
391///         field_type: "text".to_string(),
392///         required: true,
393///         value: "".to_string(),
394///     },
395/// ];
396/// model_form("User", &fields, None)
397/// ```
398pub 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	// Add form fields
411	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
444/// Generates a form group (label + input) for a field
445fn 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
460/// Generates an input element for a form field
461fn 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
476/// Convert FilterType to choice list
477///
478/// Generates a list of (value, label) pairs for select options.
479/// Always includes an "All" option as the first choice.
480fn 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
510/// Create filter select element
511///
512/// Generates a <select> element for a filter field.
513/// Includes SSR/WASM conditional compilation for event handlers.
514fn 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	// Generate <option> elements
524	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	// WASM: Add event handler for filter changes
540	#[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	// SSR: No event handler (will be hydrated on client)
574	#[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") // Marker for client-side hydration
580			.children(options)
581	};
582
583	select_view.into_page()
584}
585
586/// Create filter control (label + select)
587///
588/// Generates a complete filter control with label and select element.
589fn 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
614/// Filters component
615///
616/// Displays filter controls for list views.
617/// Uses Signal to track current filter values.
618///
619/// # Example
620///
621/// ```ignore
622/// use reinhardt_admin::pages::components::features::filters;
623/// use reinhardt_admin::types::{FilterInfo, FilterType};
624/// use reinhardt_pages::Signal;
625/// use std::collections::HashMap;
626///
627/// let filters_signal = Signal::new(HashMap::new());
628/// let filter_infos = vec![
629///     FilterInfo {
630///         field: "status".to_string(),
631///         title: "Status".to_string(),
632///         filter_type: FilterType::Boolean,
633///         current_value: None,
634///     },
635/// ];
636/// filters(&filter_infos, filters_signal)
637/// ```
638pub 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}