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 pub icon: &'static str,
2342}
2343
2344#[derive(Clone, Debug)]
2346pub(crate) struct ReorderView {
2347 pub path: String,
2348 pub page: i64,
2349 pub parent: String,
2352}
2353
2354#[derive(Clone, Debug)]
2356pub(crate) struct TreeView {
2357 pub move_path: Option<String>,
2359}
2360
2361pub(crate) struct Crumb {
2363 pub label: String,
2364 pub href: String,
2365}
2366
2367async fn crumbs(
2370 db: &Db,
2371 config: &ListConfig,
2372 tree: &TreeConfig,
2373 parent: &str,
2374 path: &str,
2375) -> Vec<Crumb> {
2376 let mut trail = Vec::new();
2377 let mut current = Some(parent.to_string());
2378 for _ in 0..64 {
2380 let Some(id) = current.take() else { break };
2381 match parent_row(db, config, tree, &id).await {
2382 Ok(Some((label, above))) => {
2383 trail.push(Crumb {
2384 label,
2385 href: format!("{path}?parent={id}"),
2386 });
2387 current = above;
2388 }
2389 _ => break,
2390 }
2391 }
2392 trail.push(Crumb {
2393 label: String::new(),
2394 href: path.to_string(),
2395 });
2396 trail.reverse();
2397 trail
2398}
2399
2400async fn parent_row(
2403 db: &Db,
2404 config: &ListConfig,
2405 tree: &TreeConfig,
2406 id: &str,
2407) -> anyhow::Result<Option<(String, Option<String>)>> {
2408 let label_col = config
2409 .columns
2410 .first()
2411 .map(|c| c.field.as_str())
2412 .unwrap_or(&config.id_field);
2413 let (sql, values) = {
2414 let cast = Alias::new(text_cast(db.backend));
2415 build(
2416 db.backend,
2417 Query::select()
2418 .expr_as(
2419 Expr::col(Alias::new(label_col)).cast_as(cast.clone()),
2420 Alias::new("lat_label"),
2421 )
2422 .expr_as(
2423 Expr::col(Alias::new(&tree.parent_key)).cast_as(cast.clone()),
2424 Alias::new("lat_parent"),
2425 )
2426 .from(Alias::new(&config.entity))
2427 .and_where(
2428 Expr::col(Alias::new(&config.id_field))
2429 .cast_as(cast)
2430 .eq(id.to_string()),
2431 )
2432 .to_owned(),
2433 )
2434 };
2435 let row = bind_values(sqlx::query(&sql), values)
2436 .fetch_optional(&db.pool)
2437 .await?;
2438 Ok(row.map(|row| {
2439 let above = row.get_text("lat_parent").ok().filter(|p| !p.is_empty());
2440 (get_text(&row, "lat_label"), above)
2441 }))
2442}
2443
2444async fn ordered_keys(
2446 db: &Db,
2447 config: &ListConfig,
2448 column: &str,
2449) -> anyhow::Result<Vec<(String, i64)>> {
2450 let (sql, values) = {
2451 let cast = Alias::new(text_cast(db.backend));
2452 let mut select = Query::select();
2453 select
2454 .expr_as(
2455 Expr::col(Alias::new(&config.id_field)).cast_as(cast.clone()),
2456 Alias::new(ID_ALIAS),
2457 )
2458 .expr_as(
2459 Expr::col(Alias::new(column)).cast_as(cast),
2460 Alias::new("lat_key"),
2461 )
2462 .from(Alias::new(&config.entity))
2463 .order_by(Alias::new(column), Order::Asc)
2464 .order_by(Alias::new(&config.id_field), Order::Asc);
2465 if let Some(scope) = &config.scope {
2466 select.and_where(scope.condition(db.backend, &config.id_field));
2467 }
2468 build(db.backend, select)
2469 };
2470 let rows = bind_values(sqlx::query(&sql), values)
2471 .fetch_all(&db.pool)
2472 .await?;
2473 Ok(rows
2474 .iter()
2475 .map(|row| {
2476 let key = get_text(row, "lat_key").trim().parse().unwrap_or(0);
2477 (get_text(row, ID_ALIAS), key)
2478 })
2479 .collect())
2480}
2481
2482async fn write_keys(
2484 db: &Db,
2485 config: &ListConfig,
2486 column: &str,
2487 changes: &[(String, i64)],
2488) -> anyhow::Result<()> {
2489 let mut tx = db.pool.begin().await?;
2490 for (id, key) in changes {
2491 let (sql, values) = build(
2492 db.backend,
2493 Query::update()
2494 .table(Alias::new(&config.entity))
2495 .value(Alias::new(column), *key)
2496 .and_where(
2497 Expr::col(Alias::new(&config.id_field))
2498 .cast_as(Alias::new(text_cast(db.backend)))
2499 .eq(id.clone()),
2500 )
2501 .to_owned(),
2502 );
2503 bind_values(sqlx::query(&sql), values)
2504 .execute(&mut *tx)
2505 .await?;
2506 }
2507 tx.commit().await?;
2508 Ok(())
2509}
2510
2511enum Move {
2513 Up,
2514 Down,
2515 Before(String),
2516 After(String),
2517}
2518
2519fn placement(order: &[(String, i64)], id: &str, target: usize) -> Vec<(String, i64)> {
2522 let mut ids: Vec<&str> = order.iter().map(|(i, _)| i.as_str()).collect();
2523 let from = match ids.iter().position(|i| *i == id) {
2524 Some(from) => from,
2525 None => return Vec::new(),
2526 };
2527 ids.remove(from);
2528 let target = target.min(ids.len());
2529 ids.insert(target, id);
2530 let key_of = |i: &str| {
2531 order
2532 .iter()
2533 .find(|(o, _)| o == i)
2534 .map(|(_, k)| *k)
2535 .unwrap_or(0)
2536 };
2537 let prev = target.checked_sub(1).map(|i| key_of(ids[i]));
2538 let next = ids.get(target + 1).map(|i| key_of(i));
2539 let key = match (prev, next) {
2540 (Some(a), Some(b)) if b - a >= 2 => Some(a + (b - a) / 2),
2541 (Some(a), None) => Some(a + REORDER_STEP),
2542 (None, Some(b)) => Some(b - REORDER_STEP),
2543 (None, None) => Some(key_of(id)),
2544 _ => None,
2545 };
2546 match key {
2547 Some(key) => vec![(id.to_string(), key)],
2548 None => ids
2549 .iter()
2550 .enumerate()
2551 .map(|(i, row)| (row.to_string(), (i as i64 + 1) * REORDER_STEP))
2552 .filter(|(row, key)| key_of(row) != *key)
2553 .collect(),
2554 }
2555}
2556
2557#[allow(clippy::too_many_arguments)]
2561pub(crate) async fn reorder_handler(
2562 state: &AdminState,
2563 config: &ListConfig,
2564 path: &str,
2565 region: &str,
2566 row_action: Option<RowAction>,
2567 user: &laterite_auth::AuthenticatedUser,
2568 session: &crate::session::SessionHandle,
2569 shell: crate::Shell,
2570 headers: &axum::http::HeaderMap,
2571 pairs: &[(String, String)],
2572) -> Response {
2573 let Some(reorder) = &config.reorder else {
2574 return crate::not_found();
2575 };
2576 let field = |name: &str| {
2577 pairs
2578 .iter()
2579 .find(|(k, _)| k == name)
2580 .map(|(_, v)| v.trim().to_string())
2581 .filter(|v| !v.is_empty())
2582 };
2583 let page = field("page")
2584 .and_then(|p| p.parse().ok())
2585 .unwrap_or(1)
2586 .max(1);
2587 let parent = field("parent");
2589 let scoped;
2590 let config = match &config.tree {
2591 Some(tree) => {
2592 let mut c = config.clone();
2593 c.scope = Some(Scope::Column {
2594 column: tree.parent_key.clone(),
2595 value: parent.clone(),
2596 });
2597 scoped = c;
2598 &scoped
2599 }
2600 None => config,
2601 };
2602 let fatal = 'apply: {
2603 let (Some(id), Some(mv)) = (
2604 field("id"),
2605 match (field("move").as_deref(), field("before"), field("after")) {
2606 (Some("up"), _, _) => Some(Move::Up),
2607 (Some("down"), _, _) => Some(Move::Down),
2608 (_, Some(before), _) => Some(Move::Before(before)),
2609 (_, _, Some(after)) => Some(Move::After(after)),
2610 _ => None,
2611 },
2612 ) else {
2613 break 'apply false;
2614 };
2615 let order = match ordered_keys(&state.db, config, &reorder.column).await {
2616 Ok(order) => order,
2617 Err(e) => {
2618 tracing::error!(entity = %config.entity, error = %e, "reorder read failed");
2619 break 'apply true;
2620 }
2621 };
2622 let position = |needle: &str| order.iter().position(|(i, _)| i == needle);
2623 let Some(from) = position(&id) else {
2624 session.push_flash(
2625 crate::session::FlashLevel::Error,
2626 laterite_core::t!("That record is no longer in the list."),
2627 );
2628 break 'apply false;
2629 };
2630 let target = match mv {
2631 Move::Up => from.saturating_sub(1),
2632 Move::Down => from + 1,
2633 Move::Before(other) => match position(&other) {
2634 Some(p) if p > from => p - 1,
2635 Some(p) => p,
2636 None => from,
2637 },
2638 Move::After(other) => match position(&other) {
2639 Some(p) if p > from => p,
2640 Some(p) => p + 1,
2641 None => from,
2642 },
2643 };
2644 let changes = placement(&order, &id, target);
2645 if changes.is_empty() {
2646 break 'apply false;
2647 }
2648 if let Err(e) = write_keys(&state.db, config, &reorder.column, &changes).await {
2649 tracing::error!(entity = %config.entity, error = %e, "reorder write failed");
2650 break 'apply true;
2651 }
2652 crate::audit::record(
2653 state,
2654 user,
2655 &format!("{}.reorder", config.entity),
2656 Some(&config.entity),
2657 Some(&id),
2658 None,
2659 Some(&format!("position {}", target.min(order.len() - 1) + 1)),
2660 )
2661 .await;
2662 false
2663 };
2664 if fatal {
2665 return render_error();
2666 }
2667 if crate::form::is_htmx(headers) {
2668 let params = ListParams {
2669 page: Some(page),
2670 parent: parent.clone(),
2671 depth: field("depth").and_then(|d| d.parse().ok()),
2672 ..Default::default()
2673 };
2674 let Some(view) =
2675 build_page(state, config, path, params, &HashMap::new(), user, shell).await
2676 else {
2677 return render_error();
2678 };
2679 return if parent.is_some() && view.tree.is_some() {
2680 render(rows_of(view, region, row_action))
2681 } else {
2682 render(fragment_of(view, region, row_action))
2683 };
2684 }
2685 let back = match parent {
2686 Some(parent) => format!("{path}?parent={parent}&page={page}"),
2687 None => format!("{path}?page={page}"),
2688 };
2689 axum::response::Redirect::to(&back).into_response()
2690}
2691
2692pub(crate) async fn move_form(
2694 state: &AdminState,
2695 config: &ListConfig,
2696 form: &crate::form::PreparedForm,
2697 path: &str,
2698 id: &str,
2699 shell: crate::Shell,
2700) -> Response {
2701 let Some(tree) = &config.tree else {
2702 return crate::not_found();
2703 };
2704 let current = match parent_row(&state.db, config, tree, id).await {
2705 Ok(Some((_, parent))) => parent.unwrap_or_default(),
2706 Ok(None) => return crate::not_found(),
2707 Err(_) => return render_error(),
2708 };
2709 let mut values = HashMap::new();
2710 values.insert(tree.parent_key.clone(), current);
2711 let action = format!("{path}/{id}/move");
2712 crate::form::page(state, form, &action, &values, &shell)
2713}
2714
2715#[allow(clippy::too_many_arguments)]
2718pub(crate) async fn move_record(
2719 state: &AdminState,
2720 config: &ListConfig,
2721 form: &crate::form::PreparedForm,
2722 path: &str,
2723 id: &str,
2724 user: &laterite_auth::AuthenticatedUser,
2725 session: &crate::session::SessionHandle,
2726 shell: crate::Shell,
2727 headers: &axum::http::HeaderMap,
2728 data: HashMap<String, String>,
2729) -> Response {
2730 let Some(tree) = &config.tree else {
2731 return crate::not_found();
2732 };
2733 match parent_row(&state.db, config, tree, id).await {
2734 Ok(Some(_)) => {}
2735 Ok(None) => return crate::not_found(),
2736 Err(_) => return render_error(),
2737 }
2738 let action = format!("{path}/{id}/move");
2739 let chosen = data
2740 .get(&tree.parent_key)
2741 .map(|v| v.trim().to_string())
2742 .filter(|v| !v.is_empty());
2743 let mut bag = laterite_core::validation::ErrorBag::default();
2744 if let Some(parent) = &chosen {
2745 let mut current = Some(parent.clone());
2748 let mut steps = 0;
2749 while let Some(at) = current.take() {
2750 if at == id {
2751 bag.add(
2752 &tree.parent_key,
2753 laterite_core::t!("A record cannot be moved under itself."),
2754 );
2755 break;
2756 }
2757 match parent_row(&state.db, config, tree, &at).await {
2758 Ok(Some((_, above))) => current = above,
2759 Ok(None) => {
2760 bag.add(
2761 &tree.parent_key,
2762 laterite_core::t!("There is no such record."),
2763 );
2764 break;
2765 }
2766 Err(_) => return render_error(),
2767 }
2768 steps += 1;
2769 if steps > 64 {
2770 break;
2771 }
2772 }
2773 }
2774 let htmx = crate::form::is_htmx(headers);
2775 if !bag.is_empty() {
2776 return crate::form::refused(htmx, state, form, &action, None, &data, &bag, &shell);
2777 }
2778 let actor = laterite_core::Actor::from(user);
2779 let outcome = async {
2780 let mut tx = state
2781 .db
2782 .pool
2783 .begin()
2784 .await
2785 .map_err(|e| MoveFailure::Failed(e.to_string()))?;
2786 let mut cx = laterite_core::SaveCx::new(state.db.backend, &mut tx, actor);
2787 let request = crate::persist::MoveTo {
2788 entity: &config.entity,
2789 id_field: &config.id_field,
2790 id,
2791 parent_key: &tree.parent_key,
2792 parent: chosen.as_deref(),
2793 };
2794 form.persister()
2795 .move_to(&mut cx, &request)
2796 .await
2797 .map_err(|e| match e {
2798 crate::persist::SaveError::Invalid(bag) => MoveFailure::Refused(bag),
2799 crate::persist::SaveError::Failed(m) => MoveFailure::Failed(m),
2800 })?;
2801 tx.commit()
2802 .await
2803 .map_err(|e| MoveFailure::Failed(e.to_string()))
2804 }
2805 .await;
2806 match outcome {
2807 Ok(()) => {
2808 crate::audit::record(
2809 state,
2810 user,
2811 &format!("{}.move", config.entity),
2812 Some(&config.entity),
2813 Some(id),
2814 None,
2815 Some(&match &chosen {
2816 Some(parent) => format!("under {parent}"),
2817 None => "top level".to_string(),
2818 }),
2819 )
2820 .await;
2821 session.push_flash(
2822 crate::session::FlashLevel::Success,
2823 laterite_core::t!("Moved."),
2824 );
2825 let back = match chosen {
2826 Some(parent) => format!("{path}?parent={parent}"),
2827 None => path.to_string(),
2828 };
2829 crate::form::saved_response(htmx, &back)
2830 }
2831 Err(MoveFailure::Refused(bag)) => {
2832 crate::form::refused(htmx, state, form, &action, None, &data, &bag, &shell)
2833 }
2834 Err(MoveFailure::Failed(m)) => {
2835 tracing::error!(entity = %config.entity, id, error = %m, "move failed");
2836 crate::form::refused(
2837 htmx,
2838 state,
2839 form,
2840 &action,
2841 Some(laterite_core::t!("Could not move. Try again.")),
2842 &data,
2843 &bag,
2844 &shell,
2845 )
2846 }
2847 }
2848}
2849
2850enum MoveFailure {
2851 Refused(laterite_core::validation::ErrorBag),
2852 Failed(String),
2853}
2854
2855#[allow(clippy::too_many_arguments)]
2859fn cell_markup(
2860 state: &AdminState,
2861 form: &crate::form::PreparedForm,
2862 path: &str,
2863 id: &str,
2864 field: &str,
2865 value: &str,
2866 shell: &crate::Shell,
2867 error: Option<&str>,
2868) -> String {
2869 let dom_id = format!("cell-{id}-{field}");
2870 let control = form
2871 .control(state, field, &dom_id, value, shell)
2872 .unwrap_or_default();
2873 let action = format!("{path}/{id}/cell");
2874 let mut html = format!(
2875 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>"#,
2876 if error.is_some() { " is-invalid" } else { "" },
2877 crate::form::escape_attr(&shell.csrf_token),
2878 crate::form::escape_attr(field),
2879 shell.tt(&laterite_core::t!("Save")),
2880 );
2881 if let Some(message) = error {
2882 html.push_str(&format!(
2883 r#"<span class="lat-cell__error">{}</span>"#,
2884 crate::form::escape_attr(message)
2885 ));
2886 }
2887 html.push_str("</form>");
2888 html
2889}
2890
2891#[allow(clippy::too_many_arguments)]
2896pub(crate) async fn cell_handler(
2897 state: &AdminState,
2898 config: &ListConfig,
2899 path: &str,
2900 id: &str,
2901 user: &laterite_auth::AuthenticatedUser,
2902 session: &crate::session::SessionHandle,
2903 shell: crate::Shell,
2904 headers: &axum::http::HeaderMap,
2905 mut data: HashMap<String, String>,
2906) -> Response {
2907 let Some(editor) = &config.editor else {
2908 return crate::not_found();
2909 };
2910 let form = &editor.0;
2911 let Some(field) = data.remove("field") else {
2912 return crate::not_found();
2913 };
2914 data.remove("_csrf");
2915 if !config
2916 .columns
2917 .iter()
2918 .any(|c| c.editable && c.field == field)
2919 || !form.has_field(&field)
2920 {
2921 return crate::not_found();
2922 }
2923 let htmx = crate::form::is_htmx(headers);
2924 let back = path.to_string();
2925 let current = match crate::form::load_values(&state.db, form, id).await {
2926 Ok(Some(values)) => values,
2927 Ok(None) => return crate::not_found(),
2928 Err(_) => return render_error(),
2929 };
2930 let submitted = data.get(&field).cloned().unwrap_or_default();
2934 let mut merged = current.clone();
2935 merged.remove(&field);
2936 for (key, value) in data {
2937 merged.insert(key, value);
2938 }
2939 let merged = crate::form::gathered(form, merged);
2940 let actor = laterite_core::Actor::from(user);
2941 match crate::form::write(&state.db, form, &merged, Some(id), &actor).await {
2942 Ok(_) => {
2943 if htmx {
2944 let stored = crate::form::load_values(&state.db, form, id)
2945 .await
2946 .ok()
2947 .flatten()
2948 .and_then(|values| values.get(&field).cloned())
2949 .unwrap_or(submitted);
2950 let html = cell_markup(state, form, path, id, &field, &stored, &shell, None);
2951 return axum::response::Html(html).into_response();
2952 }
2953 session.push_flash(
2954 crate::session::FlashLevel::Success,
2955 laterite_core::t!("Saved."),
2956 );
2957 crate::form::saved_response(false, &back)
2958 }
2959 Err(crate::persist::SaveError::Invalid(bag)) => {
2960 let message = bag
2961 .messages(&field)
2962 .first()
2963 .map(|m| shell.tt(m))
2964 .or_else(|| {
2965 bag.iter()
2966 .next()
2967 .and_then(|(_, m)| m.first())
2968 .map(|m| shell.tt(m))
2969 })
2970 .unwrap_or_else(|| shell.tt(&laterite_core::t!("Could not save.")));
2971 if htmx {
2972 let html = cell_markup(
2973 state,
2974 form,
2975 path,
2976 id,
2977 &field,
2978 &submitted,
2979 &shell,
2980 Some(&message),
2981 );
2982 return (
2983 axum::http::StatusCode::UNPROCESSABLE_ENTITY,
2984 axum::response::Html(html),
2985 )
2986 .into_response();
2987 }
2988 session.push_flash(crate::session::FlashLevel::Error, Text::dynamic(message));
2989 crate::form::saved_response(false, &back)
2990 }
2991 Err(crate::persist::SaveError::Failed(m)) => {
2992 tracing::error!(entity = %config.entity, id, field, error = %m, "cell save failed");
2993 render_error()
2994 }
2995 }
2996}
2997
2998fn fragment_of(page: ListTemplate, region: &str, delete_path: Option<RowAction>) -> ListFragment {
3000 ListFragment {
3001 shell: page.shell,
3002 columns: page.columns,
3003 rows: page.rows,
3004 shown: page.shown,
3005 page: page.page,
3006 total: page.total,
3007 total_pages: page.total_pages,
3008 edit_base: page.edit_base,
3009 row_link: page.row_link,
3010 sort: page.sort,
3011 dir: page.dir,
3012 carry: page.carry,
3013 filtered: page.filtered,
3014 deletable: page.deletable,
3015 no_records_message: page.no_records_message,
3016 path: page.path,
3017 region: region.to_string(),
3018 delete_path,
3019 reorder: page.reorder,
3020 tree: page.tree,
3021 }
3022}
3023
3024fn rows_of(page: ListTemplate, region: &str, delete_path: Option<RowAction>) -> RowsFragment {
3026 RowsFragment {
3027 shell: page.shell,
3028 rows: page.rows,
3029 page: page.page,
3030 total_pages: page.total_pages,
3031 edit_base: page.edit_base,
3032 row_link: page.row_link,
3033 deletable: page.deletable,
3034 region: region.to_string(),
3035 delete_path,
3036 reorder: page.reorder,
3037 tree: page.tree,
3038 }
3039}
3040
3041#[allow(clippy::too_many_arguments)]
3045pub(crate) async fn fragment(
3046 state: &AdminState,
3047 config: &ListConfig,
3048 path: &str,
3049 params: ListParams,
3050 raw: &HashMap<String, String>,
3051 user: &laterite_auth::AuthenticatedUser,
3052 shell: crate::Shell,
3053 region: &str,
3054 delete_path: Option<RowAction>,
3055) -> Result<crate::html::Markup, ()> {
3056 let page = build_page(state, config, path, params, raw, user, shell)
3057 .await
3058 .ok_or(())?;
3059 crate::html::Markup::from_template(&fragment_of(page, region, delete_path)).map_err(|_| ())
3060}
3061
3062pub(crate) async fn set_columns(
3069 state: &AdminState,
3070 config: &ListConfig,
3071 path: &str,
3072 user: &laterite_auth::AuthenticatedUser,
3073 session: &crate::session::SessionHandle,
3074 headers: &axum::http::HeaderMap,
3075 pairs: &[(String, String)],
3076) -> Response {
3077 let allowed = allowed_columns(config, user);
3078 let chosen: Vec<&str> = pairs
3079 .iter()
3080 .filter(|(k, _)| k == "column")
3081 .map(|(_, v)| v.trim())
3082 .filter(|v| allowed.iter().any(|c| c.field == *v))
3083 .collect();
3084 let mut by_default: Vec<&str> = allowed
3088 .iter()
3089 .filter(|c| !c.invisible)
3090 .map(|c| c.field.as_str())
3091 .collect();
3092 let mut as_chosen = chosen.clone();
3093 by_default.sort_unstable();
3094 as_chosen.sort_unstable();
3095 as_chosen.dedup();
3096
3097 let size_key = per_page_preference_key(path);
3100 let size = pairs
3101 .iter()
3102 .find(|(k, _)| k == "per_page")
3103 .and_then(|(_, v)| v.trim().parse::<i64>().ok())
3104 .filter(|n| config.per_page_options.contains(n));
3105 let size_result = match size {
3106 Some(n) if n != config.per_page => {
3107 laterite_auth::store::set_user_preference(
3108 &state.db,
3109 user.user.id,
3110 &size_key,
3111 &n.to_string(),
3112 )
3113 .await
3114 }
3115 _ => laterite_auth::store::clear_user_preference(&state.db, user.user.id, &size_key).await,
3116 };
3117
3118 let key = columns_preference_key(path);
3119 let result = if chosen.is_empty() || as_chosen == by_default {
3120 laterite_auth::store::clear_user_preference(&state.db, user.user.id, &key).await
3121 } else {
3122 laterite_auth::store::set_user_preference(&state.db, user.user.id, &key, &chosen.join(","))
3123 .await
3124 };
3125 match result.and(size_result) {
3126 Ok(()) => session.push_flash(
3127 crate::session::FlashLevel::Success,
3128 laterite_core::t!("List setup saved."),
3129 ),
3130 Err(e) => {
3131 tracing::error!(error = %e, "storing the list setup failed");
3132 session.push_flash(
3133 crate::session::FlashLevel::Error,
3134 laterite_core::t!("Could not save your list setup."),
3135 );
3136 }
3137 }
3138 let back = format!("{}{}", state.admin_path, path);
3139 crate::form::saved_response(crate::form::is_htmx(headers), &back)
3140}
3141
3142pub struct ColumnChoice {
3144 pub field: String,
3145 pub label: String,
3146 pub shown: bool,
3147}
3148
3149pub struct ColumnHead {
3152 pub label: String,
3153 pub field: String,
3154 pub next_dir: String,
3155 pub active: String,
3156 pub sortable: bool,
3157 pub width: Option<String>,
3158 pub align_class: String,
3159}
3160
3161#[derive(Template)]
3162#[template(path = "list.html")]
3163struct ListTemplate {
3164 shell: crate::Shell,
3165 title: String,
3166 columns: Vec<ColumnHead>,
3167 rows: Vec<RenderedRow>,
3168 shown: i64,
3170 page: i64,
3171 total: i64,
3172 total_pages: i64,
3173 edit_base: Option<String>,
3174 row_link: bool,
3176 sort: String,
3178 dir: String,
3179 q: String,
3181 searchable: bool,
3183 search_prompt: Option<String>,
3185 search_on_enter: bool,
3186 no_records_message: Option<String>,
3188 per_page: i64,
3191 per_page_options: Vec<i64>,
3192 deletable: bool,
3194 exportable: bool,
3197 import_href: Option<String>,
3200 pickers: Vec<ColumnChoice>,
3202 toolbar: Vec<ToolbarView>,
3204 export_query: String,
3207 path: String,
3209 filters: Vec<FilterView>,
3211 filtered: bool,
3213 carry: String,
3215 region: String,
3217 delete_path: Option<RowAction>,
3219 reorder: Option<ReorderView>,
3221 tree: Option<TreeView>,
3223 crumbs: Vec<Crumb>,
3225}
3226
3227pub struct FilterView {
3229 pub param: String,
3230 pub label: String,
3231 pub input_type: String,
3233 pub options: Vec<FilterOptionView>,
3234 pub value: String,
3236}
3237
3238pub struct FilterOptionView {
3239 pub value: String,
3240 pub label: String,
3241}
3242
3243fn filter_views(
3245 config: &ListConfig,
3246 active: &[ActiveFilter<'_>],
3247 shell: &crate::Shell,
3248) -> Vec<FilterView> {
3249 config
3250 .filters
3251 .iter()
3252 .map(|f| {
3253 let value = active
3254 .iter()
3255 .find(|a| a.filter.field == f.field)
3256 .map(|a| a.raw.clone())
3257 .unwrap_or_default();
3258 let options: Vec<FilterOptionView> = match f.filter_type.as_str() {
3261 "boolean" => vec![
3262 FilterOptionView {
3263 value: "1".to_string(),
3264 label: shell.tt(&laterite_core::t!("Yes")),
3265 },
3266 FilterOptionView {
3267 value: "0".to_string(),
3268 label: shell.tt(&laterite_core::t!("No")),
3269 },
3270 ],
3271 "select" => f
3272 .choices()
3273 .iter()
3274 .map(|o| FilterOptionView {
3275 value: o.value.clone(),
3276 label: shell.tt(&o.label),
3277 })
3278 .collect(),
3279 _ => Vec::new(),
3280 };
3281 let input_type = match f.filter_type.as_str() {
3282 "number" => "number",
3283 "date" => "date",
3284 _ => "text",
3285 };
3286 FilterView {
3287 param: f.param(),
3288 label: shell.tt(&f.label),
3289 input_type: input_type.to_string(),
3290 options,
3291 value,
3292 }
3293 })
3294 .collect()
3295}
3296
3297fn urlencode(value: &str) -> String {
3299 let mut out = String::with_capacity(value.len());
3300 for byte in value.as_bytes() {
3301 match byte {
3302 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
3303 out.push(*byte as char)
3304 }
3305 b' ' => out.push('+'),
3306 _ => out.push_str(&format!("%{byte:02X}")),
3307 }
3308 }
3309 out
3310}
3311
3312#[derive(Template)]
3314#[template(path = "_list_table.html")]
3315struct ListFragment {
3316 shell: crate::Shell,
3317 columns: Vec<ColumnHead>,
3318 rows: Vec<RenderedRow>,
3319 shown: i64,
3320 page: i64,
3321 total: i64,
3322 total_pages: i64,
3323 edit_base: Option<String>,
3324 row_link: bool,
3326 sort: String,
3327 dir: String,
3328 carry: String,
3329 filtered: bool,
3333 deletable: bool,
3334 no_records_message: Option<String>,
3335 path: String,
3338 region: String,
3341 delete_path: Option<RowAction>,
3344 reorder: Option<ReorderView>,
3346 tree: Option<TreeView>,
3347}
3348
3349#[derive(Template)]
3351#[template(path = "_list_rows.html")]
3352struct RowsFragment {
3353 shell: crate::Shell,
3354 rows: Vec<RenderedRow>,
3355 page: i64,
3356 total_pages: i64,
3357 edit_base: Option<String>,
3358 row_link: bool,
3359 deletable: bool,
3360 region: String,
3361 delete_path: Option<RowAction>,
3362 reorder: Option<ReorderView>,
3363 tree: Option<TreeView>,
3364}
3365
3366#[cfg(test)]
3367mod tests {
3368 use super::*;
3369
3370 fn config() -> ListConfig {
3371 ListConfig {
3372 entity: "backend_users".to_string(),
3373 title: "Users".into(),
3374 columns: vec![
3375 ListColumn::new("username", "Username"),
3376 ListColumn::new("is_superuser", "Superuser").yes_no(),
3377 ],
3378 order_by: "created_at".to_string(),
3379 order_dir: SortDir::Desc,
3380 per_page: 25,
3381 id_field: "id".to_string(),
3382 edit_base: None,
3383 creatable: false,
3384 filters: vec![ListFilter::boolean("is_superuser", "Superuser")],
3385 deletable: true,
3386 ..Default::default()
3387 }
3388 }
3389
3390 #[test]
3391 fn column_types_format_their_cell_display() {
3392 let ist: Tz = "Asia/Kolkata".parse().unwrap();
3393 let display = |ct: &dyn ColumnType, value: &str, tz: Tz| {
3394 ct.view_model(&CellCx {
3395 value,
3396 tz,
3397 locale: chrono::Locale::en_US,
3398 })
3399 .display
3400 };
3401 assert_eq!(
3403 display(&DateTimeColumn, "2026-08-13T10:00:00+00:00", ist),
3404 "13 Aug 2026, 15:30"
3405 );
3406 assert_eq!(
3407 display(&DateColumn, "2026-08-13T10:00:00+00:00", Tz::UTC),
3408 "13 Aug 2026"
3409 );
3410 assert_eq!(
3411 display(&TimeColumn, "2026-08-13T10:00:00+00:00", ist),
3412 "15:30"
3413 );
3414 assert_eq!(display(&BoolColumn, "true", Tz::UTC), "Yes");
3415 assert_eq!(display(&BoolColumn, "false", Tz::UTC), "No");
3416 assert_eq!(display(&TextColumn, "root", Tz::UTC), "root");
3417 assert_eq!(display(&DateTimeColumn, "n/a", Tz::UTC), "n/a");
3419 }
3420
3421 #[test]
3422 fn sort_falls_back_to_the_configured_order() {
3423 let c = config();
3424 assert_eq!(
3425 resolve_sort(&c, None, None),
3426 ("created_at".to_string(), SortDir::Desc)
3427 );
3428 }
3429
3430 #[test]
3431 fn a_declared_column_can_be_sorted_either_way() {
3432 let c = config();
3433 assert_eq!(
3434 resolve_sort(&c, Some("username"), Some("asc")),
3435 ("username".to_string(), SortDir::Asc)
3436 );
3437 assert_eq!(
3438 resolve_sort(&c, Some("username"), Some("desc")),
3439 ("username".to_string(), SortDir::Desc)
3440 );
3441 }
3442
3443 #[test]
3446 fn an_undeclared_sort_column_is_ignored() {
3447 let c = config();
3448 for crafted in [
3449 "password_hash",
3450 "id) --",
3451 "username; drop table backend_users",
3452 "",
3453 ] {
3454 assert_eq!(
3455 resolve_sort(&c, Some(crafted), Some("asc")).0,
3456 "created_at",
3457 "{crafted} must not reach the query"
3458 );
3459 }
3460 }
3461
3462 #[test]
3463 fn an_unknown_direction_falls_back() {
3464 let c = config();
3465 assert_eq!(
3467 resolve_sort(&c, Some("username"), Some("sideways")).1,
3468 SortDir::Asc
3469 );
3470 assert_eq!(resolve_sort(&c, Some("created_at"), None).1, SortDir::Desc);
3472 }
3473
3474 #[test]
3477 fn a_contributed_column_type_renders_its_cell() {
3478 struct Rating;
3479 impl ColumnType for Rating {
3480 fn view_key(&self) -> &'static str {
3481 "acme.rating"
3482 }
3483 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
3484 let stars = cx.value.parse::<usize>().unwrap_or(0);
3485 text_cell("acme.rating", cx.value, "*".repeat(stars))
3486 }
3487 fn render_default(&self, vm: &CellVm) -> Markup {
3488 render_text(vm)
3489 }
3490 }
3491
3492 let reg = ColumnTypeReg::new(Arc::new(Rating));
3493 assert_eq!(reg.name(), "acme.rating", "the type names itself");
3494
3495 let mut registry = builtin_column_registry();
3496 registry.insert(reg.name().to_string(), reg.column_type);
3497 let rendered = render_cell(
3498 registry.get("acme.rating").unwrap().as_ref(),
3499 &crate::field::NoOverrides,
3500 &OverrideScope {
3501 surface: Surface::Column,
3502 view_key: "acme.rating",
3503 resource: Some("products"),
3504 field: Some("stars"),
3505 },
3506 &CellCx {
3507 value: "3",
3508 tz: Tz::UTC,
3509 locale: chrono::Locale::en_US,
3510 },
3511 None,
3512 );
3513 assert!(rendered.into_string().contains("***"));
3514 }
3515
3516 #[test]
3519 fn a_contributed_key_is_the_types_own_and_collides_visibly() {
3520 struct Impostor;
3521 impl ColumnType for Impostor {
3522 fn view_key(&self) -> &'static str {
3523 "text"
3524 }
3525 fn view_model(&self, cx: &CellCx<'_>) -> CellVm {
3526 text_cell("text", cx.value, String::new())
3527 }
3528 fn render_default(&self, vm: &CellVm) -> Markup {
3529 render_text(vm)
3530 }
3531 }
3532 let reg = ColumnTypeReg::new(Arc::new(Impostor));
3533 assert_eq!(reg.name(), "text");
3534 assert!(builtin_column_registry().contains_key(reg.name()));
3536 }
3537
3538 #[test]
3539 fn status_pill_renders_a_slugged_markup_span() {
3540 let vm = StatusPillColumn.view_model(&CellCx {
3541 value: "In Progress",
3542 tz: Tz::UTC,
3543 locale: chrono::Locale::en_US,
3544 });
3545 let html = StatusPillColumn.render_default(&vm).into_string();
3546 assert!(html.contains(r#"class="lat-status lat-status--in-progress""#));
3547 assert!(html.contains(">In Progress<"));
3548 }
3549
3550 async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
3553 laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
3554 }
3555
3556 #[tokio::test]
3557 async fn query_returns_display_rows() {
3558 let (db, _guard) = test_db().await;
3559 let hash = laterite_auth::password::hash_password("pw").unwrap();
3560 laterite_auth::store::create_user(
3561 &db,
3562 "root",
3563 "root@example.test",
3564 "Ada",
3565 None,
3566 &hash,
3567 true,
3568 )
3569 .await
3570 .unwrap();
3571
3572 let c = config();
3573 let result = query(&db, &c, &plain(&c)).await.unwrap();
3574 assert_eq!(result.total, 1);
3575 assert_eq!(result.rows.len(), 1);
3576 assert_eq!(result.rows[0].cells[0], "root");
3577 assert_eq!(result.rows[0].cells[1], "1");
3580 assert!(!result.rows[0].id.is_empty());
3581 }
3582
3583 async fn get(params: ListParams, headers: axum::http::HeaderMap) -> String {
3584 get_with(params, HashMap::new(), headers).await
3585 }
3586
3587 async fn get_with(
3588 params: ListParams,
3589 raw: HashMap<String, String>,
3590 headers: axum::http::HeaderMap,
3591 ) -> String {
3592 let (db, _guard) = test_db().await;
3593 let state = AdminState::new(
3594 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
3595 db,
3596 );
3597 let resp = handle(
3598 &state,
3599 &config(),
3600 "/admin/users",
3601 params,
3602 &raw,
3603 &crate::audit::test_actor(),
3604 crate::Shell::test(),
3605 &headers,
3606 )
3607 .await;
3608 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
3609 .await
3610 .unwrap();
3611 String::from_utf8(bytes.to_vec()).unwrap()
3612 }
3613
3614 #[test]
3615 fn a_typed_filter_accepts_only_its_own_shape() {
3616 let mut c = config();
3617 c.filters = vec![
3618 ListFilter::number("views", "Views"),
3619 ListFilter::date("created_at", "Created"),
3620 ListFilter::text("username", "Username"),
3621 ];
3622 let accepted = |param: &str, value: &str| {
3623 !resolve_filters(&c, &filter_params(&[(param, value)])).is_empty()
3624 };
3625 assert!(accepted("f_views", "42"));
3626 assert!(
3627 !accepted("f_views", "4.2"),
3628 "a number filter takes whole numbers"
3629 );
3630 assert!(!accepted("f_views", "; drop table"), "and nothing else");
3631
3632 assert!(accepted("f_created_at", "2026-09-21"));
3633 assert!(
3634 !accepted("f_created_at", "2026-9-21"),
3635 "a date is ISO or nothing"
3636 );
3637 assert!(
3638 !accepted("f_created_at", "2026-09-21%"),
3639 "so a pattern cannot slip in"
3640 );
3641
3642 assert!(accepted("f_username", "ada"));
3643 }
3644
3645 #[test]
3646 fn a_filter_default_applies_only_until_the_operator_decides() {
3647 let mut c = config();
3648 c.filters = vec![ListFilter::boolean("is_superuser", "Superuser").default_value("1")];
3649 assert_eq!(resolve_filters(&c, &filter_params(&[])).len(), 1);
3651 assert!(resolve_filters(&c, &filter_params(&[("f_is_superuser", "")])).is_empty());
3653 let active = resolve_filters(&c, &filter_params(&[("f_is_superuser", "0")]));
3655 assert_eq!(active.len(), 1);
3656 }
3657
3658 #[test]
3659 fn a_gated_filter_is_narrowed_away_with_its_default() {
3660 let mut c = config();
3661 c.filters = vec![ListFilter::boolean("is_superuser", "Superuser")
3662 .default_value("1")
3663 .require("acme.secret")];
3664 let plain = laterite_auth::AuthenticatedUser {
3665 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
3666 ..crate::audit::test_actor()
3667 };
3668 assert!(allowed_filters(&c, &plain).is_empty());
3669 let narrowed = narrowed(&c, None, &plain);
3671 assert!(resolve_filters(&narrowed, &filter_params(&[])).is_empty());
3672 assert_eq!(allowed_filters(&c, &root()).len(), 1);
3673 }
3674
3675 #[test]
3676 fn a_stored_page_size_counts_only_while_it_is_offered() {
3677 let mut cfg = config();
3678 assert_eq!(offered_per_page(&cfg, Some(50)), 25, "nothing offered");
3679 cfg.per_page_options = vec![25, 50, 100];
3680 assert_eq!(offered_per_page(&cfg, Some(50)), 50);
3681 assert_eq!(offered_per_page(&cfg, Some(999)), 25, "no longer offered");
3682 assert_eq!(offered_per_page(&cfg, None), 25);
3683 }
3684
3685 fn params(sort: Option<&str>) -> ListParams {
3686 ListParams {
3687 page: None,
3688 sort: sort.map(|s| s.to_string()),
3689 dir: Some("asc".to_string()),
3690 q: None,
3691 parent: None,
3692 depth: None,
3693 }
3694 }
3695
3696 fn plain(config: &ListConfig) -> ListQuery<'_> {
3698 ListQuery {
3699 offset: 0,
3700 order_by: &config.order_by,
3701 order_dir: config.order_dir,
3702 q: "",
3703 filters: &[],
3704 per_page: None,
3705 children_of: None,
3706 }
3707 }
3708
3709 fn searched<'a>(config: &'a ListConfig, q: &'a str) -> ListQuery<'a> {
3710 ListQuery {
3711 offset: 0,
3712 order_by: &config.order_by,
3713 order_dir: config.order_dir,
3714 q,
3715 filters: &[],
3716 per_page: None,
3717 children_of: None,
3718 }
3719 }
3720
3721 fn amps(html: &str) -> String {
3724 html.replace("&", "&").replace("&", "&")
3725 }
3726
3727 fn filter_params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
3729 pairs
3730 .iter()
3731 .map(|(k, v)| (k.to_string(), v.to_string()))
3732 .collect()
3733 }
3734
3735 #[tokio::test]
3736 async fn search_filters_rows_and_the_total() {
3737 let (db, _guard) = test_db().await;
3738 for name in ["ada", "brendan", "clara"] {
3739 let hash = laterite_auth::password::hash_password("x").unwrap();
3740 laterite_auth::store::create_user(
3741 &db,
3742 name,
3743 &format!("{name}@example.test"),
3744 name,
3745 None,
3746 &hash,
3747 false,
3748 )
3749 .await
3750 .unwrap();
3751 }
3752 let c = config();
3753
3754 let hit = query(&db, &c, &searched(&c, "bren")).await.unwrap();
3755 assert_eq!(hit.rows.len(), 1);
3756 assert_eq!(hit.rows[0].cells[0], "brendan");
3757 assert_eq!(hit.total, 1);
3759
3760 assert_eq!(
3762 query(&db, &c, &searched(&c, "BREN")).await.unwrap().total,
3763 1
3764 );
3765
3766 assert_eq!(query(&db, &c, &searched(&c, "")).await.unwrap().total, 3);
3768 assert_eq!(query(&db, &c, &searched(&c, " ")).await.unwrap().total, 3);
3769
3770 assert_eq!(query(&db, &c, &searched(&c, "%")).await.unwrap().total, 0);
3772 }
3773
3774 #[tokio::test]
3777 async fn search_skips_a_non_text_column() {
3778 let (db, _guard) = test_db().await;
3779 let hash = laterite_auth::password::hash_password("x").unwrap();
3780 laterite_auth::store::create_user(&db, "ada", "a@example.test", "Ada", None, &hash, true)
3781 .await
3782 .unwrap();
3783 let c = config();
3784 assert!(c.columns[0].is_searchable(), "username is text");
3785 assert!(!c.columns[1].is_searchable(), "is_superuser is a boolean");
3786 assert_eq!(query(&db, &c, &searched(&c, "1")).await.unwrap().total, 0);
3788 }
3789
3790 #[test]
3791 fn a_column_can_override_its_searchability() {
3792 assert!(ListColumn::new("code", "Code").is_searchable());
3793 assert!(!ListColumn::new("code", "Code")
3794 .searchable(false)
3795 .is_searchable());
3796 assert!(ListColumn::new("at", "At")
3797 .datetime()
3798 .searchable(true)
3799 .is_searchable());
3800 }
3801
3802 async fn seed_users(db: &Db) {
3803 for (name, su) in [("ada", true), ("brendan", false), ("clara", false)] {
3804 let hash = laterite_auth::password::hash_password("x").unwrap();
3805 laterite_auth::store::create_user(
3806 db,
3807 name,
3808 &format!("{name}@example.test"),
3809 name,
3810 None,
3811 &hash,
3812 su,
3813 )
3814 .await
3815 .unwrap();
3816 }
3817 }
3818
3819 async fn total_with(db: &Db, c: &ListConfig, params: &HashMap<String, String>) -> i64 {
3820 let active = resolve_filters(c, params);
3821 let req = ListQuery {
3822 offset: 0,
3823 order_by: &c.order_by,
3824 order_dir: c.order_dir,
3825 q: "",
3826 filters: &active,
3827 per_page: None,
3828 children_of: None,
3829 };
3830 query(db, c, &req).await.unwrap().total
3831 }
3832
3833 #[tokio::test]
3834 async fn a_filter_narrows_the_rows_and_the_total() {
3835 let (db, _guard) = test_db().await;
3836 seed_users(&db).await;
3837 let c = config();
3838
3839 assert_eq!(total_with(&db, &c, &filter_params(&[])).await, 3);
3840 assert_eq!(
3841 total_with(&db, &c, &filter_params(&[("f_is_superuser", "1")])).await,
3842 1
3843 );
3844 assert_eq!(
3845 total_with(&db, &c, &filter_params(&[("f_is_superuser", "0")])).await,
3846 2
3847 );
3848 }
3849
3850 #[test]
3853 fn an_undeclared_filter_or_value_is_ignored() {
3854 let c = config();
3855 assert!(resolve_filters(&c, &filter_params(&[("f_username", "ada")])).is_empty());
3857 for bad in ["yes", "true", "1 or 1=1", "", " "] {
3859 assert!(
3860 resolve_filters(&c, &filter_params(&[("f_is_superuser", bad)])).is_empty(),
3861 "{bad} must not reach the query"
3862 );
3863 }
3864
3865 let with_select = ListConfig {
3866 filters: vec![ListFilter::select(
3867 "status",
3868 "Status",
3869 vec![FilterOption::new("live", "Live")],
3870 )],
3871 ..config()
3872 };
3873 assert_eq!(
3874 resolve_filters(&with_select, &filter_params(&[("f_status", "live")])).len(),
3875 1
3876 );
3877 assert!(
3878 resolve_filters(&with_select, &filter_params(&[("f_status", "draft")])).is_empty(),
3879 "an option the descriptor never offered is dropped"
3880 );
3881 }
3882
3883 #[tokio::test]
3884 async fn a_filter_survives_a_sort_link_and_shows_as_selected() {
3885 let mut p = params(Some("username"));
3886 p.q = None;
3887 let html = get_with(
3888 p,
3889 filter_params(&[("f_is_superuser", "1")]),
3890 axum::http::HeaderMap::new(),
3891 )
3892 .await;
3893 let links = amps(&html);
3894 assert!(
3895 links.contains("?sort=is_superuser&dir=asc&f_is_superuser=1"),
3896 "a sort link carries the filter"
3897 );
3898 assert!(links.contains("?sort=username&dir=desc&f_is_superuser=1"));
3900 assert!(
3901 html.contains(r#"<option value="1" selected>"#),
3902 "the control shows the active value"
3903 );
3904 assert!(html.contains("Clear"), "and a way back to the full list");
3905 assert!(
3906 html.contains(r#"data-filtered="true""#),
3907 "the region says the list is narrowed, which is what reveals Clear"
3908 );
3909 }
3910
3911 #[tokio::test]
3914 async fn the_empty_state_distinguishes_no_records_from_no_match() {
3915 let (db, _guard) = test_db().await;
3916 let state = AdminState::new(
3917 laterite_auth::AuthService::new(db.clone(), laterite_auth::AuthConfig::default()),
3918 db,
3919 );
3920 let render = |raw: HashMap<String, String>, q: Option<&str>| {
3921 let q = q.map(String::from);
3922 let state = state.clone();
3923 async move {
3924 let p = ListParams {
3925 page: None,
3926 sort: None,
3927 dir: None,
3928 q,
3929 parent: None,
3930 depth: None,
3931 };
3932 let resp = handle(
3933 &state,
3934 &config(),
3935 "/admin/users",
3936 p,
3937 &raw,
3938 &crate::audit::test_actor(),
3939 crate::Shell::test(),
3940 &axum::http::HeaderMap::new(),
3941 )
3942 .await;
3943 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
3944 .await
3945 .unwrap();
3946 String::from_utf8(bytes.to_vec()).unwrap()
3947 }
3948 };
3949
3950 let bare = render(HashMap::new(), None).await;
3952 assert!(bare.contains("No records yet."));
3953
3954 let filtered = render(filter_params(&[("f_is_superuser", "1")]), None).await;
3956 assert!(filtered.contains("No records match."));
3957 assert!(!filtered.contains("No records yet."));
3958
3959 let searched = render(HashMap::new(), Some("zzz")).await;
3961 assert!(searched.contains("No records match."));
3962 }
3963
3964 fn root() -> laterite_auth::AuthenticatedUser {
3966 laterite_auth::AuthenticatedUser {
3967 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
3968 ..crate::audit::test_actor()
3969 }
3970 }
3971
3972 #[test]
3973 fn an_invisible_column_is_left_out_of_the_default_but_may_be_chosen() {
3974 let mut c = config();
3975 c.columns[1].invisible = true;
3976 assert_eq!(visible_columns(&c, None, &root()).len(), 1);
3977 assert_eq!(visible_columns(&c, Some("is_superuser"), &root()).len(), 1);
3978 assert_eq!(
3979 visible_columns(&c, Some("is_superuser"), &root())[0].field,
3980 "is_superuser"
3981 );
3982 }
3983
3984 #[test]
3985 fn a_gated_column_is_narrowed_away_for_an_operator_without_the_grant() {
3986 let mut c = config();
3987 c.columns[1].permission = Some("acme.secret".into());
3988 let plain = laterite_auth::AuthenticatedUser {
3989 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
3990 ..crate::audit::test_actor()
3991 };
3992 assert_eq!(visible_columns(&c, None, &plain).len(), 1);
3993 assert_eq!(visible_columns(&c, Some("is_superuser"), &plain).len(), 1);
3995 assert_eq!(visible_columns(&c, None, &root()).len(), 2);
3996 }
3997
3998 #[test]
3999 fn an_unsortable_column_is_not_sorted_by() {
4000 let mut c = config();
4001 c.columns[0].sortable = false;
4002 let (by, _) = resolve_sort(&c, Some("username"), Some("asc"));
4003 assert_eq!(by, c.order_by, "falls back to the descriptor's order");
4004 }
4005
4006 #[test]
4007 fn a_stored_choice_narrows_the_columns_in_descriptor_order() {
4008 let c = config();
4009 let kept = visible_columns(&c, Some("is_superuser,username"), &root());
4012 assert_eq!(
4013 kept.iter().map(|c| c.field.as_str()).collect::<Vec<_>>(),
4014 ["username", "is_superuser"]
4015 );
4016 }
4017
4018 #[test]
4019 fn no_stored_choice_shows_every_column() {
4020 let c = config();
4021 assert_eq!(visible_columns(&c, None, &root()).len(), c.columns.len());
4022 }
4023
4024 #[test]
4027 fn a_stale_choice_falls_back_to_every_column() {
4028 let c = config();
4029 assert_eq!(
4030 visible_columns(&c, Some("gone,removed"), &root()).len(),
4031 c.columns.len()
4032 );
4033 assert_eq!(
4034 visible_columns(&c, Some(""), &root()).len(),
4035 c.columns.len()
4036 );
4037 assert_eq!(
4038 visible_columns(&c, Some(" , "), &root()).len(),
4039 c.columns.len()
4040 );
4041 assert_eq!(visible_columns(&c, Some("gone,username"), &root()).len(), 1);
4043 }
4044
4045 #[test]
4048 fn the_toolbar_hides_what_the_operator_may_not_do() {
4049 let shell = crate::Shell::test();
4050 let cfg = ListConfig {
4051 creatable: false,
4052 toolbar: vec![
4053 ToolbarButton::new("Open", "/reports"),
4054 ToolbarButton::new("Audit", "/audit").require("backend.view_audit"),
4055 ],
4056 ..config()
4057 };
4058
4059 let root = laterite_auth::AuthenticatedUser {
4061 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
4062 ..crate::audit::test_actor()
4063 };
4064 let views = toolbar_views(&cfg, "/admin", &root, &shell);
4065 assert_eq!(
4066 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
4067 ["Open", "Audit"]
4068 );
4069
4070 let granted = laterite_auth::AuthenticatedUser {
4072 permissions: laterite_auth::PermissionSet::with_overrides(
4073 false,
4074 ["backend.view_audit".to_string()],
4075 [],
4076 [],
4077 ),
4078 ..crate::audit::test_actor()
4079 };
4080 assert_eq!(toolbar_views(&cfg, "/admin", &granted, &shell).len(), 2);
4081
4082 let plain = laterite_auth::AuthenticatedUser {
4084 permissions: laterite_auth::PermissionSet::with_overrides(false, [], [], []),
4085 ..crate::audit::test_actor()
4086 };
4087 let views = toolbar_views(&cfg, "/admin", &plain, &shell);
4088 assert_eq!(
4089 views.iter().map(|v| v.label.as_str()).collect::<Vec<_>>(),
4090 ["Open"]
4091 );
4092 }
4093
4094 #[test]
4097 fn a_toolbar_href_follows_the_admin_mount() {
4098 let shell = crate::Shell::test();
4099 let cfg = ListConfig {
4100 creatable: true,
4101 edit_base: Some("/backoffice/roles".to_string()),
4103 toolbar: vec![
4104 ToolbarButton::new("Reports", "/reports"),
4105 ToolbarButton::new("Docs", "https://example.test/docs"),
4106 ],
4107 ..config()
4108 };
4109 let root = laterite_auth::AuthenticatedUser {
4110 permissions: laterite_auth::PermissionSet::with_overrides(true, [], [], []),
4111 ..crate::audit::test_actor()
4112 };
4113 let views = toolbar_views(&cfg, "/backoffice", &root, &shell);
4114 assert_eq!(views[0].href, "/backoffice/roles/new", "New comes first");
4115 assert_eq!(views[1].href, "/backoffice/reports");
4116 assert_eq!(views[2].href, "https://example.test/docs");
4118 }
4119
4120 #[tokio::test]
4121 async fn an_htmx_sort_returns_the_list_region_alone() {
4122 let mut headers = axum::http::HeaderMap::new();
4123 headers.insert("hx-request", "true".parse().unwrap());
4124 let html = get(params(Some("username")), headers).await;
4125 assert!(html.contains(r#"id="lat-list""#), "the region comes back");
4126 assert!(html.contains("aria-sort=\"ascending\""), "sorted ascending");
4127 assert!(!html.contains("<body"), "no page chrome");
4130 assert!(html.contains(r#"data-filtered="false""#));
4132 }
4133
4134 #[tokio::test]
4137 async fn the_search_term_survives_a_sort_link() {
4138 let mut p = params(Some("username"));
4139 p.q = Some("bren".to_string());
4140 let html = get(p, axum::http::HeaderMap::new()).await;
4141 assert!(
4142 amps(&html).contains("?sort=username&dir=desc&q=bren"),
4143 "sort and pager links carry the term"
4144 );
4145 assert!(
4146 html.contains(r#"value="bren""#),
4147 "and the box still shows it"
4148 );
4149 }
4150
4151 #[tokio::test]
4152 async fn a_plain_sort_returns_the_whole_page() {
4153 let html = get(params(Some("username")), axum::http::HeaderMap::new()).await;
4154 assert!(html.contains("<body"), "lists still work without htmx");
4155 assert!(html.contains(r#"id="lat-list""#));
4156 assert!(
4159 html.contains(r#"?sort=username"#) && html.contains(r#"href="/admin/"#),
4160 "{html}"
4161 );
4162 }
4163
4164 #[tokio::test]
4165 async fn query_rejects_bad_identifiers() {
4166 let (db, _guard) = test_db().await;
4167 let mut bad = config();
4168 bad.entity = "backend_users; drop table backend_users".to_string();
4169 assert!(query(&db, &bad, &plain(&bad)).await.is_err());
4170 }
4171}