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
11#[cfg(client)]
12use crate::server::{create_record, delete_record, update_record};
13use crate::types::{FilterInfo, FilterType, ModelInfo};
14use reinhardt_pages::Signal;
15use reinhardt_pages::component::Page;
16use reinhardt_pages::page;
17use std::collections::HashMap;
18
19fn reverse_admin_url(route_name: &str, params: &[(&str, &str)]) -> String {
20	crate::pages::router::try_with_router(|router| router.reverse(route_name, params))
21		.unwrap_or_else(|| crate::pages::router::init_router().reverse(route_name, params))
22		.unwrap_or_else(|err| panic!("failed to reverse admin route `{}`: {}", route_name, err))
23}
24
25fn admin_model_url(route_name: &str, model_name: &str) -> String {
26	let model = model_name.to_lowercase();
27	reverse_admin_url(route_name, &[("model", &model)])
28}
29
30fn admin_record_url(route_name: &str, model_name: &str, record_id: &str) -> String {
31	let model = model_name.to_lowercase();
32	reverse_admin_url(route_name, &[("model", &model), ("id", record_id)])
33}
34
35/// Dashboard component
36///
37/// Displays the admin dashboard with model cards.
38///
39/// # Example
40///
41/// ```ignore
42/// use reinhardt_admin::pages::components::features::dashboard;
43/// use reinhardt_admin::types::ModelInfo;
44///
45/// let models = vec![
46///     ModelInfo { name: "Users".to_string(), list_url: "/admin/users/".to_string() },
47///     ModelInfo { name: "Posts".to_string(), list_url: "/admin/posts/".to_string() },
48/// ];
49/// dashboard("My Admin Panel", &models)
50/// ```
51pub fn dashboard(site_name: &str, models: &[ModelInfo]) -> Page {
52	let site_name = site_name.to_string();
53	let grid = models_grid(models);
54
55	page!(|site_name: String, grid: Page| {
56		div {
57			class: "dashboard animate__animated animate__fadeIn",
58			h1 {
59				class: "font-display text-2xl font-bold text-slate-900 mb-6",
60				{ format!("{} Dashboard", site_name) }
61			}
62			{ grid }
63		}
64	})(site_name, grid)
65}
66
67/// Generates a grid of model cards
68fn models_grid(models: &[ModelInfo]) -> Page {
69	if models.is_empty() {
70		return page!(|| {
71			div {
72				class: "admin-alert admin-alert-info",
73				"No models registered. Add models to AdminSite to see them here."
74			}
75		})();
76	}
77
78	let card_views: Vec<Page> = models
79		.iter()
80		.map(|model| model_card(&model.name, &model.list_url))
81		.collect();
82
83	page!(|card_views: Vec<Page>| {
84		div {
85			class: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4",
86			{ card_views }
87		}
88	})(card_views)
89}
90
91/// Generates a single model card
92fn model_card(name: &str, url: &str) -> Page {
93	let name = name.to_string();
94	let url = url.to_string();
95	let label = format!("View {}", &name);
96	let manage_text = format!("Manage {} records", &name);
97
98	page!(|name: String, url: String, label: String, manage_text: String| {
99		div {
100			class: "admin-card p-5 flex flex-col animate__animated animate__fadeInUp",
101			h3 {
102				class: "font-display text-lg font-bold text-slate-900 mb-1",
103				{ name }
104			}
105			p {
106				class: "text-sm text-slate-500 mb-4 flex-1",
107				{ manage_text }
108			}
109			a {
110				class: "admin-btn admin-btn-primary text-center",
111				href: url,
112				{ label }
113			}
114		}
115	})(name, url, label, manage_text)
116}
117
118/// Column definition for list view
119#[derive(Debug, Clone)]
120pub struct Column {
121	/// Column field name
122	pub field: String,
123	/// Column display label
124	pub label: String,
125	/// Whether this column is sortable
126	pub sortable: bool,
127}
128
129/// List view data structure
130#[derive(Debug, Clone)]
131pub struct ListViewData {
132	/// Model name
133	pub model_name: String,
134	/// Column definitions
135	pub columns: Vec<Column>,
136	/// Record data (each record is a HashMap of field -> value)
137	pub records: Vec<std::collections::HashMap<String, String>>,
138	/// Current page number (1-indexed)
139	pub current_page: u64,
140	/// Total number of pages
141	pub total_pages: u64,
142	/// Total number of records
143	pub total_count: u64,
144	/// Filter information
145	pub filters: Vec<FilterInfo>,
146}
147
148/// List view component
149///
150/// Displays a paginated list of records with filters and search.
151///
152/// # Example
153///
154/// ```ignore
155/// use reinhardt_admin::pages::components::features::{list_view, ListViewData, Column};
156/// use reinhardt_pages::Signal;
157/// use std::collections::HashMap;
158///
159/// let data = ListViewData {
160///     model_name: "User".to_string(),
161///     columns: vec![
162///         Column { field: "id".to_string(), label: "ID".to_string(), sortable: true },
163///         Column { field: "username".to_string(), label: "Username".to_string(), sortable: true },
164///     ],
165///     records: vec![/* ... */],
166///     current_page: 1,
167///     total_pages: 5,
168///     total_count: 42,
169///     filters: vec![],
170/// };
171/// let page_signal = Signal::new(1u64);
172/// let filters_signal = Signal::new(HashMap::new());
173/// list_view(&data, page_signal, filters_signal)
174/// ```
175pub fn list_view(
176	data: &ListViewData,
177	current_page_signal: reinhardt_pages::Signal<u64>,
178	filters_signal: Signal<HashMap<String, String>>,
179) -> Page {
180	let title = format!("{} List", data.model_name);
181	let summary = format!(
182		"Showing {} {} (Page {} of {})",
183		data.total_count, data.model_name, data.current_page, data.total_pages
184	);
185	let filters_page = filters(&data.filters, filters_signal);
186	let table_page = data_table(&data.columns, &data.records, &data.model_name);
187	let pagination_page =
188		crate::pages::components::common::pagination(current_page_signal, data.total_pages);
189	let add_url = admin_model_url("create", &data.model_name);
190	let add_label = format!("Add {}", data.model_name);
191	let add_link = {
192		use reinhardt_pages::component::Component;
193		use reinhardt_pages::router::Link;
194		Link::new(add_url, add_label)
195			.class("admin-btn admin-btn-primary")
196			.render()
197	};
198
199	page!(|title: String,
200	 add_link: Page,
201	 filters_page: Page,
202	 summary: String,
203	 table_page: Page,
204	 pagination_page: Page| {
205		div {
206			class: "list-view animate__animated animate__fadeIn",
207			div {
208				class: "mb-6 flex items-center justify-between gap-4",
209				h1 {
210					class: "font-display text-2xl font-bold text-slate-900",
211					{ title }
212				}
213				{ add_link }
214			}
215			{ filters_page }
216			div {
217				class: "text-sm text-slate-500 mb-4",
218				{ summary }
219			}
220			{ table_page }
221			{ pagination_page }
222		}
223	})(
224		title,
225		add_link,
226		filters_page,
227		summary,
228		table_page,
229		pagination_page,
230	)
231}
232
233/// Generates a data table
234fn data_table(
235	columns: &[Column],
236	records: &[std::collections::HashMap<String, String>],
237	model_name: &str,
238) -> Page {
239	let header_cells: Vec<Page> = columns
240		.iter()
241		.map(|col| {
242			let label = col.label.clone();
243			page!(|label: String| {
244				th { { label } }
245			})(label)
246		})
247		.chain(std::iter::once(page!(|| {
248			th { "Actions" }
249		})()))
250		.collect();
251
252	let thead = page!(|header_cells: Vec<Page>| {
253		thead {
254			tr { { header_cells } }
255		}
256	})(header_cells);
257
258	let body_rows: Vec<Page> = records
259		.iter()
260		.map(|record| table_row(columns, record, model_name))
261		.collect();
262
263	let tbody = page!(|body_rows: Vec<Page>| {
264		tbody { { body_rows } }
265	})(body_rows);
266
267	page!(|thead: Page, tbody: Page| {
268		div {
269			class: "overflow-x-auto rounded-lg border border-slate-200",
270			table {
271				class: "admin-table",
272				{ thead }
273				{ tbody }
274			}
275		}
276	})(thead, tbody)
277}
278
279/// Generates a table row for a single record
280fn table_row(
281	columns: &[Column],
282	record: &std::collections::HashMap<String, String>,
283	model_name: &str,
284) -> Page {
285	let data_cells: Vec<Page> = columns
286		.iter()
287		.map(|col| {
288			let value = record
289				.get(&col.field)
290				.cloned()
291				.unwrap_or_else(|| "-".to_string());
292			page!(|value: String| {
293				td { { value } }
294			})(value)
295		})
296		.collect();
297
298	let record_id = record.get("id").cloned().unwrap_or_else(|| "0".to_string());
299	let actions = action_buttons(model_name, &record_id);
300	let actions_cell = page!(|actions: Page| {
301		td { { actions } }
302	})(actions);
303
304	page!(|data_cells: Vec<Page>, actions_cell: Page| {
305		tr {
306			{ data_cells }
307			{ actions_cell }
308		}
309	})(data_cells, actions_cell)
310}
311
312/// Generates action buttons for a record
313fn action_buttons(model_name: &str, record_id: &str) -> Page {
314	use reinhardt_pages::component::Component;
315	use reinhardt_pages::router::Link;
316
317	let detail_url = admin_record_url("detail", model_name, record_id);
318	let edit_url = admin_record_url("edit", model_name, record_id);
319
320	let view_link = Link::new(detail_url, "View")
321		.class("admin-btn admin-btn-outline admin-btn-sm")
322		.render();
323	let edit_link = Link::new(edit_url, "Edit")
324		.class("admin-btn admin-btn-outline admin-btn-sm")
325		.render();
326
327	page!(|view_link: Page, edit_link: Page| {
328		div {
329			class: "flex gap-1",
330			{ view_link }
331			{ edit_link }
332		}
333	})(view_link, edit_link)
334}
335
336/// Form field definition for model forms
337#[derive(Debug, Clone)]
338pub struct FormField {
339	/// Field name (corresponds to database column)
340	pub name: String,
341	/// Field display label
342	pub label: String,
343	/// Rendering specification (input type, textarea, select, etc.)
344	pub spec: crate::types::FormFieldSpec,
345	/// Whether this field is required
346	pub required: bool,
347	/// Current field value (for edit forms)
348	pub value: String,
349}
350
351/// Detail view component
352///
353/// Displays detailed information about a single record.
354///
355/// # Example
356///
357/// ```ignore
358/// use reinhardt_admin::pages::components::features::detail_view;
359/// use std::collections::HashMap;
360///
361/// let mut record = HashMap::new();
362/// record.insert("id".to_string(), "1".to_string());
363/// record.insert("username".to_string(), "john_doe".to_string());
364/// detail_view("User", "1", &record)
365/// ```
366pub fn detail_view(
367	model_name: &str,
368	record_id: &str,
369	record: &std::collections::HashMap<String, String>,
370) -> Page {
371	use reinhardt_pages::component::Component;
372	use reinhardt_pages::router::Link;
373
374	let edit_url = admin_record_url("edit", model_name, record_id);
375	let list_url = admin_model_url("list", model_name);
376
377	let title = format!("{} Detail", model_name);
378	let table_page = detail_table(record);
379	let edit_link = Link::new(edit_url, "Edit")
380		.class("admin-btn admin-btn-primary mr-2")
381		.render();
382	let back_link = Link::new(list_url.clone(), "Back to List")
383		.class("admin-btn admin-btn-secondary")
384		.render();
385	let delete_model = model_name.to_string();
386	let delete_id = record_id.to_string();
387	let delete_return_url = list_url.clone();
388
389	page!(|title: String,
390	 table_page: Page,
391	 edit_link: Page,
392	 back_link: Page,
393	 delete_model: String,
394	 delete_id: String,
395	 delete_return_url: String| {
396		div {
397			class: "detail-view animate__animated animate__fadeIn",
398			h1 {
399				class: "font-display text-2xl font-bold text-slate-900 mb-6",
400				{ title }
401			}
402			{ table_page }
403			div {
404				class: "mt-6 flex gap-2",
405				{ edit_link }
406				{ back_link }
407				button {
408					type: "button",
409					class: "admin-btn admin-btn-danger",
410					@click: move |_| {
411						#[cfg(client)]
412						crate::pages::components::features::delete_model_record(
413							delete_model.clone(),
414							delete_id.clone(),
415							delete_return_url.clone(),
416						);
417					},
418					"Delete"
419				}
420			}
421		}
422	})(
423		title,
424		table_page,
425		edit_link,
426		back_link,
427		delete_model,
428		delete_id,
429		delete_return_url,
430	)
431}
432
433/// Generates a detail table for record fields
434fn detail_table(record: &std::collections::HashMap<String, String>) -> Page {
435	// Collect key-value pairs and sort by key for deterministic field display order
436	let mut entries: Vec<(&String, &String)> = record.iter().collect();
437	entries.sort_by_key(|(k, _)| *k);
438	let rows: Vec<Page> = entries
439		.into_iter()
440		.map(|(key, value)| {
441			let key = key.clone();
442			let value = value.clone();
443			page!(|key: String, value: String| {
444				tr {
445					th {
446						class: "w-1/4 text-left text-sm font-medium text-slate-500 py-3 px-4 bg-slate-50",
447						{ key }
448					}
449					td {
450						class: "text-sm text-slate-800 py-3 px-4",
451						{ value }
452					}
453				}
454			})(key, value)
455		})
456		.collect();
457
458	page!(|rows: Vec<Page>| {
459		div {
460			class: "overflow-x-auto rounded-lg border border-slate-200",
461			table {
462				class: "admin-table",
463				tbody { { rows } }
464			}
465		}
466	})(rows)
467}
468
469/// Model form component
470///
471/// Displays a form for creating or editing a record.
472///
473/// # Example
474///
475/// ```ignore
476/// use reinhardt_admin::pages::components::features::{model_form, FormField};
477/// use reinhardt_admin::types::FormFieldSpec;
478///
479/// let fields = vec![
480///     FormField {
481///         name: "username".to_string(),
482///         label: "Username".to_string(),
483///         spec: FormFieldSpec::Input { html_type: "text".to_string() },
484///         required: true,
485///         value: "".to_string(),
486///     },
487/// ];
488/// model_form("User", &fields, None)
489/// ```
490pub fn model_form(model_name: &str, fields: &[FormField], record_id: Option<&str>) -> Page {
491	use reinhardt_pages::component::Component;
492	use reinhardt_pages::router::Link;
493
494	let form_title = if record_id.is_some() {
495		format!("Edit {}", model_name)
496	} else {
497		format!("Create {}", model_name)
498	};
499
500	let action_url = if let Some(rid) = record_id {
501		admin_record_url("edit", model_name, rid)
502	} else {
503		admin_model_url("create", model_name)
504	};
505
506	let list_url = admin_model_url("list", model_name);
507
508	let form_fields: Vec<Page> = fields.iter().map(form_group).collect();
509	let form_groups = page!(|form_fields: Vec<Page>| {
510		div {
511			class: "admin-card p-6",
512			{ form_fields }
513		}
514	})(form_fields);
515	let cancel_link = Link::new(list_url.clone(), "Cancel")
516		.class("admin-btn admin-btn-secondary")
517		.render();
518	let submit_model = model_name.to_string();
519	let submit_record_id = record_id.map(str::to_string);
520	let submit_return_url = list_url.clone();
521
522	page!(|form_title: String,
523	 action_url: String,
524	 form_groups: Page,
525	 cancel_link: Page,
526	 submit_model: String,
527	 submit_record_id: Option<String>,
528	 submit_return_url: String| {
529		div {
530			class: "model-form max-w-2xl animate__animated animate__fadeIn",
531			h1 {
532				class: "font-display text-2xl font-bold text-slate-900 mb-6",
533				{ form_title }
534			}
535			form {
536				method: "post",
537				action: action_url,
538				@submit: move |event| {
539					event.prevent_default();
540					#[cfg(client)]
541					crate::pages::components::features::submit_model_form(
542						event,
543						submit_model.clone(),
544						submit_record_id.clone(),
545						submit_return_url.clone(),
546					);
547				},
548				{ form_groups }
549				div {
550					class: "mt-6 flex gap-2",
551					button {
552						class: "admin-btn admin-btn-primary",
553						type: "submit",
554						"Save"
555					}
556					{ cancel_link }
557				}
558			}
559		}
560	})(
561		form_title,
562		action_url,
563		form_groups,
564		cancel_link,
565		submit_model,
566		submit_record_id,
567		submit_return_url,
568	)
569}
570
571#[cfg(client)]
572fn submit_model_form(
573	event: web_sys::Event,
574	model_name: String,
575	record_id: Option<String>,
576	return_url: String,
577) {
578	let request = collect_mutation_request(&event);
579	reinhardt_pages::platform::spawn_task(async move {
580		let result = if let Some(id) = record_id {
581			update_record(model_name, id, request).await
582		} else {
583			create_record(model_name, request).await
584		};
585
586		match result {
587			Ok(_) => navigate_or_set_href(&return_url),
588			Err(e) => report_admin_error(&format!("Save failed: {}", e)),
589		}
590	});
591}
592
593#[cfg(client)]
594fn delete_model_record(model_name: String, record_id: String, return_url: String) {
595	let confirmed = web_sys::window()
596		.and_then(|w| w.confirm_with_message("Delete this record?").ok())
597		.unwrap_or(false);
598	if !confirmed {
599		return;
600	}
601
602	let csrf_token = reinhardt_pages::csrf::get_csrf_token().unwrap_or_default();
603	reinhardt_pages::platform::spawn_task(async move {
604		match delete_record(model_name, record_id, csrf_token).await {
605			Ok(_) => navigate_or_set_href(&return_url),
606			Err(e) => report_admin_error(&format!("Delete failed: {}", e)),
607		}
608	});
609}
610
611#[cfg(client)]
612fn collect_mutation_request(event: &web_sys::Event) -> crate::types::MutationRequest {
613	use wasm_bindgen::JsCast;
614
615	let mut data = HashMap::new();
616	let target = event.target().or_else(|| event.current_target());
617	if let Some(target) = target
618		&& let Ok(form) = target.dyn_into::<web_sys::HtmlFormElement>()
619	{
620		let elements = form.elements();
621		for index in 0..elements.length() {
622			let Some(element) = elements.item(index) else {
623				continue;
624			};
625			collect_form_control_value(&element, &mut data);
626		}
627	}
628
629	crate::types::MutationRequest {
630		csrf_token: reinhardt_pages::csrf::get_csrf_token().unwrap_or_default(),
631		data,
632	}
633}
634
635#[cfg(client)]
636fn collect_form_control_value(
637	element: &web_sys::Element,
638	data: &mut HashMap<String, serde_json::Value>,
639) {
640	use wasm_bindgen::JsCast;
641
642	if let Some(input) = element.dyn_ref::<web_sys::HtmlInputElement>() {
643		let name = input.name();
644		if name.is_empty() {
645			return;
646		}
647		let value = if input.type_() == "checkbox" {
648			serde_json::Value::Bool(input.checked())
649		} else {
650			form_value_to_json(&name, &input.value(), input.type_() == "number")
651		};
652		data.insert(name, value);
653		return;
654	}
655
656	if let Some(textarea) = element.dyn_ref::<web_sys::HtmlTextAreaElement>() {
657		let name = textarea.name();
658		if !name.is_empty() {
659			data.insert(name, serde_json::Value::String(textarea.value()));
660		}
661		return;
662	}
663
664	if let Some(select) = element.dyn_ref::<web_sys::HtmlSelectElement>() {
665		let name = select.name();
666		if !name.is_empty() {
667			data.insert(name.clone(), select_value_to_json(select, &name));
668		}
669	}
670}
671
672#[cfg(client)]
673fn select_value_to_json(select: &web_sys::HtmlSelectElement, name: &str) -> serde_json::Value {
674	use wasm_bindgen::JsCast;
675
676	if !select.multiple() {
677		return form_value_to_json(name, &select.value(), false);
678	}
679
680	let options = select.options();
681	let values: Vec<String> = (0..options.length())
682		.filter_map(|index| {
683			let option = options.item(index)?;
684			let option = option.dyn_into::<web_sys::HtmlOptionElement>().ok()?;
685			option.selected().then(|| option.value())
686		})
687		.collect();
688
689	form_values_to_json_array(name, &values)
690}
691
692#[cfg(any(client, test))]
693fn form_values_to_json_array(name: &str, values: &[String]) -> serde_json::Value {
694	serde_json::Value::Array(
695		values
696			.iter()
697			.map(|value| form_value_to_json(name, value, false))
698			.collect(),
699	)
700}
701
702#[cfg(any(client, test))]
703fn form_value_to_json(name: &str, value: &str, prefer_number: bool) -> serde_json::Value {
704	if prefer_number || name.ends_with("_id") {
705		if value.trim().is_empty() {
706			return serde_json::Value::Null;
707		}
708		if let Ok(value) = value.parse::<i64>() {
709			return serde_json::Value::Number(value.into());
710		}
711		if let Ok(value) = value.parse::<f64>()
712			&& let Some(number) = serde_json::Number::from_f64(value)
713		{
714			return serde_json::Value::Number(number);
715		}
716	}
717	serde_json::Value::String(value.to_string())
718}
719
720#[cfg(client)]
721fn navigate_or_set_href(url: &str) {
722	if reinhardt_pages::navigate(url.to_string(), reinhardt_pages::NavigationType::Push).is_err()
723		&& let Some(window) = web_sys::window()
724	{
725		let _ = window.location().set_href(url);
726	}
727}
728
729#[cfg(client)]
730fn report_admin_error(message: &str) {
731	web_sys::console::error_1(&message.into());
732	if let Some(window) = web_sys::window() {
733		let _ = window.alert_with_message(message);
734	}
735}
736
737/// Generates a form group (label + input) for a field
738fn form_group(field: &FormField) -> Page {
739	let input_id = format!("field-{}", field.name);
740	let label = field.label.clone();
741	let input = form_element(field, &input_id);
742
743	page!(|input_id: String, label: String, input: Page| {
744		div {
745			class: "mb-4",
746			label {
747				for: input_id,
748				class: "admin-label",
749				{ label }
750			}
751			{ input }
752		}
753	})(input_id, label, input)
754}
755
756/// Render `<option>` elements for a list of `(value, label)` choices,
757/// marking each option whose value appears in `selected` as `selected`.
758///
759/// `selected` is a slice so that both single-select (`[current]`) and
760/// multi-select (`split` of the `FormField::value` string) can share the
761/// same renderer. See `parse_multi_value` for the multi-select wire format.
762fn render_option_elements(choices: &[(String, String)], selected: &[&str]) -> Vec<Page> {
763	choices
764		.iter()
765		.map(|(value, label)| {
766			let value = value.clone();
767			let label = label.clone();
768			let is_selected = selected.iter().any(|s| *s == value);
769			if is_selected {
770				page!(|value: String, label: String| {
771					option {
772						value: value,
773						selected: true,
774						{ label }
775					}
776				})(value, label)
777			} else {
778				page!(|value: String, label: String| {
779					option {
780						value: value,
781						{ label }
782					}
783				})(value, label)
784			}
785		})
786		.collect()
787}
788
789/// Multi-select wire format: `FormField::value` carries the selected values
790/// as a comma-separated list (e.g., `"read,write,delete"`). Empty entries
791/// are skipped so an empty value yields no selected options.
792fn parse_multi_value(raw: &str) -> Vec<&str> {
793	raw.split(',')
794		.map(str::trim)
795		.filter(|s| !s.is_empty())
796		.collect()
797}
798
799/// Generates an input element for a form field
800fn form_element(field: &FormField, input_id: &str) -> Page {
801	use crate::types::FormFieldSpec;
802
803	let input_id = input_id.to_string();
804	let name = field.name.clone();
805	let value = field.value.clone();
806	let required = field.required;
807
808	match &field.spec {
809		FormFieldSpec::Input { html_type } => {
810			render_input(html_type.clone(), input_id, name, value, required)
811		}
812		FormFieldSpec::File => render_input("file".to_string(), input_id, name, value, required),
813		FormFieldSpec::Hidden => {
814			render_input("hidden".to_string(), input_id, name, value, required)
815		}
816		FormFieldSpec::TextArea => {
817			if required {
818				page!(|input_id: String, name: String, value: String| {
819					textarea {
820						class: "admin-input",
821						id: input_id,
822						name: name,
823						required: true,
824						autocomplete: "off",
825						{ value }
826					}
827				})(input_id, name, value)
828			} else {
829				page!(|input_id: String, name: String, value: String| {
830					textarea {
831						class: "admin-input",
832						id: input_id,
833						name: name,
834						autocomplete: "off",
835						{ value }
836					}
837				})(input_id, name, value)
838			}
839		}
840		FormFieldSpec::Select { choices } => {
841			let options = render_option_elements(choices, &[value.as_str()]);
842			if required {
843				page!(|input_id: String, name: String, options: Vec<Page>| {
844					select {
845						class: "admin-select",
846						id: input_id,
847						name: name,
848						required: true,
849						{ options }
850					}
851				})(input_id, name, options)
852			} else {
853				page!(|input_id: String, name: String, options: Vec<Page>| {
854					select {
855						class: "admin-select",
856						id: input_id,
857						name: name,
858						{ options }
859					}
860				})(input_id, name, options)
861			}
862		}
863		FormFieldSpec::MultiSelect { choices } => {
864			let selected = parse_multi_value(&value);
865			let options = render_option_elements(choices, &selected);
866			if required {
867				page!(|input_id: String, name: String, options: Vec<Page>| {
868					select {
869						class: "admin-select",
870						id: input_id,
871						name: name,
872						multiple: true,
873						required: true,
874						{ options }
875					}
876				})(input_id, name, options)
877			} else {
878				page!(|input_id: String, name: String, options: Vec<Page>| {
879					select {
880						class: "admin-select",
881						id: input_id,
882						name: name,
883						multiple: true,
884						{ options }
885					}
886				})(input_id, name, options)
887			}
888		}
889	}
890}
891
892/// Render an `<input>` element with the given HTML `type`.
893fn render_input(
894	html_type: String,
895	input_id: String,
896	name: String,
897	value: String,
898	required: bool,
899) -> Page {
900	if required {
901		page!(|html_type: String, input_id: String, name: String, value: String| {
902			input {
903				class: "admin-input",
904				type: html_type,
905				id: input_id,
906				name: name,
907				value: value,
908				required: true,
909				autocomplete: "off",
910			}
911		})(html_type, input_id, name, value)
912	} else {
913		page!(|html_type: String, input_id: String, name: String, value: String| {
914			input {
915				class: "admin-input",
916				type: html_type,
917				id: input_id,
918				name: name,
919				value: value,
920				autocomplete: "off",
921			}
922		})(html_type, input_id, name, value)
923	}
924}
925
926/// Convert FilterType to choice list
927///
928/// Generates a list of (value, label) pairs for select options.
929/// Always includes an "All" option as the first choice.
930fn filter_type_to_choices(filter_type: &FilterType) -> Vec<(String, String)> {
931	let mut choices = vec![("".to_string(), "All".to_string())];
932
933	match filter_type {
934		FilterType::Boolean => {
935			choices.push(("true".to_string(), "Yes".to_string()));
936			choices.push(("false".to_string(), "No".to_string()));
937		}
938		FilterType::Choice {
939			choices: filter_choices,
940		} => {
941			for choice in filter_choices {
942				choices.push((choice.value.clone(), choice.label.clone()));
943			}
944		}
945		FilterType::DateRange { ranges } => {
946			for range in ranges {
947				choices.push((range.value.clone(), range.label.clone()));
948			}
949		}
950		FilterType::NumberRange { ranges } => {
951			for range in ranges {
952				choices.push((range.value.clone(), range.label.clone()));
953			}
954		}
955	}
956
957	choices
958}
959
960/// Create filter select element
961///
962/// Generates a <select> element for a filter field.
963fn create_filter_select(
964	field: &str,
965	filter_type: &FilterType,
966	current_value: Option<&str>,
967	filters_signal: Signal<HashMap<String, String>>,
968) -> Page {
969	let choices = filter_type_to_choices(filter_type);
970	let current_val = current_value.unwrap_or("");
971
972	// Generate <option> elements
973	let options: Vec<Page> = choices
974		.iter()
975		.map(|(value, label)| {
976			let value = value.clone();
977			let label = label.clone();
978			if value == current_val {
979				page!(|value: String, label: String| {
980					option {
981						value: value,
982						selected: true,
983						{ label }
984					}
985				})(value, label)
986			} else {
987				page!(|value: String, label: String| {
988					option {
989						value: value,
990						{ label }
991					}
992				})(value, label)
993			}
994		})
995		.collect();
996	let options_container = page!(|options: Vec<Page>| {
997		span { { options } }
998	})(options);
999	let field_str = field.to_string();
1000
1001	page!(|field_str: String,
1002	 _filters_signal: Signal<HashMap<String, String>>,
1003	 options_container: Page| {
1004		select {
1005			class: "admin-select",
1006			data_filter_field: field_str.clone(),
1007			@change: move |event| {
1008				use wasm_bindgen::JsCast;
1009				if let Some(target) = event.target() {
1010					if let Ok(select_el) = target.dyn_into::<web_sys::HtmlSelectElement>() {
1011						let value = select_el.value();
1012						let field = field_str.clone();
1013						_filters_signal.update(move |map| {
1014							if value.is_empty() {
1015								map.remove(&field);
1016							} else {
1017								map.insert(field, value);
1018							}
1019						});
1020					}
1021				}
1022			},
1023			{ options_container }
1024		}
1025	})(field_str, filters_signal, options_container)
1026}
1027
1028/// Create filter control (label + select)
1029///
1030/// Generates a complete filter control with label and select element.
1031fn create_filter_control(
1032	filter_info: &FilterInfo,
1033	current_value: Option<&str>,
1034	filters_signal: Signal<HashMap<String, String>>,
1035) -> Page {
1036	let label = filter_info.title.clone();
1037	let select = create_filter_select(
1038		&filter_info.field,
1039		&filter_info.filter_type,
1040		current_value,
1041		filters_signal,
1042	);
1043
1044	page!(|label: String, select: Page| {
1045		div {
1046			class: "min-w-48",
1047			label {
1048				class: "admin-label",
1049				{ label }
1050			}
1051			{ select }
1052		}
1053	})(label, select)
1054}
1055
1056/// Filters component
1057///
1058/// Displays filter controls for list views.
1059/// Uses Signal to track current filter values.
1060///
1061/// # Example
1062///
1063/// ```ignore
1064/// use reinhardt_admin::pages::components::features::filters;
1065/// use reinhardt_admin::types::{FilterInfo, FilterType};
1066/// use reinhardt_pages::Signal;
1067/// use std::collections::HashMap;
1068///
1069/// let filters_signal = Signal::new(HashMap::new());
1070/// let filter_infos = vec![
1071///     FilterInfo {
1072///         field: "status".to_string(),
1073///         title: "Status".to_string(),
1074///         filter_type: FilterType::Boolean,
1075///         current_value: None,
1076///     },
1077/// ];
1078/// filters(&filter_infos, filters_signal)
1079/// ```
1080pub fn filters(
1081	filters_info: &[FilterInfo],
1082	filters_signal: Signal<HashMap<String, String>>,
1083) -> Page {
1084	if filters_info.is_empty() {
1085		return page!(|| { div {} })();
1086	}
1087
1088	let current_filters = filters_signal.get();
1089
1090	let filter_controls: Vec<Page> = filters_info
1091		.iter()
1092		.map(|info| {
1093			let current_value = current_filters.get(&info.field).map(|s| s.as_str());
1094			create_filter_control(info, current_value, filters_signal.clone())
1095		})
1096		.collect();
1097
1098	let filter_controls = page!(|filter_controls: Vec<Page>| {
1099		div {
1100			class: "flex flex-wrap gap-4",
1101			{ filter_controls }
1102		}
1103	})(filter_controls);
1104
1105	page!(|filter_controls: Page| {
1106		div {
1107			class: "admin-card p-4 mb-4",
1108			h5 {
1109				class: "text-xs font-semibold uppercase tracking-wider text-slate-500 mb-3",
1110				"Filters"
1111			}
1112			{ filter_controls }
1113		}
1114	})(filter_controls)
1115}
1116
1117#[cfg(all(test, server))]
1118mod tests {
1119	use super::{detail_table, form_value_to_json, form_values_to_json_array};
1120	use rstest::rstest;
1121	use serde_json::json;
1122	use std::collections::HashMap;
1123
1124	/// Verifies that detail_table renders fields in alphabetical order regardless
1125	/// of HashMap insertion order.
1126	#[rstest]
1127	fn test_detail_table_renders_fields_in_alphabetical_order() {
1128		// Arrange
1129		let mut record = HashMap::new();
1130		record.insert("zebra".to_string(), "z_value".to_string());
1131		record.insert("alpha".to_string(), "a_value".to_string());
1132		record.insert("middle".to_string(), "m_value".to_string());
1133
1134		// Act
1135		let page = detail_table(&record);
1136		let html = page.render_to_string();
1137
1138		// Assert: alpha must appear before middle, and middle before zebra
1139		let pos_alpha = html.find("alpha").expect("alpha field must be present");
1140		let pos_middle = html.find("middle").expect("middle field must be present");
1141		let pos_zebra = html.find("zebra").expect("zebra field must be present");
1142		assert!(
1143			pos_alpha < pos_middle,
1144			"alpha must appear before middle in rendered output"
1145		);
1146		assert!(
1147			pos_middle < pos_zebra,
1148			"middle must appear before zebra in rendered output"
1149		);
1150	}
1151
1152	/// Verifies that detail_table renders associated values alongside their keys.
1153	#[rstest]
1154	fn test_detail_table_renders_key_value_pairs() {
1155		// Arrange
1156		let mut record = HashMap::new();
1157		record.insert("username".to_string(), "john_doe".to_string());
1158		record.insert("email".to_string(), "john@example.com".to_string());
1159
1160		// Act
1161		let page = detail_table(&record);
1162		let html = page.render_to_string();
1163
1164		// Assert
1165		assert!(
1166			html.contains("username"),
1167			"key 'username' must appear in output"
1168		);
1169		assert!(
1170			html.contains("john_doe"),
1171			"value 'john_doe' must appear in output"
1172		);
1173		assert!(html.contains("email"), "key 'email' must appear in output");
1174		assert!(
1175			html.contains("john@example.com"),
1176			"value 'john@example.com' must appear in output"
1177		);
1178	}
1179
1180	#[rstest]
1181	fn test_form_value_to_json_converts_id_values() {
1182		assert_eq!(form_value_to_json("owner_id", "42", false), json!(42));
1183		assert_eq!(
1184			form_value_to_json("owner_id", "", false),
1185			serde_json::Value::Null
1186		);
1187		assert_eq!(form_value_to_json("title", "42", false), json!("42"));
1188	}
1189
1190	#[rstest]
1191	fn test_form_values_to_json_array_preserves_all_values() {
1192		let values = vec![
1193			"read".to_string(),
1194			"write".to_string(),
1195			"delete".to_string(),
1196		];
1197
1198		assert_eq!(
1199			form_values_to_json_array("permissions", &values),
1200			json!(["read", "write", "delete"])
1201		);
1202	}
1203}