1#[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
35pub 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
67fn 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
91fn 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#[derive(Debug, Clone)]
120pub struct Column {
121 pub field: String,
123 pub label: String,
125 pub sortable: bool,
127}
128
129#[derive(Debug, Clone)]
131pub struct ListViewData {
132 pub model_name: String,
134 pub columns: Vec<Column>,
136 pub records: Vec<std::collections::HashMap<String, String>>,
138 pub current_page: u64,
140 pub total_pages: u64,
142 pub total_count: u64,
144 pub filters: Vec<FilterInfo>,
146}
147
148pub 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
233fn 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
279fn 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
312fn 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#[derive(Debug, Clone)]
338pub struct FormField {
339 pub name: String,
341 pub label: String,
343 pub spec: crate::types::FormFieldSpec,
345 pub required: bool,
347 pub value: String,
349}
350
351pub 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
433fn detail_table(record: &std::collections::HashMap<String, String>) -> Page {
435 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
469pub 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
737fn 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
756fn 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
789fn parse_multi_value(raw: &str) -> Vec<&str> {
793 raw.split(',')
794 .map(str::trim)
795 .filter(|s| !s.is_empty())
796 .collect()
797}
798
799fn 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
892fn 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
926fn 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
960fn 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 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
1028fn 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
1056pub 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 #[rstest]
1127 fn test_detail_table_renders_fields_in_alphabetical_order() {
1128 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 let page = detail_table(&record);
1136 let html = page.render_to_string();
1137
1138 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 #[rstest]
1154 fn test_detail_table_renders_key_value_pairs() {
1155 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 let page = detail_table(&record);
1162 let html = page.render_to_string();
1163
1164 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}