1use std::collections::HashMap;
12use std::sync::Arc;
13
14use askama::Template;
15use axum::response::{IntoResponse, Response};
16use chrono::DateTime;
17use chrono_tz::Tz;
18use laterite_core::query::{bind_values, bind_values_as, build, text_cast};
19use laterite_core::search::SearchProfile;
20use laterite_core::{AnyRowExt, Db, DbBackend, Text};
21use sea_query::{Alias, Expr, Order, Query, SimpleExpr};
22use serde::{Deserialize, Serialize};
23
24use crate::field::{OverrideResolver, OverrideScope, Surface};
25use crate::html::Markup;
26use crate::sql::valid_ident;
27use crate::{render, render_error, AdminState};
28
29const ID_ALIAS: &str = "_lat_id";
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum SortDir {
34 Asc,
35 Desc,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum Align {
44 Left,
45 Center,
46 Right,
47}
48
49impl Align {
50 fn class(self) -> &'static str {
51 match self {
52 Align::Left => "lat-col--left",
53 Align::Center => "lat-col--center",
54 Align::Right => "lat-col--right",
55 }
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct ValueLabel {
63 pub value: String,
64 pub label: Text,
66}
67
68impl ValueLabel {
69 pub fn new(value: impl Into<String>, label: impl Into<Text>) -> Self {
70 Self {
71 value: value.into(),
72 label: label.into(),
73 }
74 }
75}
76
77#[derive(Debug, Clone)]
78#[non_exhaustive]
79pub struct ListColumn {
80 pub field: String,
82 pub label: Text,
84 pub column_type: String,
86 pub options: serde_json::Value,
89 pub sortable: bool,
91 pub invisible: bool,
93 pub width: Option<String>,
95 pub align: Option<Align>,
96 pub permission: Option<String>,
99 pub searchable: Option<bool>,
103 pub labels: Vec<ValueLabel>,
106 pub editable: bool,
109}
110
111impl ListColumn {
112 pub fn new(field: &str, label: impl Into<Text>) -> Self {
113 Self {
114 field: field.to_string(),
115 label: label.into(),
116 column_type: "text".to_string(),
117 sortable: true,
118 invisible: false,
119 width: None,
120 align: None,
121 permission: None,
122 searchable: None,
123 options: serde_json::Value::Null,
124 labels: Vec::new(),
125 editable: false,
126 }
127 }
128
129 pub fn labels(mut self, labels: Vec<ValueLabel>) -> Self {
131 self.labels = labels;
132 self
133 }
134
135 pub fn editable(mut self) -> Self {
137 self.editable = true;
138 self
139 }
140
141 fn of(mut self, column_type: &str) -> Self {
142 self.column_type = column_type.to_string();
143 self
144 }
145
146 pub fn datetime(self) -> Self {
148 self.of("datetime")
149 }
150 pub fn date(self) -> Self {
152 self.of("date")
153 }
154 pub fn time(self) -> Self {
156 self.of("time")
157 }
158 pub fn yes_no(self) -> Self {
160 self.of("boolean")
161 }
162 pub fn pill(self) -> Self {
164 self.of("status_pill")
165 }
166
167 pub fn searchable(mut self, searchable: bool) -> Self {
169 self.searchable = Some(searchable);
170 self
171 }
172
173 pub fn sortable(mut self, sortable: bool) -> Self {
174 self.sortable = sortable;
175 self
176 }
177
178 pub fn invisible(mut self) -> Self {
180 self.invisible = true;
181 self
182 }
183
184 pub fn width(mut self, width: impl Into<String>) -> Self {
185 self.width = Some(width.into());
186 self
187 }
188
189 pub fn align(mut self, align: Align) -> Self {
190 self.align = Some(align);
191 self
192 }
193
194 pub fn require(mut self, permission: impl Into<String>) -> Self {
196 self.permission = Some(permission.into());
197 self
198 }
199
200 pub(crate) fn align_class(&self) -> &'static str {
201 self.align.map(Align::class).unwrap_or("")
202 }
203
204 pub(crate) fn is_searchable(&self) -> bool {
206 self.searchable.unwrap_or(self.column_type == "text")
207 }
208}
209
210pub struct CellCx<'a> {
213 pub value: &'a str,
214 pub tz: Tz,
215 pub locale: chrono::Locale,
216}
217
218pub(crate) fn date_locale(tag: &str) -> chrono::Locale {
224 chrono::Locale::try_from(tag.replace('-', "_").as_str()).unwrap_or(chrono::Locale::en_US)
225}
226
227#[derive(Serialize)]
230pub struct CellVm {
231 pub view_key: String,
232 pub value: String,
233 pub display: String,
234 pub data: serde_json::Value,
235}
236
237pub trait ColumnType: Send + Sync + 'static {
240 fn view_key(&self) -> &'static str;
241 fn view_model(&self, cx: &CellCx<'_>) -> CellVm;
242 fn render_default(&self, vm: &CellVm) -> Markup;
243 fn option_keys(&self) -> Option<Vec<&'static str>> {
247 None
248 }
249 fn assets(&self) -> Vec<&'static str> {
252 Vec::new()
253 }
254}
255
256pub struct ColumnTypeReg {
263 pub column_type: Arc<dyn ColumnType>,
264}
265
266impl ColumnTypeReg {
267 pub fn new(column_type: Arc<dyn ColumnType>) -> Self {
268 Self { column_type }
269 }
270
271 pub fn name(&self) -> &'static str {
273 self.column_type.view_key()
274 }
275}
276
277pub type ColumnRegistry = HashMap<String, Arc<dyn ColumnType>>;
279
280pub(crate) fn render_cell(
285 ct: &dyn ColumnType,
286 resolver: &dyn OverrideResolver,
287 scope: &OverrideScope<'_>,
288 cx: &CellCx<'_>,
289 labels: Option<&HashMap<String, String>>,
290) -> Markup {
291 let mut vm = ct.view_model(cx);
292 if let Some(label) = labels.and_then(|l| l.get(&vm.value)) {
293 vm.display = label.clone();
294 }
295 match resolver.render_override(scope, &serde_json::to_value(&vm).unwrap_or_default()) {
296 Some(Ok(html)) => Markup::from_override(html),
297 Some(Err(_)) | None => ct.render_default(&vm),
298 }
299}
300
301pub(crate) fn builtin_column_types() -> Vec<Arc<dyn ColumnType>> {
302 vec![
303 Arc::new(TextColumn),
304 Arc::new(DateTimeColumn),
305 Arc::new(DateColumn),
306 Arc::new(TimeColumn),
307 Arc::new(BoolColumn),
308 Arc::new(StatusPillColumn),
309 ]
310}
311
312pub(crate) fn builtin_column_registry() -> ColumnRegistry {
314 builtin_column_types()
315 .into_iter()
316 .map(|c| (c.view_key().to_string(), c))
317 .collect()
318}
319
320#[derive(Template)]
321#[template(path = "cells/text.html")]
322struct CellTextTmpl<'a> {
323 display: &'a str,
324}
325
326fn text_cell(view_key: &str, value: &str, display: String) -> CellVm {
328 CellVm {
329 view_key: view_key.to_string(),
330 value: value.to_string(),
331 display,
332 data: serde_json::Value::Null,
333 }
334}
335
336fn render_text(vm: &CellVm) -> Markup {
337 Markup::from_template(&CellTextTmpl {
338 display: &vm.display,
339 })
340 .unwrap_or_default()
341}
342
343pub(crate) fn format_ts(raw: &str, tz: Tz, locale: chrono::Locale, pattern: &str) -> String {
346 match DateTime::parse_from_rfc3339(raw) {
347 Ok(dt) => dt
348 .with_timezone(&tz)
349 .format_localized(pattern, locale)
350 .to_string(),
351 Err(_) => raw.to_string(),
352 }
353}
354
355struct TextColumn;
356impl ColumnType for TextColumn {
357 fn option_keys(&self) -> Option<Vec<&'static str>> {
358 Some(Vec::new())
359 }
360 fn view_key(&self) -> &'static str {
361 "text"
362 }
363 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
364 text_cell("text", cx.value, cx.value.to_string())
365 }
366 fn render_default(&self, vm: &CellVm) -> Markup {
367 render_text(vm)
368 }
369}
370
371struct DateTimeColumn;
372impl ColumnType for DateTimeColumn {
373 fn option_keys(&self) -> Option<Vec<&'static str>> {
374 Some(Vec::new())
375 }
376 fn view_key(&self) -> &'static str {
377 "datetime"
378 }
379 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
380 text_cell(
381 "datetime",
382 cx.value,
383 format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y, %H:%M"),
384 )
385 }
386 fn render_default(&self, vm: &CellVm) -> Markup {
387 render_text(vm)
388 }
389}
390
391struct DateColumn;
392impl ColumnType for DateColumn {
393 fn option_keys(&self) -> Option<Vec<&'static str>> {
394 Some(Vec::new())
395 }
396 fn view_key(&self) -> &'static str {
397 "date"
398 }
399 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
400 text_cell(
401 "date",
402 cx.value,
403 format_ts(cx.value, cx.tz, cx.locale, "%-d %b %Y"),
404 )
405 }
406 fn render_default(&self, vm: &CellVm) -> Markup {
407 render_text(vm)
408 }
409}
410
411struct TimeColumn;
412impl ColumnType for TimeColumn {
413 fn option_keys(&self) -> Option<Vec<&'static str>> {
414 Some(Vec::new())
415 }
416 fn view_key(&self) -> &'static str {
417 "time"
418 }
419 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
420 text_cell(
421 "time",
422 cx.value,
423 format_ts(cx.value, cx.tz, cx.locale, "%H:%M"),
424 )
425 }
426 fn render_default(&self, vm: &CellVm) -> Markup {
427 render_text(vm)
428 }
429}
430
431struct BoolColumn;
432impl ColumnType for BoolColumn {
433 fn option_keys(&self) -> Option<Vec<&'static str>> {
434 Some(Vec::new())
435 }
436 fn view_key(&self) -> &'static str {
437 "boolean"
438 }
439 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
440 let display = match cx.value {
441 "1" | "true" => "Yes",
442 "0" | "false" | "" => "No",
443 other => other,
444 };
445 text_cell("boolean", cx.value, display.to_string())
446 }
447 fn render_default(&self, vm: &CellVm) -> Markup {
448 render_text(vm)
449 }
450}
451
452#[derive(Template)]
453#[template(path = "cells/status.html")]
454struct CellStatusTmpl<'a> {
455 label: &'a str,
456 slug: &'a str,
457}
458
459struct StatusPillColumn;
461impl ColumnType for StatusPillColumn {
462 fn option_keys(&self) -> Option<Vec<&'static str>> {
463 Some(Vec::new())
464 }
465 fn view_key(&self) -> &'static str {
466 "status_pill"
467 }
468 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
469 CellVm {
470 view_key: "status_pill".to_string(),
471 value: cx.value.to_string(),
472 display: cx.value.to_string(),
473 data: serde_json::json!({ "slug": status_slug(cx.value) }),
474 }
475 }
476 fn render_default(&self, vm: &CellVm) -> Markup {
477 let slug = vm.data.get("slug").and_then(|v| v.as_str()).unwrap_or("");
478 Markup::from_template(&CellStatusTmpl {
479 label: &vm.display,
480 slug,
481 })
482 .unwrap_or_default()
483 }
484}
485
486fn status_slug(value: &str) -> String {
488 value
489 .trim()
490 .to_lowercase()
491 .chars()
492 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
493 .collect()
494}
495
496impl Default for ListColumn {
497 fn default() -> Self {
500 Self::new("", Text::new(""))
501 }
502}
503
504impl Default for ListFilter {
505 fn default() -> Self {
507 Self::of("", Text::new(""), "text")
508 }
509}
510
511impl<'de> Deserialize<'de> for ListColumn {
512 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
513 struct Entry;
514 impl<'de> serde::de::Visitor<'de> for Entry {
515 type Value = ListColumn;
516
517 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
518 f.write_str("a column: its own keys and its type's")
519 }
520
521 fn visit_map<A: serde::de::MapAccess<'de>>(
522 self,
523 map: A,
524 ) -> Result<ListColumn, A::Error> {
525 let mut column = ListColumn::default();
526 let theirs = crate::keyed::split_entry(map, |key, map| {
527 match key {
528 "label" => column.label = map.next_value()?,
529 "type" => column.column_type = map.next_value()?,
530 "sortable" => column.sortable = map.next_value()?,
531 "invisible" => column.invisible = map.next_value()?,
532 "width" => column.width = map.next_value()?,
533 "align" => column.align = map.next_value()?,
534 "permission" => column.permission = map.next_value()?,
535 "searchable" => column.searchable = map.next_value()?,
536 "labels" => column.labels = map.next_value()?,
537 "editable" => column.editable = map.next_value()?,
538 _ => return Ok(false),
539 }
540 Ok(true)
541 })?;
542 column.options = theirs;
543 Ok(column)
544 }
545
546 fn visit_unit<E: serde::de::Error>(self) -> Result<ListColumn, E> {
548 Ok(ListColumn::default())
549 }
550 }
551 de.deserialize_map(Entry)
552 }
553}
554
555impl Serialize for ListColumn {
556 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
557 use serde::ser::SerializeMap;
558 let mut map = ser.serialize_map(None)?;
559 map.serialize_entry("label", &self.label)?;
560 map.serialize_entry("type", &self.column_type)?;
561 map.serialize_entry("sortable", &self.sortable)?;
562 map.serialize_entry("editable", &self.editable)?;
563 map.serialize_entry("invisible", &self.invisible)?;
564 map.serialize_entry("width", &self.width)?;
565 map.serialize_entry("align", &self.align)?;
566 map.serialize_entry("permission", &self.permission)?;
567 map.serialize_entry("searchable", &self.searchable)?;
568 if !self.labels.is_empty() {
569 map.serialize_entry("labels", &self.labels)?;
570 }
571 crate::keyed::serialize_theirs(&mut map, &self.options)?;
572 map.end()
573 }
574}
575
576impl<'de> Deserialize<'de> for ListFilter {
577 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
578 struct Entry;
579 impl<'de> serde::de::Visitor<'de> for Entry {
580 type Value = ListFilter;
581
582 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
583 f.write_str("a filter: its own keys and its type's")
584 }
585
586 fn visit_map<A: serde::de::MapAccess<'de>>(
587 self,
588 map: A,
589 ) -> Result<ListFilter, A::Error> {
590 let mut filter = ListFilter::default();
591 let theirs = crate::keyed::split_entry(map, |key, map| {
592 match key {
593 "label" => filter.label = map.next_value()?,
594 "type" => filter.filter_type = map.next_value()?,
595 "default_value" => filter.default_value = map.next_value()?,
596 "permission" => filter.permission = map.next_value()?,
597 _ => return Ok(false),
598 }
599 Ok(true)
600 })?;
601 filter.options = theirs;
602 Ok(filter)
603 }
604
605 fn visit_unit<E: serde::de::Error>(self) -> Result<ListFilter, E> {
607 Ok(ListFilter::default())
608 }
609 }
610 de.deserialize_map(Entry)
611 }
612}
613
614impl Serialize for ListFilter {
615 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
616 use serde::ser::SerializeMap;
617 let mut map = ser.serialize_map(None)?;
618 map.serialize_entry("label", &self.label)?;
619 map.serialize_entry("type", &self.filter_type)?;
620 map.serialize_entry("default_value", &self.default_value)?;
621 map.serialize_entry("permission", &self.permission)?;
622 crate::keyed::serialize_theirs(&mut map, &self.options)?;
623 map.end()
624 }
625}
626
627impl crate::keyed::Keyed for ListColumn {
628 fn key(&self) -> &str {
629 &self.field
630 }
631 fn set_key(&mut self, key: String) {
632 self.field = key;
633 }
634}
635
636impl crate::keyed::Keyed for ListFilter {
637 fn key(&self) -> &str {
638 &self.field
639 }
640 fn set_key(&mut self, key: String) {
641 self.field = key;
642 }
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize)]
647pub struct FilterOption {
648 pub value: String,
649 pub label: Text,
651}
652
653impl FilterOption {
654 pub fn new(value: &str, label: impl Into<Text>) -> Self {
655 Self {
656 value: value.to_string(),
657 label: label.into(),
658 }
659 }
660}
661
662#[derive(Debug, Clone)]
664#[non_exhaustive]
665pub struct ListFilter {
666 pub field: String,
668 pub label: Text,
670 pub filter_type: String,
672 pub options: serde_json::Value,
675 pub default_value: Option<String>,
678 pub permission: Option<String>,
681}
682
683impl ListFilter {
684 pub fn of(field: &str, label: impl Into<Text>, filter_type: &str) -> Self {
686 Self {
687 field: field.to_string(),
688 label: label.into(),
689 filter_type: filter_type.to_string(),
690 options: serde_json::Value::Null,
691 default_value: None,
692 permission: None,
693 }
694 }
695
696 pub fn boolean(field: &str, label: impl Into<Text>) -> Self {
698 Self::of(field, label, "boolean")
699 }
700
701 pub fn select(field: &str, label: impl Into<Text>, options: Vec<FilterOption>) -> Self {
704 let mut filter = Self::of(field, label, "select");
705 filter.options = serde_json::json!({ "options": options });
706 filter
707 }
708
709 pub fn text(field: &str, label: impl Into<Text>) -> Self {
711 Self::of(field, label, "text")
712 }
713
714 pub fn number(field: &str, label: impl Into<Text>) -> Self {
716 Self::of(field, label, "number")
717 }
718
719 pub fn date(field: &str, label: impl Into<Text>) -> Self {
722 Self::of(field, label, "date")
723 }
724
725 pub(crate) fn choices(&self) -> Vec<FilterOption> {
727 self.options
728 .get("options")
729 .and_then(|v| serde_json::from_value(v.clone()).ok())
730 .unwrap_or_default()
731 }
732
733 pub fn default_value(mut self, value: impl Into<String>) -> Self {
735 self.default_value = Some(value.into());
736 self
737 }
738
739 pub fn require(mut self, permission: impl Into<String>) -> Self {
741 self.permission = Some(permission.into());
742 self
743 }
744
745 pub(crate) fn param(&self) -> String {
746 format!("f_{}", self.field)
747 }
748}
749
750#[derive(Clone, Debug)]
754pub(crate) struct ImportLink {
755 pub href: String,
756 pub permission: String,
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize)]
763#[serde(deny_unknown_fields)]
764pub struct ToolbarButton {
765 pub label: Text,
767 pub href: String,
770 pub icon: Option<String>,
772 pub permission: Option<String>,
775 pub primary: bool,
777}
778
779impl ToolbarButton {
780 pub fn new(label: impl Into<Text>, href: impl Into<String>) -> Self {
781 Self {
782 label: label.into(),
783 href: href.into(),
784 icon: None,
785 permission: None,
786 primary: false,
787 }
788 }
789
790 pub fn icon(mut self, icon: &str) -> Self {
791 self.icon = Some(icon.to_string());
792 self
793 }
794
795 pub fn require(mut self, permission: impl Into<String>) -> Self {
797 self.permission = Some(permission.into());
798 self
799 }
800
801 pub fn primary(mut self) -> Self {
802 self.primary = true;
803 self
804 }
805}
806
807pub struct ToolbarView {
809 pub label: String,
810 pub href: String,
811 pub icon: String,
813 pub primary: bool,
814}
815
816fn toolbar_views(
820 config: &ListConfig,
821 admin_path: &str,
822 user: &laterite_auth::AuthenticatedUser,
823 shell: &crate::Shell,
824) -> Vec<ToolbarView> {
825 let mut out = Vec::new();
826 if config.creatable {
827 if let Some(base) = &config.edit_base {
828 out.push(ToolbarView {
829 label: shell.tt(&laterite_core::t!("New")),
830 href: format!("{base}/new"),
833 icon: String::new(),
834 primary: true,
835 });
836 }
837 }
838 for button in &config.toolbar {
839 if let Some(needed) = &button.permission {
840 if !user.permissions.allows(needed) {
841 continue;
842 }
843 }
844 let href = if button.href.starts_with('/') {
845 format!("{admin_path}{}", button.href)
846 } else {
847 button.href.clone()
848 };
849 out.push(ToolbarView {
850 label: shell.tt(&button.label),
851 href,
852 icon: crate::icons::svg(&shell.icon_set, &shell.sprite_url, button.icon.as_deref()),
853 primary: button.primary,
854 });
855 }
856 out
857}
858
859pub(crate) fn columns_preference_key(base_path: &str) -> String {
862 format!("list.columns.{base_path}")
863}
864
865pub(crate) async fn stored_columns(
870 state: &AdminState,
871 path: &str,
872 user: &laterite_auth::AuthenticatedUser,
873) -> Option<String> {
874 match laterite_auth::store::user_preference(
875 &state.db,
876 user.user.id,
877 &columns_preference_key(path),
878 )
879 .await
880 {
881 Ok(stored) => stored,
882 Err(e) => {
883 tracing::warn!(error = %e, "reading the column preference failed");
884 None
885 }
886 }
887}
888
889pub(crate) fn per_page_preference_key(base_path: &str) -> String {
891 format!("list.per_page:{base_path}")
892}
893
894pub(crate) async fn stored_per_page(
898 state: &AdminState,
899 config: &ListConfig,
900 path: &str,
901 user: &laterite_auth::AuthenticatedUser,
902) -> i64 {
903 let stored = match laterite_auth::store::user_preference(
904 &state.db,
905 user.user.id,
906 &per_page_preference_key(path),
907 )
908 .await
909 {
910 Ok(stored) => stored,
911 Err(e) => {
912 tracing::warn!(error = %e, "reading the page-size preference failed");
913 None
914 }
915 };
916 offered_per_page(config, stored.as_deref().and_then(|s| s.parse().ok()))
917}
918
919pub(crate) fn offered_per_page(config: &ListConfig, size: Option<i64>) -> i64 {
921 size.filter(|n| config.per_page_options.contains(n))
922 .unwrap_or(config.per_page)
923}
924
925pub(crate) fn allowed_columns<'a>(
928 config: &'a ListConfig,
929 user: &laterite_auth::AuthenticatedUser,
930) -> Vec<&'a ListColumn> {
931 config
932 .columns
933 .iter()
934 .filter(|c| {
935 c.permission
936 .as_deref()
937 .is_none_or(|p| user.permissions.allows(p))
938 })
939 .collect()
940}
941
942pub(crate) fn allowed_filters<'a>(
945 config: &'a ListConfig,
946 user: &laterite_auth::AuthenticatedUser,
947) -> Vec<&'a ListFilter> {
948 config
949 .filters
950 .iter()
951 .filter(|f| {
952 f.permission
953 .as_deref()
954 .is_none_or(|p| user.permissions.allows(p))
955 })
956 .collect()
957}
958
959pub(crate) fn narrowed(
962 config: &ListConfig,
963 stored: Option<&str>,
964 user: &laterite_auth::AuthenticatedUser,
965) -> ListConfig {
966 ListConfig {
967 columns: visible_columns(config, stored, user)
968 .into_iter()
969 .cloned()
970 .collect(),
971 filters: allowed_filters(config, user).into_iter().cloned().collect(),
972 ..config.clone()
973 }
974}
975
976pub(crate) fn visible_columns<'a>(
984 config: &'a ListConfig,
985 stored: Option<&str>,
986 user: &laterite_auth::AuthenticatedUser,
987) -> Vec<&'a ListColumn> {
988 let allowed = allowed_columns(config, user);
989 let by_default = || allowed.iter().copied().filter(|c| !c.invisible).collect();
990 let Some(stored) = stored else {
991 return by_default();
992 };
993 let chosen: Vec<&str> = stored
994 .split(',')
995 .map(str::trim)
996 .filter(|s| !s.is_empty())
997 .collect();
998 let kept: Vec<&ListColumn> = allowed
999 .iter()
1000 .copied()
1001 .filter(|c| chosen.iter().any(|n| *n == c.field))
1002 .collect();
1003 if kept.is_empty() {
1004 return by_default();
1005 }
1006 kept
1007}
1008
1009pub(crate) fn check(
1013 config: &ListConfig,
1014 column_types: &ColumnRegistry,
1015 permission_codes: &std::collections::HashSet<String>,
1016) -> Result<(), String> {
1017 if let Some(tree) = &config.tree {
1018 if !valid_ident(&tree.parent_key) {
1019 return Err(format!(
1020 "tree names an invalid parent column `{}`",
1021 tree.parent_key
1022 ));
1023 }
1024 }
1025 if let Some(reorder) = &config.reorder {
1026 if !valid_ident(&reorder.column) {
1027 return Err(format!(
1028 "reorder names an invalid column `{}`",
1029 reorder.column
1030 ));
1031 }
1032 }
1033 for col in &config.columns {
1034 if column_types.get(&col.column_type).is_none() {
1035 return Err(format!(
1036 "column `{}` uses unregistered type `{}`",
1037 col.field, col.column_type
1038 ));
1039 }
1040 if let Some(p) = &col.permission {
1043 if !permission_codes.contains(p) {
1044 return Err(format!(
1045 "column `{}` requires unregistered permission `{p}`",
1046 col.field
1047 ));
1048 }
1049 }
1050 }
1051 for filter in &config.filters {
1052 if let Some(p) = &filter.permission {
1053 if !permission_codes.contains(p) {
1054 return Err(format!(
1055 "filter `{}` requires unregistered permission `{p}`",
1056 filter.field
1057 ));
1058 }
1059 }
1060 }
1061 Ok(())
1062}
1063
1064#[derive(Debug, Clone, Serialize, Deserialize)]
1066#[serde(deny_unknown_fields, default)]
1067pub struct SearchConfig {
1068 pub enabled: bool,
1070 pub prompt: Option<Text>,
1072 pub on_enter: bool,
1074}
1075
1076impl Default for SearchConfig {
1077 fn default() -> Self {
1078 Self {
1079 enabled: true,
1080 prompt: None,
1081 on_enter: false,
1082 }
1083 }
1084}
1085
1086impl SearchConfig {
1087 pub fn off() -> Self {
1089 Self {
1090 enabled: false,
1091 ..Self::default()
1092 }
1093 }
1094
1095 pub fn prompt(mut self, text: impl Into<Text>) -> Self {
1096 self.prompt = Some(text.into());
1097 self
1098 }
1099
1100 pub fn on_enter(mut self) -> Self {
1101 self.on_enter = true;
1102 self
1103 }
1104}
1105
1106#[derive(Debug, Clone, Serialize, Deserialize)]
1110#[serde(deny_unknown_fields, default)]
1111#[non_exhaustive]
1112pub struct ReorderConfig {
1113 pub column: String,
1115}
1116
1117impl Default for ReorderConfig {
1118 fn default() -> Self {
1119 Self {
1120 column: "sort_order".to_string(),
1121 }
1122 }
1123}
1124
1125fn deserialize_reorder<'de, D>(deserializer: D) -> Result<Option<ReorderConfig>, D::Error>
1128where
1129 D: serde::Deserializer<'de>,
1130{
1131 #[derive(Deserialize)]
1132 #[serde(untagged)]
1133 enum Key {
1134 Flag(bool),
1135 Config(ReorderConfig),
1136 }
1137 Ok(match Option::<Key>::deserialize(deserializer)? {
1138 Some(Key::Flag(true)) => Some(ReorderConfig::default()),
1139 Some(Key::Flag(false)) | None => None,
1140 Some(Key::Config(config)) => Some(config),
1141 })
1142}
1143
1144const REORDER_STEP: i64 = 1000;
1146
1147#[derive(Debug, Clone, Serialize, Deserialize)]
1151#[serde(deny_unknown_fields, default)]
1152#[non_exhaustive]
1153pub struct TreeConfig {
1154 pub parent_key: String,
1157 pub source: Option<String>,
1161}
1162
1163impl TreeConfig {
1164 pub fn new(parent_key: impl Into<String>) -> Self {
1166 Self {
1167 parent_key: parent_key.into(),
1168 source: None,
1169 }
1170 }
1171
1172 pub fn move_to(mut self, source: impl Into<String>) -> Self {
1174 self.source = Some(source.into());
1175 self
1176 }
1177}
1178
1179impl Default for TreeConfig {
1180 fn default() -> Self {
1181 Self::new("parent_id")
1182 }
1183}
1184
1185fn deserialize_tree<'de, D>(deserializer: D) -> Result<Option<TreeConfig>, D::Error>
1188where
1189 D: serde::Deserializer<'de>,
1190{
1191 #[derive(Deserialize)]
1192 #[serde(untagged)]
1193 enum Key {
1194 Flag(bool),
1195 Config(TreeConfig),
1196 }
1197 Ok(match Option::<Key>::deserialize(deserializer)? {
1198 Some(Key::Flag(true)) => Some(TreeConfig::default()),
1199 Some(Key::Flag(false)) | None => None,
1200 Some(Key::Config(config)) => Some(config),
1201 })
1202}
1203
1204const TREE_CHILDREN_LIMIT: i64 = 1000;
1206
1207#[derive(Clone)]
1209pub(crate) struct Editor(pub Arc<crate::form::PreparedForm>);
1210
1211impl std::fmt::Debug for Editor {
1212 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1213 f.write_str("Editor")
1214 }
1215}
1216
1217#[derive(Debug, Clone, Serialize, Deserialize)]
1224#[serde(deny_unknown_fields, default)]
1225#[non_exhaustive]
1226pub struct ListConfig {
1227 pub entity: String,
1228 pub title: Text,
1230 #[serde(
1231 deserialize_with = "crate::keyed::deserialize",
1232 serialize_with = "crate::keyed::serialize"
1233 )]
1234 pub columns: Vec<ListColumn>,
1235 pub order_by: String,
1236 pub order_dir: SortDir,
1237 pub per_page: i64,
1238 pub id_field: String,
1239 pub edit_base: Option<String>,
1241 pub creatable: bool,
1244 #[serde(
1246 deserialize_with = "crate::keyed::deserialize",
1247 serialize_with = "crate::keyed::serialize"
1248 )]
1249 pub filters: Vec<ListFilter>,
1250 pub deletable: bool,
1254 pub exportable: bool,
1257 #[serde(skip)]
1261 pub(crate) import: Option<ImportLink>,
1262 #[serde(skip)]
1265 pub(crate) scope: Option<Scope>,
1266 #[serde(deserialize_with = "deserialize_reorder")]
1269 pub reorder: Option<ReorderConfig>,
1270 #[serde(deserialize_with = "deserialize_tree")]
1273 pub tree: Option<TreeConfig>,
1274 #[serde(skip)]
1277 pub(crate) editor: Option<Editor>,
1278 pub toolbar: Vec<ToolbarButton>,
1280 pub search: SearchConfig,
1281 pub per_page_options: Vec<i64>,
1284 pub no_records_message: Option<Text>,
1286 pub row_click: RowClick,
1289 pub remember: bool,
1292}
1293
1294#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1296#[serde(rename_all = "lowercase")]
1297pub enum RowClick {
1298 #[default]
1300 Open,
1301 None,
1303}
1304
1305impl ListConfig {
1306 pub fn new(
1311 entity: impl Into<String>,
1312 title: impl Into<Text>,
1313 columns: Vec<ListColumn>,
1314 ) -> Self {
1315 Self {
1316 entity: entity.into(),
1317 title: title.into(),
1318 columns,
1319 ..Self::default()
1320 }
1321 }
1322
1323 pub fn order(mut self, column: impl Into<String>, dir: SortDir) -> Self {
1325 self.order_by = column.into();
1326 self.order_dir = dir;
1327 self
1328 }
1329
1330 pub fn per_page(mut self, rows: i64) -> Self {
1331 self.per_page = rows;
1332 self
1333 }
1334
1335 pub fn id_field(mut self, column: impl Into<String>) -> Self {
1337 self.id_field = column.into();
1338 self
1339 }
1340
1341 pub fn edit_base(mut self, base: impl Into<String>) -> Self {
1343 self.edit_base = Some(base.into());
1344 self
1345 }
1346
1347 pub fn creatable(mut self) -> Self {
1351 self.creatable = true;
1352 self
1353 }
1354
1355 pub fn exportable(mut self) -> Self {
1359 self.exportable = true;
1360 self
1361 }
1362
1363 pub fn filters(mut self, filters: Vec<ListFilter>) -> Self {
1364 self.filters = filters;
1365 self
1366 }
1367
1368 pub fn deletable(mut self) -> Self {
1371 self.deletable = true;
1372 self
1373 }
1374
1375 pub fn tree(mut self, tree: TreeConfig) -> Self {
1378 self.tree = Some(tree);
1379 self
1380 }
1381
1382 pub fn reorder(mut self, column: impl Into<String>) -> Self {
1385 self.reorder = Some(ReorderConfig {
1386 column: column.into(),
1387 });
1388 self
1389 }
1390
1391 pub fn toolbar(mut self, buttons: Vec<ToolbarButton>) -> Self {
1392 self.toolbar = buttons;
1393 self
1394 }
1395
1396 pub fn search(mut self, search: SearchConfig) -> Self {
1397 self.search = search;
1398 self
1399 }
1400
1401 pub fn per_page_options(mut self, sizes: Vec<i64>) -> Self {
1402 self.per_page_options = sizes;
1403 self
1404 }
1405
1406 pub fn no_records_message(mut self, text: impl Into<Text>) -> Self {
1407 self.no_records_message = Some(text.into());
1408 self
1409 }
1410
1411 pub fn row_click(mut self, action: RowClick) -> Self {
1413 self.row_click = action;
1414 self
1415 }
1416
1417 pub fn remember(mut self, remember: bool) -> Self {
1420 self.remember = remember;
1421 self
1422 }
1423}
1424
1425impl Default for ListConfig {
1426 fn default() -> Self {
1433 Self {
1434 entity: String::new(),
1435 title: Text::new(""),
1436 columns: Vec::new(),
1437 order_by: "id".to_string(),
1438 order_dir: SortDir::Desc,
1439 per_page: 25,
1440 id_field: "id".to_string(),
1441 edit_base: None,
1442 creatable: false,
1443 filters: Vec::new(),
1444 deletable: false,
1445 exportable: false,
1446 import: None,
1447 scope: None,
1448 reorder: None,
1449 tree: None,
1450 editor: None,
1451 toolbar: Vec::new(),
1452 search: SearchConfig::default(),
1453 per_page_options: Vec::new(),
1454 no_records_message: None,
1455 row_click: RowClick::Open,
1456 remember: true,
1457 }
1458 }
1459}
1460
1461#[derive(Deserialize, Default)]
1463pub struct ListParams {
1464 page: Option<i64>,
1465 sort: Option<String>,
1466 dir: Option<String>,
1467 q: Option<String>,
1468 parent: Option<String>,
1470 depth: Option<i64>,
1472}
1473
1474pub(crate) fn resolve_sort(
1479 config: &ListConfig,
1480 sort: Option<&str>,
1481 dir: Option<&str>,
1482) -> (String, SortDir) {
1483 let (natural, natural_dir) = natural_order(config);
1484 let by = match sort.filter(|s| config.columns.iter().any(|c| c.field == *s && c.sortable)) {
1485 Some(field) => field.to_string(),
1486 None => natural.to_string(),
1487 };
1488 let dir = match dir {
1489 Some("asc") => SortDir::Asc,
1490 Some("desc") => SortDir::Desc,
1491 _ if by == natural => natural_dir,
1494 _ => SortDir::Asc,
1495 };
1496 (by, dir)
1497}
1498
1499fn natural_order(config: &ListConfig) -> (&str, SortDir) {
1502 match &config.reorder {
1503 Some(reorder) => (&reorder.column, SortDir::Asc),
1504 None => (&config.order_by, config.order_dir),
1505 }
1506}
1507
1508pub struct RowView {
1510 pub id: String,
1511 pub cells: Vec<String>,
1512 pub children: i64,
1514}
1515
1516pub struct CellView {
1518 pub html: String,
1519 pub class: String,
1520}
1521
1522pub struct RenderedRow {
1524 pub id: String,
1525 pub cells: Vec<CellView>,
1526 pub depth: i64,
1529 pub indent: String,
1530 pub toggle: Option<String>,
1531}
1532
1533pub struct ListPage {
1535 pub rows: Vec<RowView>,
1536 pub total: i64,
1537}
1538
1539pub(crate) struct ListQuery<'a> {
1542 pub offset: i64,
1543 pub order_by: &'a str,
1544 pub order_dir: SortDir,
1545 pub q: &'a str,
1546 pub filters: &'a [ActiveFilter<'a>],
1547 pub per_page: Option<i64>,
1550 pub children_of: Option<&'a str>,
1552}
1553
1554pub(crate) struct ActiveFilter<'a> {
1558 filter: &'a ListFilter,
1559 raw: String,
1561 matcher: FilterMatch,
1564}
1565
1566pub(crate) enum FilterMatch {
1568 Equals(sea_query::Value),
1570 Contains(String),
1572 StartsWith(String),
1574}
1575
1576fn filter_match(filter: &ListFilter, raw: &str) -> Option<FilterMatch> {
1582 match filter.filter_type.as_str() {
1583 "boolean" => match raw {
1586 "1" => Some(FilterMatch::Equals(true.into())),
1587 "0" => Some(FilterMatch::Equals(false.into())),
1588 _ => None,
1589 },
1590 "select" => filter
1591 .choices()
1592 .iter()
1593 .any(|o| o.value == raw)
1594 .then(|| FilterMatch::Equals(raw.into())),
1595 "text" => Some(FilterMatch::Contains(raw.to_string())),
1596 "number" => raw
1597 .parse::<i64>()
1598 .ok()
1599 .map(|n| FilterMatch::Equals(n.into())),
1600 "date" => is_iso_date(raw).then(|| FilterMatch::StartsWith(raw.to_string())),
1604 _ => None,
1605 }
1606}
1607
1608fn is_iso_date(raw: &str) -> bool {
1610 raw.len() == 10
1611 && raw.as_bytes().iter().enumerate().all(|(i, b)| {
1612 if i == 4 || i == 7 {
1613 *b == b'-'
1614 } else {
1615 b.is_ascii_digit()
1616 }
1617 })
1618}
1619
1620pub(crate) fn resolve_filters<'a>(
1623 config: &'a ListConfig,
1624 params: &HashMap<String, String>,
1625) -> Vec<ActiveFilter<'a>> {
1626 let mut out = Vec::new();
1627 for filter in &config.filters {
1628 let submitted = params.get(&filter.param()).map(|v| v.trim());
1629 let raw = match submitted {
1630 Some(value) => value,
1632 None => filter.default_value.as_deref().unwrap_or_default(),
1634 };
1635 if raw.is_empty() {
1636 continue;
1637 }
1638 let Some(matcher) = filter_match(filter, raw) else {
1639 continue;
1640 };
1641 out.push(ActiveFilter {
1642 filter,
1643 raw: raw.to_string(),
1644 matcher,
1645 });
1646 }
1647 out
1648}
1649
1650fn filter_condition(active: &[ActiveFilter<'_>]) -> Option<sea_query::Condition> {
1652 if active.is_empty() {
1653 return None;
1654 }
1655 let caps = laterite_core::capabilities::CapabilitySet::default();
1656 let mut all = sea_query::Condition::all();
1657 for f in active {
1658 let col = Expr::col(Alias::new(&f.filter.field));
1659 all = match &f.matcher {
1660 FilterMatch::Equals(value) => all.add(col.eq(value.clone())),
1661 FilterMatch::Contains(term) => {
1663 match SearchProfile::new().condition(&caps, &f.filter.field, term) {
1664 Some(cond) => all.add(cond),
1665 None => all,
1666 }
1667 }
1668 FilterMatch::StartsWith(prefix) => all.add(col.like(format!("{prefix}%"))),
1669 };
1670 }
1671 Some(all)
1672}
1673
1674fn search_condition(config: &ListConfig, q: &str) -> Option<sea_query::Condition> {
1678 let caps = laterite_core::capabilities::CapabilitySet::default();
1682 let profile = SearchProfile::new();
1683 let mut any = sea_query::Condition::any();
1684 let mut matched = false;
1685 for column in config.columns.iter().filter(|c| c.is_searchable()) {
1686 if let Some(cond) = profile.condition(&caps, &column.field, q) {
1687 any = any.add(cond);
1688 matched = true;
1689 }
1690 }
1691 matched.then_some(any)
1692}
1693
1694#[derive(Clone, Debug)]
1697pub(crate) enum Scope {
1698 Column {
1701 column: String,
1702 value: Option<String>,
1703 },
1704 Pivot {
1709 table: String,
1710 parent_key: String,
1711 related_key: String,
1712 id_field: String,
1713 value: String,
1714 },
1715}
1716
1717impl Scope {
1718 fn idents_valid(&self) -> bool {
1719 match self {
1720 Scope::Column { column, .. } => valid_ident(column),
1721 Scope::Pivot {
1722 table,
1723 parent_key,
1724 related_key,
1725 id_field,
1726 ..
1727 } => {
1728 valid_ident(table)
1729 && valid_ident(parent_key)
1730 && valid_ident(related_key)
1731 && valid_ident(id_field)
1732 }
1733 }
1734 }
1735
1736 fn condition(&self, backend: DbBackend, entity_id: &str) -> SimpleExpr {
1738 let cast = Alias::new(text_cast(backend));
1739 match self {
1740 Scope::Column {
1741 column,
1742 value: Some(value),
1743 } => Expr::col(Alias::new(column))
1744 .cast_as(cast)
1745 .eq(value.clone()),
1746 Scope::Column {
1747 column,
1748 value: None,
1749 } => Expr::col(Alias::new(column)).is_null(),
1750 Scope::Pivot {
1751 table,
1752 parent_key,
1753 related_key,
1754 value,
1755 ..
1756 } => Expr::col(Alias::new(entity_id)).in_subquery(
1757 Query::select()
1758 .column(Alias::new(related_key))
1759 .from(Alias::new(table))
1760 .and_where(
1761 Expr::col(Alias::new(parent_key))
1762 .cast_as(cast)
1763 .eq(value.clone()),
1764 )
1765 .to_owned(),
1766 ),
1767 }
1768 }
1769
1770 fn row_id(&self, backend: DbBackend, entity: &str, entity_id: &str) -> Option<SimpleExpr> {
1773 match self {
1774 Scope::Column { .. } => None,
1775 Scope::Pivot {
1776 table,
1777 parent_key,
1778 related_key,
1779 id_field,
1780 value,
1781 } => {
1782 let pivot = Alias::new("lat_pivot");
1783 Some(SimpleExpr::SubQuery(
1784 None,
1785 Box::new(
1786 Query::select()
1787 .column((pivot.clone(), Alias::new(id_field)))
1788 .from_as(Alias::new(table), pivot.clone())
1789 .and_where(
1790 Expr::col((pivot.clone(), Alias::new(related_key)))
1791 .equals((Alias::new(entity), Alias::new(entity_id))),
1792 )
1793 .and_where(
1794 Expr::col((pivot, Alias::new(parent_key)))
1795 .cast_as(Alias::new(text_cast(backend)))
1796 .eq(value.clone()),
1797 )
1798 .to_owned()
1799 .into_sub_query_statement(),
1800 ),
1801 ))
1802 }
1803 }
1804 }
1805}
1806
1807pub(crate) async fn query(
1812 db: &Db,
1813 config: &ListConfig,
1814 req: &ListQuery<'_>,
1815) -> anyhow::Result<ListPage> {
1816 if !valid_ident(&config.entity)
1817 || !valid_ident(&config.order_by)
1818 || !valid_ident(req.order_by)
1819 || !valid_ident(&config.id_field)
1820 || !config.columns.iter().all(|c| valid_ident(&c.field))
1821 || !config.filters.iter().all(|f| valid_ident(&f.field))
1822 || !config.scope.iter().all(Scope::idents_valid)
1823 {
1824 anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
1825 }
1826
1827 let dir = match req.order_dir {
1828 SortDir::Asc => Order::Asc,
1829 SortDir::Desc => Order::Desc,
1830 };
1831 let (sql, values) = {
1835 let mut select = Query::select();
1836 for column in &config.columns {
1837 select.expr_as(
1838 Expr::col(Alias::new(&column.field)).cast_as(Alias::new(text_cast(db.backend))),
1839 Alias::new(&column.field),
1840 );
1841 }
1842 let row_id = config
1843 .scope
1844 .as_ref()
1845 .and_then(|scope| scope.row_id(db.backend, &config.entity, &config.id_field))
1846 .unwrap_or_else(|| Expr::col(Alias::new(&config.id_field)).into());
1847 select
1848 .expr_as(
1849 row_id.cast_as(Alias::new(text_cast(db.backend))),
1850 Alias::new(ID_ALIAS),
1851 )
1852 .from(Alias::new(&config.entity))
1853 .order_by(Alias::new(req.order_by), dir);
1854 if let Some(parent_key) = req.children_of {
1855 let child = Alias::new("lat_child");
1856 select.expr_as(
1857 SimpleExpr::SubQuery(
1858 None,
1859 Box::new(
1860 Query::select()
1861 .expr(Expr::col((child.clone(), Alias::new(&config.id_field))).count())
1862 .from_as(Alias::new(&config.entity), child.clone())
1863 .and_where(
1864 Expr::col((child, Alias::new(parent_key))).equals((
1865 Alias::new(&config.entity),
1866 Alias::new(&config.id_field),
1867 )),
1868 )
1869 .to_owned()
1870 .into_sub_query_statement(),
1871 ),
1872 )
1873 .cast_as(Alias::new(text_cast(db.backend))),
1874 Alias::new("lat_children"),
1875 );
1876 }
1877 if req.order_by != config.id_field {
1879 select.order_by(Alias::new(&config.id_field), Order::Asc);
1880 }
1881 select
1882 .limit(req.per_page.unwrap_or(config.per_page).max(0) as u64)
1883 .offset(req.offset.max(0) as u64);
1884 if let Some(cond) = search_condition(config, req.q) {
1885 select.cond_where(cond);
1886 }
1887 if let Some(cond) = filter_condition(req.filters) {
1888 select.cond_where(cond);
1889 }
1890 if let Some(scope) = &config.scope {
1891 select.and_where(scope.condition(db.backend, &config.id_field));
1892 }
1893 build(db.backend, select)
1894 };
1895 let raw = bind_values(sqlx::query(&sql), values)
1896 .fetch_all(&db.pool)
1897 .await?;
1898
1899 let (csql, cvalues) = {
1900 let mut count = Query::select();
1901 count
1902 .expr(Expr::col(Alias::new(&config.id_field)).count())
1903 .from(Alias::new(&config.entity));
1904 if let Some(cond) = search_condition(config, req.q) {
1906 count.cond_where(cond);
1907 }
1908 if let Some(cond) = filter_condition(req.filters) {
1909 count.cond_where(cond);
1910 }
1911 if let Some(scope) = &config.scope {
1912 count.and_where(scope.condition(db.backend, &config.id_field));
1913 }
1914 build(db.backend, count)
1915 };
1916 let total: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&csql), cvalues)
1917 .fetch_one(&db.pool)
1918 .await?
1919 .0;
1920
1921 let rows = raw
1922 .iter()
1923 .map(|row| RowView {
1924 id: get_text(row, ID_ALIAS),
1925 cells: config
1926 .columns
1927 .iter()
1928 .map(|c| get_text(row, &c.field))
1929 .collect(),
1930 children: match req.children_of {
1931 Some(_) => get_text(row, "lat_children").trim().parse().unwrap_or(0),
1932 None => 0,
1933 },
1934 })
1935 .collect();
1936 Ok(ListPage { rows, total })
1937}
1938
1939fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
1942 row.get_text_opt(column).ok().flatten().unwrap_or_default()
1945}
1946
1947#[cfg(test)]
1949#[allow(clippy::too_many_arguments)]
1950pub(crate) async fn handle(
1951 state: &AdminState,
1952 config: &ListConfig,
1953 path: &str,
1954 params: ListParams,
1955 raw: &HashMap<String, String>,
1956 user: &laterite_auth::AuthenticatedUser,
1957 shell: crate::Shell,
1958 headers: &axum::http::HeaderMap,
1959) -> Response {
1960 handle_remembering(
1961 state, config, path, params, raw, user, shell, headers, None, None,
1962 )
1963 .await
1964}
1965
1966fn query_to_remember(
1970 config: &ListConfig,
1971 params: &ListParams,
1972 active: &[ActiveFilter<'_>],
1973) -> Option<String> {
1974 let mut parts = Vec::new();
1975 let (by, dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
1976 let (natural, natural_dir) = natural_order(config);
1977 if by != natural || dir != natural_dir {
1978 let dir = match dir {
1979 SortDir::Asc => "asc",
1980 SortDir::Desc => "desc",
1981 };
1982 parts.push(format!("sort={}&dir={dir}", urlencode(&by)));
1983 }
1984 if let Some(q) = params.q.as_deref().map(str::trim).filter(|q| !q.is_empty()) {
1985 parts.push(format!("q={}", urlencode(q)));
1986 }
1987 for f in active {
1988 parts.push(format!("{}={}", f.filter.param(), urlencode(&f.raw)));
1989 }
1990 (!parts.is_empty()).then(|| parts.join("&"))
1991}
1992
1993#[allow(clippy::too_many_arguments)]
1999pub(crate) async fn handle_remembering(
2000 state: &AdminState,
2001 config: &ListConfig,
2002 path: &str,
2003 params: ListParams,
2004 raw: &HashMap<String, String>,
2005 user: &laterite_auth::AuthenticatedUser,
2006 shell: crate::Shell,
2007 headers: &axum::http::HeaderMap,
2008 query_string: Option<&str>,
2009 session: Option<&crate::session::SessionHandle>,
2010) -> Response {
2011 if let Some(session) = session.filter(|_| config.remember) {
2012 match query_string.filter(|q| !q.is_empty()) {
2013 None if !crate::form::is_htmx(headers) => {
2014 if let Some(remembered) = session.remembered_list(path) {
2015 return axum::response::Redirect::to(&format!("{path}?{remembered}"))
2016 .into_response();
2017 }
2018 }
2019 None => {}
2020 Some(_) => {
2021 let active = resolve_filters(config, raw);
2022 let keep = query_to_remember(config, ¶ms, &active);
2023 session.remember_list(path, keep.as_deref());
2024 }
2025 }
2026 }
2027 show(state, config, path, params, raw, user, shell, headers).await
2028}
2029
2030#[allow(clippy::too_many_arguments)]
2032async fn show(
2033 state: &AdminState,
2034 config: &ListConfig,
2035 path: &str,
2036 params: ListParams,
2037 raw: &HashMap<String, String>,
2038 user: &laterite_auth::AuthenticatedUser,
2039 shell: crate::Shell,
2040 headers: &axum::http::HeaderMap,
2041) -> Response {
2042 let children = params.parent.is_some();
2043 match build_page(state, config, path, params, raw, user, shell).await {
2044 Some(page_view) => {
2049 if crate::form::is_htmx(headers) && children && page_view.tree.is_some() {
2050 render(rows_of(page_view, "lat-list", None))
2051 } else if crate::form::is_htmx(headers) {
2052 render(fragment_of(page_view, "lat-list", None))
2053 } else {
2054 render(page_view)
2055 }
2056 }
2057 None => render_error(),
2058 }
2059}
2060
2061async fn build_page(
2063 state: &AdminState,
2064 config: &ListConfig,
2065 path: &str,
2066 params: ListParams,
2067 raw: &HashMap<String, String>,
2068 user: &laterite_auth::AuthenticatedUser,
2069 shell: crate::Shell,
2070) -> Option<ListTemplate> {
2071 let stored = stored_columns(state, path, user).await;
2075 let declared = config;
2076 let narrowed = narrowed(config, stored.as_deref(), user);
2077 let config = &narrowed;
2078
2079 let page = params.page.unwrap_or(1).max(1);
2080 let per_page = stored_per_page(state, config, path, user).await;
2081 let (order_by, order_dir) = resolve_sort(config, params.sort.as_deref(), params.dir.as_deref());
2082 let q = params.q.unwrap_or_default();
2083 let active = resolve_filters(config, raw);
2084 let (natural, natural_dir) = natural_order(config);
2087 let own_order =
2088 order_by == natural && order_dir == natural_dir && q.trim().is_empty() && active.is_empty();
2089 let reorder = config
2090 .reorder
2091 .as_ref()
2092 .filter(|_| own_order)
2093 .map(|_| ReorderView {
2094 path: format!("{path}/reorder"),
2095 page,
2096 parent: params.parent.clone().unwrap_or_default(),
2097 });
2098 let structure = config.tree.as_ref().filter(|_| own_order);
2101 let scoped;
2102 let config = match structure {
2103 Some(tree) => {
2104 let mut c = config.clone();
2105 c.scope = Some(Scope::Column {
2106 column: tree.parent_key.clone(),
2107 value: params.parent.clone(),
2108 });
2109 scoped = c;
2110 &scoped
2111 }
2112 None => config,
2113 };
2114 let under_parent = structure.is_some() && params.parent.is_some();
2115 let depth = if under_parent {
2116 params.depth.unwrap_or(1).max(1)
2117 } else {
2118 0
2119 };
2120 let per_page = if under_parent {
2122 TREE_CHILDREN_LIMIT
2123 } else {
2124 per_page
2125 };
2126 let offset = (page - 1) * per_page;
2127 let tree = structure.map(|t| TreeView {
2128 move_path: t.source.as_ref().map(|_| path.to_string()),
2129 });
2130 let crumbs = match (structure, ¶ms.parent) {
2131 (Some(t), Some(parent)) => crumbs(&state.db, config, t, parent, path).await,
2132 _ => Vec::new(),
2133 };
2134 let req = ListQuery {
2135 offset,
2136 order_by: &order_by,
2137 order_dir,
2138 q: q.trim(),
2139 filters: &active,
2140 per_page: Some(per_page),
2141 children_of: structure.map(|t| t.parent_key.as_str()),
2142 };
2143 let mut carry = String::new();
2146 if !q.trim().is_empty() {
2147 carry.push_str(&format!("&q={}", urlencode(q.trim())));
2148 }
2149 for f in &active {
2150 carry.push_str(&format!("&{}={}", f.filter.param(), urlencode(&f.raw)));
2151 }
2152 let result = query(&state.db, config, &req).await.ok()?;
2153 {
2154 {
2155 let total_pages = ((result.total + per_page - 1) / per_page).max(1);
2156 let date_loc = date_locale(shell.locale());
2157 let labels: Vec<Option<HashMap<String, String>>> = config
2159 .columns
2160 .iter()
2161 .map(|c| {
2162 (!c.labels.is_empty()).then(|| {
2163 c.labels
2164 .iter()
2165 .map(|l| (l.value.clone(), shell.tt(&l.label)))
2166 .collect()
2167 })
2168 })
2169 .collect();
2170 let rows: Vec<RenderedRow> = result
2171 .rows
2172 .into_iter()
2173 .map(|row| RenderedRow {
2174 depth,
2175 indent: format!("{}px", 16 + depth * 18),
2176 toggle: (structure.is_some() && row.children > 0)
2177 .then(|| format!("{path}?parent={}&depth={}", row.id, depth + 1)),
2178 id: row.id.clone(),
2179 cells: row
2180 .cells
2181 .iter()
2182 .zip(config.columns.iter().zip(&labels))
2183 .map(|(raw, (col, labels))| {
2184 let cx = CellCx {
2185 value: raw,
2186 tz: shell.tz,
2187 locale: date_loc,
2188 };
2189 let scope = OverrideScope {
2190 surface: Surface::Column,
2191 view_key: &col.column_type,
2192 resource: Some(&config.entity),
2193 field: Some(&col.field),
2194 };
2195 let html = match (&config.editor, col.editable) {
2196 (Some(editor), true) => cell_markup(
2197 state, &editor.0, path, &row.id, &col.field, raw, &shell, None,
2198 ),
2199 _ => match state.column_types.get(&col.column_type) {
2200 Some(ct) => render_cell(
2201 ct.as_ref(),
2202 state.overrides.as_ref(),
2203 &scope,
2204 &cx,
2205 labels.as_ref(),
2206 )
2207 .into_string(),
2208 None => String::new(),
2210 },
2211 };
2212 CellView {
2213 html,
2214 class: col.align_class().to_string(),
2215 }
2216 })
2217 .collect(),
2218 })
2219 .collect();
2220 let editable_assets: Vec<&'static str> = match &config.editor {
2221 Some(editor) => config
2222 .columns
2223 .iter()
2224 .filter(|c| c.editable)
2225 .flat_map(|c| editor.0.field_assets(&c.field))
2226 .collect(),
2227 None => Vec::new(),
2228 };
2229 let keys: Vec<&str> = config
2230 .columns
2231 .iter()
2232 .filter_map(|c| state.column_types.get(&c.column_type).map(|ct| ct.assets()))
2233 .flatten()
2234 .chain(editable_assets)
2235 .collect();
2236 let mut shell = shell;
2237 shell.assets = crate::page_assets(&keys, &shell.base, &state.assets, &state.asset_urls);
2238 let shown = rows.len() as i64;
2239 let title = shell.tt(&config.title);
2241 let active_dir = match order_dir {
2242 SortDir::Asc => "asc",
2243 SortDir::Desc => "desc",
2244 };
2245 let columns: Vec<ColumnHead> = config
2246 .columns
2247 .iter()
2248 .map(|c| {
2249 let active = c.field == order_by;
2250 ColumnHead {
2251 label: shell.tt(&c.label),
2252 field: c.field.clone(),
2253 next_dir: if active && active_dir == "asc" {
2256 "desc".to_string()
2257 } else {
2258 "asc".to_string()
2259 },
2260 active: if active {
2261 active_dir.to_string()
2262 } else {
2263 String::new()
2264 },
2265 sortable: c.sortable,
2266 width: c.width.clone(),
2267 align_class: c.align_class().to_string(),
2268 }
2269 })
2270 .collect();
2271 let shell_for_toolbar = shell.clone();
2272 let filter_views = filter_views(config, &active, &shell);
2273 let pickers: Vec<ColumnChoice> = allowed_columns(declared, user)
2274 .into_iter()
2275 .map(|c| ColumnChoice {
2276 field: c.field.clone(),
2277 label: shell.tt(&c.label),
2278 shown: config.columns.iter().any(|v| v.field == c.field),
2279 })
2280 .collect();
2281 let export_query = format!("&sort={order_by}&dir={active_dir}{carry}");
2283 let toolbar = toolbar_views(declared, &state.admin_path, user, &shell_for_toolbar);
2284 let search_prompt = config.search.prompt.as_ref().map(|p| shell.tt(p));
2286 let no_records_message = config.no_records_message.as_ref().map(|m| shell.tt(m));
2287 let page_view = ListTemplate {
2288 shell,
2289 title,
2290 columns,
2291 rows,
2292 shown,
2293 page,
2294 total: result.total,
2295 total_pages,
2296 edit_base: config.edit_base.clone(),
2297 row_link: config.row_click == RowClick::Open,
2298 sort: order_by,
2299 dir: active_dir.to_string(),
2300 q: q.trim().to_string(),
2301 searchable: config.search.enabled
2302 && config.columns.iter().any(|c| c.is_searchable()),
2303 search_prompt,
2304 search_on_enter: config.search.on_enter,
2305 no_records_message,
2306 per_page,
2307 per_page_options: config.per_page_options.clone(),
2308 pickers,
2309 toolbar,
2310 export_query,
2311 path: path.to_string(),
2312 filters: filter_views,
2313 deletable: config.deletable,
2314 exportable: config.exportable,
2315 import_href: config
2316 .import
2317 .as_ref()
2318 .filter(|link| user.permissions.allows(&link.permission))
2319 .map(|link| link.href.clone()),
2320 filtered: !active.is_empty() || !q.trim().is_empty(),
2321 carry: carry.clone(),
2322 region: "lat-list".to_string(),
2323 delete_path: None,
2324 reorder,
2325 tree,
2326 crumbs,
2327 };
2328 Some(page_view)
2329 }
2330 }
2331}
2332
2333#[derive(Clone, Debug)]
2336pub(crate) struct RowAction {
2337 pub path: String,
2338 pub label: String,
2339 pub confirm: String,
2340}
2341
2342#[derive(Clone, Debug)]
2344pub(crate) struct ReorderView {
2345 pub path: String,
2346 pub page: i64,
2347 pub parent: String,
2350}
2351
2352#[derive(Clone, Debug)]
2354pub(crate) struct TreeView {
2355 pub move_path: Option<String>,
2357}
2358
2359pub(crate) struct Crumb {
2361 pub label: String,
2362 pub href: String,
2363}
2364
2365async fn crumbs(
2368 db: &Db,
2369 config: &ListConfig,
2370 tree: &TreeConfig,
2371 parent: &str,
2372 path: &str,
2373) -> Vec<Crumb> {
2374 let mut trail = Vec::new();
2375 let mut current = Some(parent.to_string());
2376 for _ in 0..64 {
2378 let Some(id) = current.take() else { break };
2379 match parent_row(db, config, tree, &id).await {
2380 Ok(Some((label, above))) => {
2381 trail.push(Crumb {
2382 label,
2383 href: format!("{path}?parent={id}"),
2384 });
2385 current = above;
2386 }
2387 _ => break,
2388 }
2389 }
2390 trail.push(Crumb {
2391 label: String::new(),
2392 href: path.to_string(),
2393 });
2394 trail.reverse();
2395 trail
2396}
2397
2398async fn parent_row(
2401 db: &Db,
2402 config: &ListConfig,
2403 tree: &TreeConfig,
2404 id: &str,
2405) -> anyhow::Result<Option<(String, Option<String>)>> {
2406 let label_col = config
2407 .columns
2408 .first()
2409 .map(|c| c.field.as_str())
2410 .unwrap_or(&config.id_field);
2411 let (sql, values) = {
2412 let cast = Alias::new(text_cast(db.backend));
2413 build(
2414 db.backend,
2415 Query::select()
2416 .expr_as(
2417 Expr::col(Alias::new(label_col)).cast_as(cast.clone()),
2418 Alias::new("lat_label"),
2419 )
2420 .expr_as(
2421 Expr::col(Alias::new(&tree.parent_key)).cast_as(cast.clone()),
2422 Alias::new("lat_parent"),
2423 )
2424 .from(Alias::new(&config.entity))
2425 .and_where(
2426 Expr::col(Alias::new(&config.id_field))
2427 .cast_as(cast)
2428 .eq(id.to_string()),
2429 )
2430 .to_owned(),
2431 )
2432 };
2433 let row = bind_values(sqlx::query(&sql), values)
2434 .fetch_optional(&db.pool)
2435 .await?;
2436 Ok(row.map(|row| {
2437 let above = row.get_text("lat_parent").ok().filter(|p| !p.is_empty());
2438 (get_text(&row, "lat_label"), above)
2439 }))
2440}
2441
2442async fn ordered_keys(
2444 db: &Db,
2445 config: &ListConfig,
2446 column: &str,
2447) -> anyhow::Result<Vec<(String, i64)>> {
2448 let (sql, values) = {
2449 let cast = Alias::new(text_cast(db.backend));
2450 let mut select = Query::select();
2451 select
2452 .expr_as(
2453 Expr::col(Alias::new(&config.id_field)).cast_as(cast.clone()),
2454 Alias::new(ID_ALIAS),
2455 )
2456 .expr_as(
2457 Expr::col(Alias::new(column)).cast_as(cast),
2458 Alias::new("lat_key"),
2459 )
2460 .from(Alias::new(&config.entity))
2461 .order_by(Alias::new(column), Order::Asc)
2462 .order_by(Alias::new(&config.id_field), Order::Asc);
2463 if let Some(scope) = &config.scope {
2464 select.and_where(scope.condition(db.backend, &config.id_field));
2465 }
2466 build(db.backend, select)
2467 };
2468 let rows = bind_values(sqlx::query(&sql), values)
2469 .fetch_all(&db.pool)
2470 .await?;
2471 Ok(rows
2472 .iter()
2473 .map(|row| {
2474 let key = get_text(row, "lat_key").trim().parse().unwrap_or(0);
2475 (get_text(row, ID_ALIAS), key)
2476 })
2477 .collect())
2478}
2479
2480async fn write_keys(
2482 db: &Db,
2483 config: &ListConfig,
2484 column: &str,
2485 changes: &[(String, i64)],
2486) -> anyhow::Result<()> {
2487 let mut tx = db.pool.begin().await?;
2488 for (id, key) in changes {
2489 let (sql, values) = build(
2490 db.backend,
2491 Query::update()
2492 .table(Alias::new(&config.entity))
2493 .value(Alias::new(column), *key)
2494 .and_where(
2495 Expr::col(Alias::new(&config.id_field))
2496 .cast_as(Alias::new(text_cast(db.backend)))
2497 .eq(id.clone()),
2498 )
2499 .to_owned(),
2500 );
2501 bind_values(sqlx::query(&sql), values)
2502 .execute(&mut *tx)
2503 .await?;
2504 }
2505 tx.commit().await?;
2506 Ok(())
2507}
2508
2509enum Move {
2511 Up,
2512 Down,
2513 Before(String),
2514 After(String),
2515}
2516
2517fn placement(order: &[(String, i64)], id: &str, target: usize) -> Vec<(String, i64)> {
2520 let mut ids: Vec<&str> = order.iter().map(|(i, _)| i.as_str()).collect();
2521 let from = match ids.iter().position(|i| *i == id) {
2522 Some(from) => from,
2523 None => return Vec::new(),
2524 };
2525 ids.remove(from);
2526 let target = target.min(ids.len());
2527 ids.insert(target, id);
2528 let key_of = |i: &str| {
2529 order
2530 .iter()
2531 .find(|(o, _)| o == i)
2532 .map(|(_, k)| *k)
2533 .unwrap_or(0)
2534 };
2535 let prev = target.checked_sub(1).map(|i| key_of(ids[i]));
2536 let next = ids.get(target + 1).map(|i| key_of(i));
2537 let key = match (prev, next) {
2538 (Some(a), Some(b)) if b - a >= 2 => Some(a + (b - a) / 2),
2539 (Some(a), None) => Some(a + REORDER_STEP),
2540 (None, Some(b)) => Some(b - REORDER_STEP),
2541 (None, None) => Some(key_of(id)),
2542 _ => None,
2543 };
2544 match key {
2545 Some(key) => vec![(id.to_string(), key)],
2546 None => ids
2547 .iter()
2548 .enumerate()
2549 .map(|(i, row)| (row.to_string(), (i as i64 + 1) * REORDER_STEP))
2550 .filter(|(row, key)| key_of(row) != *key)
2551 .collect(),
2552 }
2553}
2554
2555#[allow(clippy::too_many_arguments)]
2559pub(crate) async fn reorder_handler(
2560 state: &AdminState,
2561 config: &ListConfig,
2562 path: &str,
2563 region: &str,
2564 row_action: Option<RowAction>,
2565 user: &laterite_auth::AuthenticatedUser,
2566 session: &crate::session::SessionHandle,
2567 shell: crate::Shell,
2568 headers: &axum::http::HeaderMap,
2569 pairs: &[(String, String)],
2570) -> Response {
2571 let Some(reorder) = &config.reorder else {
2572 return crate::not_found();
2573 };
2574 let field = |name: &str| {
2575 pairs
2576 .iter()
2577 .find(|(k, _)| k == name)
2578 .map(|(_, v)| v.trim().to_string())
2579 .filter(|v| !v.is_empty())
2580 };
2581 let page = field("page")
2582 .and_then(|p| p.parse().ok())
2583 .unwrap_or(1)
2584 .max(1);
2585 let parent = field("parent");
2587 let scoped;
2588 let config = match &config.tree {
2589 Some(tree) => {
2590 let mut c = config.clone();
2591 c.scope = Some(Scope::Column {
2592 column: tree.parent_key.clone(),
2593 value: parent.clone(),
2594 });
2595 scoped = c;
2596 &scoped
2597 }
2598 None => config,
2599 };
2600 let fatal = 'apply: {
2601 let (Some(id), Some(mv)) = (
2602 field("id"),
2603 match (field("move").as_deref(), field("before"), field("after")) {
2604 (Some("up"), _, _) => Some(Move::Up),
2605 (Some("down"), _, _) => Some(Move::Down),
2606 (_, Some(before), _) => Some(Move::Before(before)),
2607 (_, _, Some(after)) => Some(Move::After(after)),
2608 _ => None,
2609 },
2610 ) else {
2611 break 'apply false;
2612 };
2613 let order = match ordered_keys(&state.db, config, &reorder.column).await {
2614 Ok(order) => order,
2615 Err(e) => {
2616 tracing::error!(entity = %config.entity, error = %e, "reorder read failed");
2617 break 'apply true;
2618 }
2619 };
2620 let position = |needle: &str| order.iter().position(|(i, _)| i == needle);
2621 let Some(from) = position(&id) else {
2622 session.push_flash(
2623 crate::session::FlashLevel::Error,
2624 laterite_core::t!("That record is no longer in the list."),
2625 );
2626 break 'apply false;
2627 };
2628 let target = match mv {
2629 Move::Up => from.saturating_sub(1),
2630 Move::Down => from + 1,
2631 Move::Before(other) => match position(&other) {
2632 Some(p) if p > from => p - 1,
2633 Some(p) => p,
2634 None => from,
2635 },
2636 Move::After(other) => match position(&other) {
2637 Some(p) if p > from => p,
2638 Some(p) => p + 1,
2639 None => from,
2640 },
2641 };
2642 let changes = placement(&order, &id, target);
2643 if changes.is_empty() {
2644 break 'apply false;
2645 }
2646 if let Err(e) = write_keys(&state.db, config, &reorder.column, &changes).await {
2647 tracing::error!(entity = %config.entity, error = %e, "reorder write failed");
2648 break 'apply true;
2649 }
2650 crate::audit::record(
2651 state,
2652 user,
2653 &format!("{}.reorder", config.entity),
2654 Some(&config.entity),
2655 Some(&id),
2656 None,
2657 Some(&format!("position {}", target.min(order.len() - 1) + 1)),
2658 )
2659 .await;
2660 false
2661 };
2662 if fatal {
2663 return render_error();
2664 }
2665 if crate::form::is_htmx(headers) {
2666 let params = ListParams {
2667 page: Some(page),
2668 parent: parent.clone(),
2669 depth: field("depth").and_then(|d| d.parse().ok()),
2670 ..Default::default()
2671 };
2672 let Some(view) =
2673 build_page(state, config, path, params, &HashMap::new(), user, shell).await
2674 else {
2675 return render_error();
2676 };
2677 return if parent.is_some() && view.tree.is_some() {
2678 render(rows_of(view, region, row_action))
2679 } else {
2680 render(fragment_of(view, region, row_action))
2681 };
2682 }
2683 let back = match parent {
2684 Some(parent) => format!("{path}?parent={parent}&page={page}"),
2685 None => format!("{path}?page={page}"),
2686 };
2687 axum::response::Redirect::to(&back).into_response()
2688}
2689
2690pub(crate) async fn move_form(
2692 state: &AdminState,
2693 config: &ListConfig,
2694 form: &crate::form::PreparedForm,
2695 path: &str,
2696 id: &str,
2697 shell: crate::Shell,
2698) -> Response {
2699 let Some(tree) = &config.tree else {
2700 return crate::not_found();
2701 };
2702 let current = match parent_row(&state.db, config, tree, id).await {
2703 Ok(Some((_, parent))) => parent.unwrap_or_default(),
2704 Ok(None) => return crate::not_found(),
2705 Err(_) => return render_error(),
2706 };
2707 let mut values = HashMap::new();
2708 values.insert(tree.parent_key.clone(), current);
2709 let action = format!("{path}/{id}/move");
2710 crate::form::page(state, form, &action, &values, &shell)
2711}
2712
2713#[allow(clippy::too_many_arguments)]
2716pub(crate) async fn move_record(
2717 state: &AdminState,
2718 config: &ListConfig,
2719 form: &crate::form::PreparedForm,
2720 path: &str,
2721 id: &str,
2722 user: &laterite_auth::AuthenticatedUser,
2723 session: &crate::session::SessionHandle,
2724 shell: crate::Shell,
2725 headers: &axum::http::HeaderMap,
2726 data: HashMap<String, String>,
2727) -> Response {
2728 let Some(tree) = &config.tree else {
2729 return crate::not_found();
2730 };
2731 match parent_row(&state.db, config, tree, id).await {
2732 Ok(Some(_)) => {}
2733 Ok(None) => return crate::not_found(),
2734 Err(_) => return render_error(),
2735 }
2736 let action = format!("{path}/{id}/move");
2737 let chosen = data
2738 .get(&tree.parent_key)
2739 .map(|v| v.trim().to_string())
2740 .filter(|v| !v.is_empty());
2741 let mut bag = laterite_core::validation::ErrorBag::default();
2742 if let Some(parent) = &chosen {
2743 let mut current = Some(parent.clone());
2746 let mut steps = 0;
2747 while let Some(at) = current.take() {
2748 if at == id {
2749 bag.add(
2750 &tree.parent_key,
2751 laterite_core::t!("A record cannot be moved under itself."),
2752 );
2753 break;
2754 }
2755 match parent_row(&state.db, config, tree, &at).await {
2756 Ok(Some((_, above))) => current = above,
2757 Ok(None) => {
2758 bag.add(
2759 &tree.parent_key,
2760 laterite_core::t!("There is no such record."),
2761 );
2762 break;
2763 }
2764 Err(_) => return render_error(),
2765 }
2766 steps += 1;
2767 if steps > 64 {
2768 break;
2769 }
2770 }
2771 }
2772 let htmx = crate::form::is_htmx(headers);
2773 if !bag.is_empty() {
2774 return crate::form::refused(htmx, state, form, &action, None, &data, &bag, &shell);
2775 }
2776 let actor = laterite_core::Actor::from(user);
2777 let outcome = async {
2778 let mut tx = state
2779 .db
2780 .pool
2781 .begin()
2782 .await
2783 .map_err(|e| MoveFailure::Failed(e.to_string()))?;
2784 let mut cx = laterite_core::SaveCx::new(state.db.backend, &mut tx, actor);
2785 let request = crate::persist::MoveTo {
2786 entity: &config.entity,
2787 id_field: &config.id_field,
2788 id,
2789 parent_key: &tree.parent_key,
2790 parent: chosen.as_deref(),
2791 };
2792 form.persister()
2793 .move_to(&mut cx, &request)
2794 .await
2795 .map_err(|e| match e {
2796 crate::persist::SaveError::Invalid(bag) => MoveFailure::Refused(bag),
2797 crate::persist::SaveError::Failed(m) => MoveFailure::Failed(m),
2798 })?;
2799 tx.commit()
2800 .await
2801 .map_err(|e| MoveFailure::Failed(e.to_string()))
2802 }
2803 .await;
2804 match outcome {
2805 Ok(()) => {
2806 crate::audit::record(
2807 state,
2808 user,
2809 &format!("{}.move", config.entity),
2810 Some(&config.entity),
2811 Some(id),
2812 None,
2813 Some(&match &chosen {
2814 Some(parent) => format!("under {parent}"),
2815 None => "top level".to_string(),
2816 }),
2817 )
2818 .await;
2819 session.push_flash(
2820 crate::session::FlashLevel::Success,
2821 laterite_core::t!("Moved."),
2822 );
2823 let back = match chosen {
2824 Some(parent) => format!("{path}?parent={parent}"),
2825 None => path.to_string(),
2826 };
2827 crate::form::saved_response(htmx, &back)
2828 }
2829 Err(MoveFailure::Refused(bag)) => {
2830 crate::form::refused(htmx, state, form, &action, None, &data, &bag, &shell)
2831 }
2832 Err(MoveFailure::Failed(m)) => {
2833 tracing::error!(entity = %config.entity, id, error = %m, "move failed");
2834 crate::form::refused(
2835 htmx,
2836 state,
2837 form,
2838 &action,
2839 Some(laterite_core::t!("Could not move. Try again.")),
2840 &data,
2841 &bag,
2842 &shell,
2843 )
2844 }
2845 }
2846}
2847
2848enum MoveFailure {
2849 Refused(laterite_core::validation::ErrorBag),
2850 Failed(String),
2851}
2852
2853#[allow(clippy::too_many_arguments)]
2857fn cell_markup(
2858 state: &AdminState,
2859 form: &crate::form::PreparedForm,
2860 path: &str,
2861 id: &str,
2862 field: &str,
2863 value: &str,
2864 shell: &crate::Shell,
2865 error: Option<&str>,
2866) -> String {
2867 let dom_id = format!("cell-{id}-{field}");
2868 let control = form
2869 .control(state, field, &dom_id, value, shell)
2870 .unwrap_or_default();
2871 let action = format!("{path}/{id}/cell");
2872 let mut html = format!(
2873 r#"<form class="lat-cell{}" method="post" action="{action}" hx-post="{action}" hx-trigger="change, submit" hx-target="this" hx-swap="outerHTML" data-lat-widget="cell" data-lat-enter-scope><input type="hidden" name="_csrf" value="{}"><input type="hidden" name="field" value="{}">{control}<button class="lat-btn lat-btn--ghost lat-btn--sm lat-cell__save" type="submit">{}</button>"#,
2874 if error.is_some() { " is-invalid" } else { "" },
2875 crate::form::escape_attr(&shell.csrf_token),
2876 crate::form::escape_attr(field),
2877 shell.tt(&laterite_core::t!("Save")),
2878 );
2879 if let Some(message) = error {
2880 html.push_str(&format!(
2881 r#"<span class="lat-cell__error">{}</span>"#,
2882 crate::form::escape_attr(message)
2883 ));
2884 }
2885 html.push_str("</form>");
2886 html
2887}
2888
2889#[allow(clippy::too_many_arguments)]
2894pub(crate) async fn cell_handler(
2895 state: &AdminState,
2896 config: &ListConfig,
2897 path: &str,
2898 id: &str,
2899 user: &laterite_auth::AuthenticatedUser,
2900 session: &crate::session::SessionHandle,
2901 shell: crate::Shell,
2902 headers: &axum::http::HeaderMap,
2903 mut data: HashMap<String, String>,
2904) -> Response {
2905 let Some(editor) = &config.editor else {
2906 return crate::not_found();
2907 };
2908 let form = &editor.0;
2909 let Some(field) = data.remove("field") else {
2910 return crate::not_found();
2911 };
2912 data.remove("_csrf");
2913 if !config
2914 .columns
2915 .iter()
2916 .any(|c| c.editable && c.field == field)
2917 || !form.has_field(&field)
2918 {
2919 return crate::not_found();
2920 }
2921 let htmx = crate::form::is_htmx(headers);
2922 let back = path.to_string();
2923 let current = match crate::form::load_values(&state.db, form, id).await {
2924 Ok(Some(values)) => values,
2925 Ok(None) => return crate::not_found(),
2926 Err(_) => return render_error(),
2927 };
2928 let submitted = data.get(&field).cloned().unwrap_or_default();
2932 let mut merged = current.clone();
2933 merged.remove(&field);
2934 for (key, value) in data {
2935 merged.insert(key, value);
2936 }
2937 let merged = crate::form::gathered(form, merged);
2938 let actor = laterite_core::Actor::from(user);
2939 match crate::form::write(&state.db, form, &merged, Some(id), &actor).await {
2940 Ok(_) => {
2941 if htmx {
2942 let stored = crate::form::load_values(&state.db, form, id)
2943 .await
2944 .ok()
2945 .flatten()
2946 .and_then(|values| values.get(&field).cloned())
2947 .unwrap_or(submitted);
2948 let html = cell_markup(state, form, path, id, &field, &stored, &shell, None);
2949 return axum::response::Html(html).into_response();
2950 }
2951 session.push_flash(
2952 crate::session::FlashLevel::Success,
2953 laterite_core::t!("Saved."),
2954 );
2955 crate::form::saved_response(false, &back)
2956 }
2957 Err(crate::persist::SaveError::Invalid(bag)) => {
2958 let message = bag
2959 .messages(&field)
2960 .first()
2961 .map(|m| shell.tt(m))
2962 .or_else(|| {
2963 bag.iter()
2964 .next()
2965 .and_then(|(_, m)| m.first())
2966 .map(|m| shell.tt(m))
2967 })
2968 .unwrap_or_else(|| shell.tt(&laterite_core::t!("Could not save.")));
2969 if htmx {
2970 let html = cell_markup(
2971 state,
2972 form,
2973 path,
2974 id,
2975 &field,
2976 &submitted,
2977 &shell,
2978 Some(&message),
2979 );
2980 return (
2981 axum::http::StatusCode::UNPROCESSABLE_ENTITY,
2982 axum::response::Html(html),
2983 )
2984 .into_response();
2985 }
2986 session.push_flash(crate::session::FlashLevel::Error, Text::dynamic(message));
2987 crate::form::saved_response(false, &back)
2988 }
2989 Err(crate::persist::SaveError::Failed(m)) => {
2990 tracing::error!(entity = %config.entity, id, field, error = %m, "cell save failed");
2991 render_error()
2992 }
2993 }
2994}
2995
2996fn fragment_of(page: ListTemplate, region: &str, delete_path: Option<RowAction>) -> ListFragment {
2998 ListFragment {
2999 shell: page.shell,
3000 columns: page.columns,
3001 rows: page.rows,
3002 shown: page.shown,
3003 page: page.page,
3004 total: page.total,
3005 total_pages: page.total_pages,
3006 edit_base: page.edit_base,
3007 row_link: page.row_link,
3008 sort: page.sort,
3009 dir: page.dir,
3010 carry: page.carry,
3011 filtered: page.filtered,
3012 deletable: page.deletable,
3013 no_records_message: page.no_records_message,
3014 path: page.path,
3015 region: region.to_string(),
3016 delete_path,
3017 reorder: page.reorder,
3018 tree: page.tree,
3019 }
3020}
3021
3022fn rows_of(page: ListTemplate, region: &str, delete_path: Option<RowAction>) -> RowsFragment {
3024 RowsFragment {
3025 shell: page.shell,
3026 rows: page.rows,
3027 page: page.page,
3028 total_pages: page.total_pages,
3029 edit_base: page.edit_base,
3030 row_link: page.row_link,
3031 deletable: page.deletable,
3032 region: region.to_string(),
3033 delete_path,
3034 reorder: page.reorder,
3035 tree: page.tree,
3036 }
3037}
3038
3039#[allow(clippy::too_many_arguments)]
3043pub(crate) async fn fragment(
3044 state: &AdminState,
3045 config: &ListConfig,
3046 path: &str,
3047 params: ListParams,
3048 raw: &HashMap<String, String>,
3049 user: &laterite_auth::AuthenticatedUser,
3050 shell: crate::Shell,
3051 region: &str,
3052 delete_path: Option<RowAction>,
3053) -> Result<crate::html::Markup, ()> {
3054 let page = build_page(state, config, path, params, raw, user, shell)
3055 .await
3056 .ok_or(())?;
3057 crate::html::Markup::from_template(&fragment_of(page, region, delete_path)).map_err(|_| ())
3058}
3059
3060pub(crate) async fn set_columns(
3067 state: &AdminState,
3068 config: &ListConfig,
3069 path: &str,
3070 user: &laterite_auth::AuthenticatedUser,
3071 session: &crate::session::SessionHandle,
3072 headers: &axum::http::HeaderMap,
3073 pairs: &[(String, String)],
3074) -> Response {
3075 let allowed = allowed_columns(config, user);
3076 let chosen: Vec<&str> = pairs
3077 .iter()
3078 .filter(|(k, _)| k == "column")
3079 .map(|(_, v)| v.trim())
3080 .filter(|v| allowed.iter().any(|c| c.field == *v))
3081 .collect();
3082 let mut by_default: Vec<&str> = allowed
3086 .iter()
3087 .filter(|c| !c.invisible)
3088 .map(|c| c.field.as_str())
3089 .collect();
3090 let mut as_chosen = chosen.clone();
3091 by_default.sort_unstable();
3092 as_chosen.sort_unstable();
3093 as_chosen.dedup();
3094
3095 let size_key = per_page_preference_key(path);
3098 let size = pairs
3099 .iter()
3100 .find(|(k, _)| k == "per_page")
3101 .and_then(|(_, v)| v.trim().parse::<i64>().ok())
3102 .filter(|n| config.per_page_options.contains(n));
3103 let size_result = match size {
3104 Some(n) if n != config.per_page => {
3105 laterite_auth::store::set_user_preference(
3106 &state.db,
3107 user.user.id,
3108 &size_key,
3109 &n.to_string(),
3110 )
3111 .await
3112 }
3113 _ => laterite_auth::store::clear_user_preference(&state.db, user.user.id, &size_key).await,
3114 };
3115
3116 let key = columns_preference_key(path);
3117 let result = if chosen.is_empty() || as_chosen == by_default {
3118 laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
3119 } else {
3120 laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &chosen.join(","))
3121 .await
3122 };
3123 match result.and(size_result) {
3124 Ok(()) => session.push_flash(
3125 crate::session::FlashLevel::Success,
3126 laterite_core::t!("List setup saved."),
3127 ),
3128 Err(e) => {
3129 tracing::error!(error = %e, "storing the list setup failed");
3130 session.push_flash(
3131 crate::session::FlashLevel::Error,
3132 laterite_core::t!("Could not save your list setup."),
3133 );
3134 }
3135 }
3136 let back = format!("{}{}", state.admin_path, path);
3137 crate::form::saved_response(crate::form::is_htmx(headers), &back)
3138}
3139
3140pub struct ColumnChoice {
3142 pub field: String,
3143 pub label: String,
3144 pub shown: bool,
3145}
3146
3147pub struct ColumnHead {
3150 pub label: String,
3151 pub field: String,
3152 pub next_dir: String,
3153 pub active: String,
3154 pub sortable: bool,
3155 pub width: Option<String>,
3156 pub align_class: String,
3157}
3158
3159#[derive(Template)]
3160#[template(path = "list.html")]
3161struct ListTemplate {
3162 shell: crate::Shell,
3163 title: String,
3164 columns: Vec<ColumnHead>,
3165 rows: Vec<RenderedRow>,
3166 shown: i64,
3168 page: i64,
3169 total: i64,
3170 total_pages: i64,
3171 edit_base: Option<String>,
3172 row_link: bool,
3174 sort: String,
3176 dir: String,
3177 q: String,
3179 searchable: bool,
3181 search_prompt: Option<String>,
3183 search_on_enter: bool,
3184 no_records_message: Option<String>,
3186 per_page: i64,
3189 per_page_options: Vec<i64>,
3190 deletable: bool,
3192 exportable: bool,
3195 import_href: Option<String>,
3198 pickers: Vec<ColumnChoice>,
3200 toolbar: Vec<ToolbarView>,
3202 export_query: String,
3205 path: String,
3207 filters: Vec<FilterView>,
3209 filtered: bool,
3211 carry: String,
3213 region: String,
3215 delete_path: Option<RowAction>,
3217 reorder: Option<ReorderView>,
3219 tree: Option<TreeView>,
3221 crumbs: Vec<Crumb>,
3223}
3224
3225pub struct FilterView {
3227 pub param: String,
3228 pub label: String,
3229 pub input_type: String,
3231 pub options: Vec<FilterOptionView>,
3232 pub value: String,
3234}
3235
3236pub struct FilterOptionView {
3237 pub value: String,
3238 pub label: String,
3239}
3240
3241fn filter_views(
3243 config: &ListConfig,
3244 active: &[ActiveFilter<'_>],
3245 shell: &crate::Shell,
3246) -> Vec<FilterView> {
3247 config
3248 .filters
3249 .iter()
3250 .map(|f| {
3251 let value = active
3252 .iter()
3253 .find(|a| a.filter.field == f.field)
3254 .map(|a| a.raw.clone())
3255 .unwrap_or_default();
3256 let options: Vec<FilterOptionView> = match f.filter_type.as_str() {
3259 "boolean" => vec![
3260 FilterOptionView {
3261 value: "1".to_string(),
3262 label: shell.tt(&laterite_core::t!("Yes")),
3263 },
3264 FilterOptionView {
3265 value: "0".to_string(),
3266 label: shell.tt(&laterite_core::t!("No")),
3267 },
3268 ],
3269 "select" => f
3270 .choices()
3271 .iter()
3272 .map(|o| FilterOptionView {
3273 value: o.value.clone(),
3274 label: shell.tt(&o.label),
3275 })
3276 .collect(),
3277 _ => Vec::new(),
3278 };
3279 let input_type = match f.filter_type.as_str() {
3280 "number" => "number",
3281 "date" => "date",
3282 _ => "text",
3283 };
3284 FilterView {
3285 param: f.param(),
3286 label: shell.tt(&f.label),
3287 input_type: input_type.to_string(),
3288 options,
3289 value,
3290 }
3291 })
3292 .collect()
3293}
3294
3295fn urlencode(value: &str) -> String {
3297 let mut out = String::with_capacity(value.len());
3298 for byte in value.as_bytes() {
3299 match byte {
3300 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
3301 out.push(*byte as char)
3302 }
3303 b' ' => out.push('+'),
3304 _ => out.push_str(&format!("%{byte:02X}")),
3305 }
3306 }
3307 out
3308}
3309
3310#[derive(Template)]
3312#[template(path = "_list_table.html")]
3313struct ListFragment {
3314 shell: crate::Shell,
3315 columns: Vec<ColumnHead>,
3316 rows: Vec<RenderedRow>,
3317 shown: i64,
3318 page: i64,
3319 total: i64,
3320 total_pages: i64,
3321 edit_base: Option<String>,
3322 row_link: bool,
3324 sort: String,
3325 dir: String,
3326 carry: String,
3327 filtered: bool,
3331 deletable: bool,
3332 no_records_message: Option<String>,
3333 path: String,
3336 region: String,
3339 delete_path: Option<RowAction>,
3342 reorder: Option<ReorderView>,
3344 tree: Option<TreeView>,
3345}
3346
3347#[derive(Template)]
3349#[template(path = "_list_rows.html")]
3350struct RowsFragment {
3351 shell: crate::Shell,
3352 rows: Vec<RenderedRow>,
3353 page: i64,
3354 total_pages: i64,
3355 edit_base: Option<String>,
3356 row_link: bool,
3357 deletable: bool,
3358 region: String,
3359 delete_path: Option<RowAction>,
3360 reorder: Option<ReorderView>,
3361 tree: Option<TreeView>,
3362}
3363
3364#[cfg(test)]
3365mod tests {
3366 use super::*;
3367
3368 fn config() -> ListConfig {
3369 ListConfig {
3370 entity: "backend_users".to_string(),
3371 title: "Users".into(),
3372 columns: vec![
3373 ListColumn::new("username", "Username"),
3374 ListColumn::new("is_superuser", "Superuser").yes_no(),
3375 ],
3376 order_by: "created_at".to_string(),
3377 order_dir: SortDir::Desc,
3378 per_page: 25,
3379 id_field: "id".to_string(),
3380 edit_base: None,
3381 creatable: false,
3382 filters: vec![ListFilter::boolean("is_superuser", "Superuser")],
3383 deletable: true,
3384 ..Default::default()
3385 }
3386 }
3387
3388 #[test]
3389 fn column_types_format_their_cell_display() {
3390 let ist: Tz = "Asia/Kolkata".parse().unwrap();
3391 let display = |ct: &dyn ColumnType, value: &str, tz: Tz| {
3392 ct.view_model(&CellCx {
3393 value,
3394 tz,
3395 locale: chrono::Locale::en_US,
3396 })
3397 .display
3398 };
3399 assert_eq!(
3401 display(&DateTimeColumn, "2026-08-13T10:00:00+00:00", ist),
3402 "13 Aug 2026, 15:30"
3403 );
3404 assert_eq!(
3405 display(&DateColumn, "2026-08-13T10:00:00+00:00", Tz::UTC),
3406 "13 Aug 2026"
3407 );
3408 assert_eq!(
3409 display(&TimeColumn, "2026-08-13T10:00:00+00:00", ist),
3410 "15:30"
3411 );
3412 assert_eq!(display(&BoolColumn, "true", Tz::UTC), "Yes");
3413 assert_eq!(display(&BoolColumn, "false", Tz::UTC), "No");
3414 assert_eq!(display(&TextColumn, "root", Tz::UTC), "root");
3415 assert_eq!(display(&DateTimeColumn, "n/a", Tz::UTC), "n/a");
3417 }
3418
3419 #[test]
3420 fn sort_falls_back_to_the_configured_order() {
3421 let c = config();
3422 assert_eq!(
3423 resolve_sort(&c, None, None),
3424 ("created_at".to_string(), SortDir::Desc)
3425 );
3426 }
3427
3428 #[test]
3429 fn a_declared_column_can_be_sorted_either_way() {
3430 let c = config();
3431 assert_eq!(
3432 resolve_sort(&c, Some("username"), Some("asc")),
3433 ("username".to_string(), SortDir::Asc)
3434 );
3435 assert_eq!(
3436 resolve_sort(&c, Some("username"), Some("desc")),
3437 ("username".to_string(), SortDir::Desc)
3438 );
3439 }
3440
3441 #[test]
3444 fn an_undeclared_sort_column_is_ignored() {
3445 let c = config();
3446 for crafted in [
3447 "password_hash",
3448 "id) --",
3449 "username; drop table backend_users",
3450 "",
3451 ] {
3452 assert_eq!(
3453 resolve_sort(&c, Some(crafted), Some("asc")).0,
3454 "created_at",
3455 "{crafted} must not reach the query"
3456 );
3457 }
3458 }
3459
3460 #[test]
3461 fn an_unknown_direction_falls_back() {
3462 let c = config();
3463 assert_eq!(
3465 resolve_sort(&c, Some("username"), Some("sideways")).1,
3466 SortDir::Asc
3467 );
3468 assert_eq!(resolve_sort(&c, Some("created_at"), None).1, SortDir::Desc);
3470 }
3471
3472 #[test]
3475 fn a_contributed_column_type_renders_its_cell() {
3476 struct Rating;
3477 impl ColumnType for Rating {
3478 fn view_key(&self) -> &'static str {
3479 "acme.rating"
3480 }
3481 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
3482 let stars = cx.value.parse::<usize>().unwrap_or(0);
3483 text_cell("acme.rating", cx.value, "*".repeat(stars))
3484 }
3485 fn render_default(&self, vm: &CellVm) -> Markup {
3486 render_text(vm)
3487 }
3488 }
3489
3490 let reg = ColumnTypeReg::new(Arc::new(Rating));
3491 assert_eq!(reg.name(), "acme.rating", "the type names itself");
3492
3493 let mut registry = builtin_column_registry();
3494 registry.insert(reg.name().to_string(), reg.column_type);
3495 let rendered = render_cell(
3496 registry.get("acme.rating").unwrap().as_ref(),
3497 &crate::field::NoOverrides,
3498 &OverrideScope {
3499 surface: Surface::Column,
3500 view_key: "acme.rating",
3501 resource: Some("products"),
3502 field: Some("stars"),
3503 },
3504 &CellCx {
3505 value: "3",
3506 tz: Tz::UTC,
3507 locale: chrono::Locale::en_US,
3508 },
3509 None,
3510 );
3511 assert!(rendered.into_string().contains("***"));
3512 }
3513
3514 #[test]
3517 fn a_contributed_key_is_the_types_own_and_collides_visibly() {
3518 struct Impostor;
3519 impl ColumnType for Impostor {
3520 fn view_key(&self) -> &'static str {
3521 "text"
3522 }
3523 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
3524 text_cell("text", cx.value, String::new())
3525 }
3526 fn render_default(&self, vm: &CellVm) -> Markup {
3527 render_text(vm)
3528 }
3529 }
3530 let reg = ColumnTypeReg::new(Arc::new(Impostor));
3531 assert_eq!(reg.name(), "text");
3532 assert!(builtin_column_registry().contains_key(reg.name()));
3534 }
3535
3536 #[test]
3537 fn status_pill_renders_a_slugged_markup_span() {
3538 let vm = StatusPillColumn.view_model(&CellCx {
3539 value: "In Progress",
3540 tz: Tz::UTC,
3541 locale: chrono::Locale::en_US,
3542 });
3543 let html = StatusPillColumn.render_default(&vm).into_string();
3544 assert!(html.contains(r#"class="lat-status lat-status--in-progress""#));
3545 assert!(html.contains(">In Progress<"));
3546 }
3547
3548 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
3551 laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
3552 }
3553
3554 #[tokio::test]
3555 async fn query_returns_display_rows() {
3556 let (db, _guard) = test_db().await;
3557 let hash = laterite_auth::password::hash_password("pw").unwrap();
3558 laterite_auth::store::create_user(
3559 &db,
3560 "root",
3561 "root@example.test",
3562 "Ada",
3563 None,
3564 &hash,
3565 true,
3566 )
3567 .await
3568 .unwrap();
3569
3570 let c = config();
3571 let result = query(&db, &c, &plain(&c)).await.unwrap();
3572 assert_eq!(result.total, 1);
3573 assert_eq!(result.rows.len(), 1);
3574 assert_eq!(result.rows[0].cells[0], "root");
3575 assert_eq!(result.rows[0].cells[1], "1");
3578 assert!(!result.rows[0].id.is_empty());
3579 }
3580
3581 async fn get(params: ListParams, headers: axum::http::HeaderMap) -> String {
3582 get_with(params, HashMap::new(), headers).await
3583 }
3584
3585 async fn get_with(
3586 params: ListParams,
3587 raw: HashMap<String, String>,
3588 headers: axum::http::HeaderMap,
3589 ) -> String {
3590 let (db, _guard) = test_db().await;
3591 let state = AdminState::new(
3592 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
3593 db,
3594 );
3595 let resp = handle(
3596 &state,
3597 &config(),
3598 "/admin/users",
3599 params,
3600 &raw,
3601 &crate::audit::test_actor(),
3602 crate::Shell::test(),
3603 &headers,
3604 )
3605 .await;
3606 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
3607 .await
3608 .unwrap();
3609 String::from_utf8(bytes.to_vec()).unwrap()
3610 }
3611
3612 #[test]
3613 fn a_typed_filter_accepts_only_its_own_shape() {
3614 let mut c = config();
3615 c.filters = vec![
3616 ListFilter::number("views", "Views"),
3617 ListFilter::date("created_at", "Created"),
3618 ListFilter::text("username", "Username"),
3619 ];
3620 let accepted = |param: &str, value: &str| {
3621 !resolve_filters(&c, &filter_params(&[(param, value)])).is_empty()
3622 };
3623 assert!(accepted("f_views", "42"));
3624 assert!(
3625 !accepted("f_views", "4.2"),
3626 "a number filter takes whole numbers"
3627 );
3628 assert!(!accepted("f_views", "; drop table"), "and nothing else");
3629
3630 assert!(accepted("f_created_at", "2026-09-21"));
3631 assert!(
3632 !accepted("f_created_at", "2026-9-21"),
3633 "a date is ISO or nothing"
3634 );
3635 assert!(
3636 !accepted("f_created_at", "2026-09-21%"),
3637 "so a pattern cannot slip in"
3638 );
3639
3640 assert!(accepted("f_username", "ada"));
3641 }
3642
3643 #[test]
3644 fn a_filter_default_applies_only_until_the_operator_decides() {
3645 let mut c = config();
3646 c.filters = vec![ListFilter::boolean("is_superuser", "Superuser").default_value("1")];
3647 assert_eq!(resolve_filters(&c, &filter_params(&[])).len(), 1);
3649 assert!(resolve_filters(&c, &filter_params(&[("f_is_superuser", "")])).is_empty());
3651 let active = resolve_filters(&c, &filter_params(&[("f_is_superuser", "0")]));
3653 assert_eq!(active.len(), 1);
3654 }
3655
3656 #[test]
3657 fn a_gated_filter_is_narrowed_away_with_its_default() {
3658 let mut c = config();
3659 c.filters = vec![ListFilter::boolean("is_superuser", "Superuser")
3660 .default_value("1")
3661 .require("acme.secret")];
3662 let plain = laterite_auth::AuthenticatedUser {
3663 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
3664 ..crate::audit::test_actor()
3665 };
3666 assert!(allowed_filters(&c, &plain).is_empty());
3667 let narrowed = narrowed(&c, None, &plain);
3669 assert!(resolve_filters(&narrowed, &filter_params(&[])).is_empty());
3670 assert_eq!(allowed_filters(&c, &root()).len(), 1);
3671 }
3672
3673 #[test]
3674 fn a_stored_page_size_counts_only_while_it_is_offered() {
3675 let mut cfg = config();
3676 assert_eq!(offered_per_page(&cfg, Some(50)), 25, "nothing offered");
3677 cfg.per_page_options = vec![25, 50, 100];
3678 assert_eq!(offered_per_page(&cfg, Some(50)), 50);
3679 assert_eq!(offered_per_page(&cfg, Some(999)), 25, "no longer offered");
3680 assert_eq!(offered_per_page(&cfg, None), 25);
3681 }
3682
3683 fn params(sort: Option<&str>) -> ListParams {
3684 ListParams {
3685 page: None,
3686 sort: sort.map(|s| s.to_string()),
3687 dir: Some("asc".to_string()),
3688 q: None,
3689 parent: None,
3690 depth: None,
3691 }
3692 }
3693
3694 fn plain(config: &ListConfig) -> ListQuery<'_> {
3696 ListQuery {
3697 offset: 0,
3698 order_by: &config.order_by,
3699 order_dir: config.order_dir,
3700 q: "",
3701 filters: &[],
3702 per_page: None,
3703 children_of: None,
3704 }
3705 }
3706
3707 fn searched<'a>(config: &'a ListConfig, q: &'a str) -> ListQuery<'a> {
3708 ListQuery {
3709 offset: 0,
3710 order_by: &config.order_by,
3711 order_dir: config.order_dir,
3712 q,
3713 filters: &[],
3714 per_page: None,
3715 children_of: None,
3716 }
3717 }
3718
3719 fn amps(html: &str) -> String {
3722 html.replace("&", "&").replace("&", "&")
3723 }
3724
3725 fn filter_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
3727 pairs
3728 .iter()
3729 .map(|(k, v)| (k.to_string(), v.to_string()))
3730 .collect()
3731 }
3732
3733 #[tokio::test]
3734 async fn search_filters_rows_and_the_total() {
3735 let (db, _guard) = test_db().await;
3736 for name in ["ada", "brendan", "clara"] {
3737 let hash = laterite_auth::password::hash_password("x").unwrap();
3738 laterite_auth::store::create_user(
3739 &db,
3740 name,
3741 &format!("{name}@example.test"),
3742 name,
3743 None,
3744 &hash,
3745 false,
3746 )
3747 .await
3748 .unwrap();
3749 }
3750 let c = config();
3751
3752 let hit = query(&db, &c, &searched(&c, "bren")).await.unwrap();
3753 assert_eq!(hit.rows.len(), 1);
3754 assert_eq!(hit.rows[0].cells[0], "brendan");
3755 assert_eq!(hit.total, 1);
3757
3758 assert_eq!(
3760 query(&db, &c, &searched(&c, "BREN")).await.unwrap().total,
3761 1
3762 );
3763
3764 assert_eq!(query(&db, &c, &searched(&c, "")).await.unwrap().total, 3);
3766 assert_eq!(query(&db, &c, &searched(&c, " ")).await.unwrap().total, 3);
3767
3768 assert_eq!(query(&db, &c, &searched(&c, "%")).await.unwrap().total, 0);
3770 }
3771
3772 #[tokio::test]
3775 async fn search_skips_a_non_text_column() {
3776 let (db, _guard) = test_db().await;
3777 let hash = laterite_auth::password::hash_password("x").unwrap();
3778 laterite_auth::store::create_user(&db, "ada", "a@example.test", "Ada", None, &hash, true)
3779 .await
3780 .unwrap();
3781 let c = config();
3782 assert!(c.columns[0].is_searchable(), "username is text");
3783 assert!(!c.columns[1].is_searchable(), "is_superuser is a boolean");
3784 assert_eq!(query(&db, &c, &searched(&c, "1")).await.unwrap().total, 0);
3786 }
3787
3788 #[test]
3789 fn a_column_can_override_its_searchability() {
3790 assert!(ListColumn::new("code", "Code").is_searchable());
3791 assert!(!ListColumn::new("code", "Code")
3792 .searchable(false)
3793 .is_searchable());
3794 assert!(ListColumn::new("at", "At")
3795 .datetime()
3796 .searchable(true)
3797 .is_searchable());
3798 }
3799
3800 async fn seed_users(db: &Db) {
3801 for (name, su) in [("ada", true), ("brendan", false), ("clara", false)] {
3802 let hash = laterite_auth::password::hash_password("x").unwrap();
3803 laterite_auth::store::create_user(
3804 db,
3805 name,
3806 &format!("{name}@example.test"),
3807 name,
3808 None,
3809 &hash,
3810 su,
3811 )
3812 .await
3813 .unwrap();
3814 }
3815 }
3816
3817 async fn total_with(db: &Db, c: &ListConfig, params: &HashMap<String, String>) -> i64 {
3818 let active = resolve_filters(c, params);
3819 let req = ListQuery {
3820 offset: 0,
3821 order_by: &c.order_by,
3822 order_dir: c.order_dir,
3823 q: "",
3824 filters: &active,
3825 per_page: None,
3826 children_of: None,
3827 };
3828 query(db, c, &req).await.unwrap().total
3829 }
3830
3831 #[tokio::test]
3832 async fn a_filter_narrows_the_rows_and_the_total() {
3833 let (db, _guard) = test_db().await;
3834 seed_users(&db).await;
3835 let c = config();
3836
3837 assert_eq!(total_with(&db, &c, &filter_params(&[])).await, 3);
3838 assert_eq!(
3839 total_with(&db, &c, &filter_params(&[("f_is_superuser", "1")])).await,
3840 1
3841 );
3842 assert_eq!(
3843 total_with(&db, &c, &filter_params(&[("f_is_superuser", "0")])).await,
3844 2
3845 );
3846 }
3847
3848 #[test]
3851 fn an_undeclared_filter_or_value_is_ignored() {
3852 let c = config();
3853 assert!(resolve_filters(&c, &filter_params(&[("f_username", "ada")])).is_empty());
3855 for bad in ["yes", "true", "1 or 1=1", "", " "] {
3857 assert!(
3858 resolve_filters(&c, &filter_params(&[("f_is_superuser", bad)])).is_empty(),
3859 "{bad} must not reach the query"
3860 );
3861 }
3862
3863 let with_select = ListConfig {
3864 filters: vec![ListFilter::select(
3865 "status",
3866 "Status",
3867 vec![FilterOption::new("live", "Live")],
3868 )],
3869 ..config()
3870 };
3871 assert_eq!(
3872 resolve_filters(&with_select, &filter_params(&[("f_status", "live")])).len(),
3873 1
3874 );
3875 assert!(
3876 resolve_filters(&with_select, &filter_params(&[("f_status", "draft")])).is_empty(),
3877 "an option the descriptor never offered is dropped"
3878 );
3879 }
3880
3881 #[tokio::test]
3882 async fn a_filter_survives_a_sort_link_and_shows_as_selected() {
3883 let mut p = params(Some("username"));
3884 p.q = None;
3885 let html = get_with(
3886 p,
3887 filter_params(&[("f_is_superuser", "1")]),
3888 axum::http::HeaderMap::new(),
3889 )
3890 .await;
3891 let links = amps(&html);
3892 assert!(
3893 links.contains("?sort=is_superuser&dir=asc&f_is_superuser=1"),
3894 "a sort link carries the filter"
3895 );
3896 assert!(links.contains("?sort=username&dir=desc&f_is_superuser=1"));
3898 assert!(
3899 html.contains(r#"<option value="1" selected>"#),
3900 "the control shows the active value"
3901 );
3902 assert!(html.contains("Clear"), "and a way back to the full list");
3903 assert!(
3904 html.contains(r#"data-filtered="true""#),
3905 "the region says the list is narrowed, which is what reveals Clear"
3906 );
3907 }
3908
3909 #[tokio::test]
3912 async fn the_empty_state_distinguishes_no_records_from_no_match() {
3913 let (db, _guard) = test_db().await;
3914 let state = AdminState::new(
3915 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
3916 db,
3917 );
3918 let render = |raw: HashMap<String, String>, q: Option<&str>| {
3919 let q = q.map(String::from);
3920 let state = state.clone();
3921 async move {
3922 let p = ListParams {
3923 page: None,
3924 sort: None,
3925 dir: None,
3926 q,
3927 parent: None,
3928 depth: None,
3929 };
3930 let resp = handle(
3931 &state,
3932 &config(),
3933 "/admin/users",
3934 p,
3935 &raw,
3936 &crate::audit::test_actor(),
3937 crate::Shell::test(),
3938 &axum::http::HeaderMap::new(),
3939 )
3940 .await;
3941 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
3942 .await
3943 .unwrap();
3944 String::from_utf8(bytes.to_vec()).unwrap()
3945 }
3946 };
3947
3948 let bare = render(HashMap::new(), None).await;
3950 assert!(bare.contains("No records yet."));
3951
3952 let filtered = render(filter_params(&[("f_is_superuser", "1")]), None).await;
3954 assert!(filtered.contains("No records match."));
3955 assert!(!filtered.contains("No records yet."));
3956
3957 let searched = render(HashMap::new(), Some("zzz")).await;
3959 assert!(searched.contains("No records match."));
3960 }
3961
3962 fn root() -> laterite_auth::AuthenticatedUser {
3964 laterite_auth::AuthenticatedUser {
3965 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
3966 ..crate::audit::test_actor()
3967 }
3968 }
3969
3970 #[test]
3971 fn an_invisible_column_is_left_out_of_the_default_but_may_be_chosen() {
3972 let mut c = config();
3973 c.columns[1].invisible = true;
3974 assert_eq!(visible_columns(&c, None, &root()).len(), 1);
3975 assert_eq!(visible_columns(&c, Some("is_superuser"), &root()).len(), 1);
3976 assert_eq!(
3977 visible_columns(&c, Some("is_superuser"), &root())[0].field,
3978 "is_superuser"
3979 );
3980 }
3981
3982 #[test]
3983 fn a_gated_column_is_narrowed_away_for_an_operator_without_the_grant() {
3984 let mut c = config();
3985 c.columns[1].permission = Some("acme.secret".into());
3986 let plain = laterite_auth::AuthenticatedUser {
3987 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
3988 ..crate::audit::test_actor()
3989 };
3990 assert_eq!(visible_columns(&c, None, &plain).len(), 1);
3991 assert_eq!(visible_columns(&c, Some("is_superuser"), &plain).len(), 1);
3993 assert_eq!(visible_columns(&c, None, &root()).len(), 2);
3994 }
3995
3996 #[test]
3997 fn an_unsortable_column_is_not_sorted_by() {
3998 let mut c = config();
3999 c.columns[0].sortable = false;
4000 let (by, _) = resolve_sort(&c, Some("username"), Some("asc"));
4001 assert_eq!(by, c.order_by, "falls back to the descriptor's order");
4002 }
4003
4004 #[test]
4005 fn a_stored_choice_narrows_the_columns_in_descriptor_order() {
4006 let c = config();
4007 let kept = visible_columns(&c, Some("is_superuser,username"), &root());
4010 assert_eq!(
4011 kept.iter().map(|c| c.field.as_str()).collect::<Vec<_>>(),
4012 ["username", "is_superuser"]
4013 );
4014 }
4015
4016 #[test]
4017 fn no_stored_choice_shows_every_column() {
4018 let c = config();
4019 assert_eq!(visible_columns(&c, None, &root()).len(), c.columns.len());
4020 }
4021
4022 #[test]
4025 fn a_stale_choice_falls_back_to_every_column() {
4026 let c = config();
4027 assert_eq!(
4028 visible_columns(&c, Some("gone,removed"), &root()).len(),
4029 c.columns.len()
4030 );
4031 assert_eq!(
4032 visible_columns(&c, Some(""), &root()).len(),
4033 c.columns.len()
4034 );
4035 assert_eq!(
4036 visible_columns(&c, Some(" , "), &root()).len(),
4037 c.columns.len()
4038 );
4039 assert_eq!(visible_columns(&c, Some("gone,username"), &root()).len(), 1);
4041 }
4042
4043 #[test]
4046 fn the_toolbar_hides_what_the_operator_may_not_do() {
4047 let shell = crate::Shell::test();
4048 let cfg = ListConfig {
4049 creatable: false,
4050 toolbar: vec![
4051 ToolbarButton::new("Open", "/reports"),
4052 ToolbarButton::new("Audit", "/audit").require("backend.view_audit"),
4053 ],
4054 ..config()
4055 };
4056
4057 let root = laterite_auth::AuthenticatedUser {
4059 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
4060 ..crate::audit::test_actor()
4061 };
4062 let views = toolbar_views(&cfg, "/admin", &root, &shell);
4063 assert_eq!(
4064 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
4065 ["Open", "Audit"]
4066 );
4067
4068 let granted = laterite_auth::AuthenticatedUser {
4070 permissions: laterite_auth::PermissionSet::with_overrides(
4071 false,
4072 ["backend.view_audit".to_string()],
4073 [],
4074 [],
4075 ),
4076 ..crate::audit::test_actor()
4077 };
4078 assert_eq!(toolbar_views(&cfg, "/admin", &granted, &shell).len(), 2);
4079
4080 let plain = laterite_auth::AuthenticatedUser {
4082 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
4083 ..crate::audit::test_actor()
4084 };
4085 let views = toolbar_views(&cfg, "/admin", &plain, &shell);
4086 assert_eq!(
4087 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
4088 ["Open"]
4089 );
4090 }
4091
4092 #[test]
4095 fn a_toolbar_href_follows_the_admin_mount() {
4096 let shell = crate::Shell::test();
4097 let cfg = ListConfig {
4098 creatable: true,
4099 edit_base: Some("/backoffice/roles".to_string()),
4101 toolbar: vec![
4102 ToolbarButton::new("Reports", "/reports"),
4103 ToolbarButton::new("Docs", "https://example.test/docs"),
4104 ],
4105 ..config()
4106 };
4107 let root = laterite_auth::AuthenticatedUser {
4108 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
4109 ..crate::audit::test_actor()
4110 };
4111 let views = toolbar_views(&cfg, "/backoffice", &root, &shell);
4112 assert_eq!(views[0].href, "/backoffice/roles/new", "New comes first");
4113 assert_eq!(views[1].href, "/backoffice/reports");
4114 assert_eq!(views[2].href, "https://example.test/docs");
4116 }
4117
4118 #[tokio::test]
4119 async fn an_htmx_sort_returns_the_list_region_alone() {
4120 let mut headers = axum::http::HeaderMap::new();
4121 headers.insert("hx-request", "true".parse().unwrap());
4122 let html = get(params(Some("username")), headers).await;
4123 assert!(html.contains(r#"id="lat-list""#), "the region comes back");
4124 assert!(html.contains("aria-sort=\"ascending\""), "sorted ascending");
4125 assert!(!html.contains("<body"), "no page chrome");
4128 assert!(html.contains(r#"data-filtered="false""#));
4130 }
4131
4132 #[tokio::test]
4135 async fn the_search_term_survives_a_sort_link() {
4136 let mut p = params(Some("username"));
4137 p.q = Some("bren".to_string());
4138 let html = get(p, axum::http::HeaderMap::new()).await;
4139 assert!(
4140 amps(&html).contains("?sort=username&dir=desc&q=bren"),
4141 "sort and pager links carry the term"
4142 );
4143 assert!(
4144 html.contains(r#"value="bren""#),
4145 "and the box still shows it"
4146 );
4147 }
4148
4149 #[tokio::test]
4150 async fn a_plain_sort_returns_the_whole_page() {
4151 let html = get(params(Some("username")), axum::http::HeaderMap::new()).await;
4152 assert!(html.contains("<body"), "lists still work without htmx");
4153 assert!(html.contains(r#"id="lat-list""#));
4154 assert!(
4157 html.contains(r#"?sort=username"#) && html.contains(r#"href="/admin/"#),
4158 "{html}"
4159 );
4160 }
4161
4162 #[tokio::test]
4163 async fn query_rejects_bad_identifiers() {
4164 let (db, _guard) = test_db().await;
4165 let mut bad = config();
4166 bad.entity = "backend_users; drop table backend_users".to_string();
4167 assert!(query(&db, &bad, &plain(&bad)).await.is_err());
4168 }
4169}