1use std::collections::BTreeMap;
48
49use axum::Router;
50use axum::extract::{FromRequestParts, State};
51use axum::http::StatusCode;
52use axum::http::request::Parts;
53use axum::response::{IntoResponse, Response};
54use axum::routing::post;
55use chrono::{Duration, NaiveDate};
56use serde::{Deserialize, Serialize};
57use serde_json::{Map, Value, json};
58
59mod export;
60
61pub use export::{ExportFormat, MAX_EXPORT_ROWS, Workbook};
62
63use crate::auth::AuthUser;
64use crate::db::{Db, DbValue, Model, Paginated, Query, ToDbValue};
65use crate::{AppState, Error, Result, Session};
66
67pub(crate) const MIGRATION: crate::db::Migration =
69 crate::db::framework_migration!("grid", "00010101000220_create_grid_preferences_table");
70
71const PER_PAGE_OPTIONS: [u32; 4] = [10, 25, 50, 100];
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "snake_case")]
77#[non_exhaustive]
78pub enum Kind {
79 Text,
82 Number,
84 Money,
87 Date,
89 DateTime,
91 Bool,
93 Select,
95 Tags,
98 Custom,
101 Image,
103 Color,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum Pin {
111 Left,
113 Right,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
120#[serde(rename_all = "snake_case")]
121#[non_exhaustive]
122pub enum Summary {
123 Sum,
125 Average,
127 Range,
129 Count,
131}
132
133#[derive(Debug, Clone)]
135#[non_exhaustive]
136pub struct Column {
137 key: String,
138 label: String,
139 kind: Kind,
140 under: Vec<String>,
141 sortable: bool,
142 filterable: bool,
143 mobile: bool,
144 hidden: bool,
145 pin: Option<Pin>,
146 decimals: Option<u8>,
147 options: Vec<(String, String)>,
148 width: Option<String>,
149 editable: bool,
150 merge: bool,
151 searchable: bool,
152 summaries: Vec<Summary>,
153 badges: Option<Vec<(String, String)>>,
154 icons: bool,
155 description: Option<String>,
156 tooltip: Option<String>,
157 wrap: bool,
158 limit: Option<usize>,
159 link: Option<String>,
160 copyable: bool,
161 round: bool,
162 expr: Option<String>,
164}
165
166impl Column {
167 fn new(key: &str, label: &str, kind: Kind) -> Self {
168 let plain = kind != Kind::Custom;
169 Self {
170 key: key.to_owned(),
171 label: label.to_owned(),
172 kind,
173 under: Vec::new(),
174 sortable: plain && kind != Kind::Tags,
175 filterable: plain,
176 mobile: false,
177 hidden: false,
178 pin: None,
179 decimals: None,
180 options: Vec::new(),
181 width: None,
182 editable: false,
183 merge: false,
184 searchable: false,
185 summaries: Vec::new(),
186 badges: None,
187 icons: false,
188 description: None,
189 tooltip: None,
190 wrap: false,
191 limit: None,
192 link: None,
193 copyable: false,
194 round: false,
195 expr: None,
196 }
197 }
198
199 pub fn related(key: &str, label: &str, table: &str, foreign_key: &str, column: &str) -> Self {
205 let mut c = Self::new(key, label, Kind::Text);
206 if [table, foreign_key, column].iter().all(|n| plain_name(n)) {
207 c.expr = Some(format!(
208 "(SELECT \"{table}\".\"{column}\" FROM \"{table}\" WHERE \"{table}\".\"id\" = {{T}}.\"{foreign_key}\")"
209 ));
210 }
211 c
212 }
213
214 pub fn count_of(key: &str, label: &str, table: &str, foreign_key: &str) -> Self {
217 let mut c = Self::new(key, label, Kind::Number);
218 if [table, foreign_key].iter().all(|n| plain_name(n)) {
219 c.expr = Some(format!(
220 "(SELECT COUNT(*) FROM \"{table}\" WHERE \"{table}\".\"{foreign_key}\" = {{T}}.\"id\")"
221 ));
222 }
223 c
224 }
225
226 pub fn sum_of(key: &str, label: &str, table: &str, foreign_key: &str, column: &str) -> Self {
229 let mut c = Self::new(key, label, Kind::Number);
230 if [table, foreign_key, column].iter().all(|n| plain_name(n)) {
231 c.expr = Some(format!(
232 "(SELECT COALESCE(SUM(\"{table}\".\"{column}\"), 0) FROM \"{table}\" WHERE \"{table}\".\"{foreign_key}\" = {{T}}.\"id\")"
233 ));
234 }
235 c
236 }
237
238 pub fn computed(mut self, sql: &str) -> Self {
247 self.expr = Some(sql.to_owned());
248 self
249 }
250
251 pub fn numeric(mut self) -> Self {
253 if self.expr.is_some() {
254 self.kind = Kind::Number;
255 }
256 self
257 }
258
259 fn target<M: Model>(&self) -> Target {
262 match &self.expr {
263 Some(expr) => Target::Expr(expr.replace("{T}", &format!("\"{}\"", M::TABLE))),
264 None => Target::Column(self.key.clone()),
265 }
266 }
267
268 pub fn image(key: &str, label: &str) -> Self {
271 let mut column = Self::new(key, label, Kind::Image);
272 column.sortable = false;
273 column.filterable = false;
274 column
275 }
276
277 pub fn color(key: &str, label: &str) -> Self {
279 Self::new(key, label, Kind::Color)
280 }
281
282 pub fn badges(mut self, tones: &[(&str, &str)]) -> Self {
286 self.badges = Some(
287 tones
288 .iter()
289 .map(|(v, t)| ((*v).to_owned(), (*t).to_owned()))
290 .collect(),
291 );
292 self
293 }
294
295 pub fn icons(mut self) -> Self {
297 self.icons = true;
298 self
299 }
300
301 pub fn description(mut self, key: &str) -> Self {
304 self.description = Some(key.to_owned());
305 self
306 }
307
308 pub fn tooltip(mut self, key: &str) -> Self {
310 self.tooltip = Some(key.to_owned());
311 self
312 }
313
314 pub fn wrap(mut self) -> Self {
316 self.wrap = true;
317 self
318 }
319
320 pub fn limit(mut self, chars: usize) -> Self {
323 self.limit = Some(chars);
324 self
325 }
326
327 pub fn link(mut self, url: &str) -> Self {
329 self.link = Some(url.to_owned());
330 self
331 }
332
333 pub fn copyable(mut self) -> Self {
335 self.copyable = true;
336 self
337 }
338
339 pub fn round(mut self) -> Self {
341 self.round = true;
342 self
343 }
344
345 pub fn text(key: &str, label: &str) -> Self {
347 Self::new(key, label, Kind::Text)
348 }
349
350 pub fn number(key: &str, label: &str) -> Self {
352 Self::new(key, label, Kind::Number)
353 }
354
355 pub fn money(key: &str, label: &str) -> Self {
362 Self::new(key, label, Kind::Money)
363 }
364
365 pub fn date(key: &str, label: &str) -> Self {
367 Self::new(key, label, Kind::Date)
368 }
369
370 pub fn datetime(key: &str, label: &str) -> Self {
372 Self::new(key, label, Kind::DateTime)
373 }
374
375 pub fn bool(key: &str, label: &str) -> Self {
377 Self::new(key, label, Kind::Bool)
378 }
379
380 pub fn select<V: Into<String>, L: Into<String>>(
382 key: &str,
383 label: &str,
384 options: impl IntoIterator<Item = (V, L)>,
385 ) -> Self {
386 let mut column = Self::new(key, label, Kind::Select);
387 column.options = options
388 .into_iter()
389 .map(|(v, l)| (v.into(), l.into()))
390 .collect();
391 column
392 }
393
394 pub fn tags<V: Into<String>, L: Into<String>>(
396 key: &str,
397 label: &str,
398 options: impl IntoIterator<Item = (V, L)>,
399 ) -> Self {
400 let mut column = Self::select(key, label, options);
401 column.kind = Kind::Tags;
402 column
403 }
404
405 pub fn custom(key: &str, label: &str) -> Self {
408 Self::new(key, label, Kind::Custom)
409 }
410
411 pub fn under<S: Into<String>>(mut self, groups: impl IntoIterator<Item = S>) -> Self {
415 self.under = groups.into_iter().map(Into::into).collect();
416 self
417 }
418
419 pub fn sortable(mut self, sortable: bool) -> Self {
422 self.sortable = sortable && self.kind != Kind::Custom;
423 self
424 }
425
426 pub fn filterable(mut self, filterable: bool) -> Self {
429 self.filterable = filterable && self.kind != Kind::Custom;
430 self
431 }
432
433 pub fn mobile(mut self) -> Self {
436 self.mobile = true;
437 self
438 }
439
440 pub fn hidden(mut self) -> Self {
442 self.hidden = true;
443 self
444 }
445
446 pub fn frozen(mut self) -> Self {
448 self.pin = Some(Pin::Left);
449 self
450 }
451
452 pub fn frozen_right(mut self) -> Self {
454 self.pin = Some(Pin::Right);
455 self
456 }
457
458 pub fn decimals(mut self, decimals: u8) -> Self {
460 self.decimals = Some(decimals);
461 self
462 }
463
464 pub fn width(mut self, width: &str) -> Self {
466 self.width = Some(width.to_owned());
467 self
468 }
469
470 pub fn editable(mut self) -> Self {
475 self.editable = self.kind != Kind::Custom;
476 self
477 }
478
479 pub fn merge(mut self) -> Self {
484 self.merge = true;
485 self.editable = false;
486 self
487 }
488
489 pub fn searchable(mut self) -> Self {
500 self.searchable = self.kind != Kind::Custom;
501 self
502 }
503
504 pub fn summary(mut self, summary: Summary) -> Self {
509 let numeric = matches!(self.kind, Kind::Number | Kind::Money);
510 if (numeric || summary == Summary::Count) && !self.summaries.contains(&summary) {
511 self.summaries.push(summary);
512 }
513 self
514 }
515
516 pub fn key(&self) -> &str {
518 &self.key
519 }
520
521 pub fn kind(&self) -> Kind {
523 self.kind
524 }
525
526 pub fn label(&self) -> &str {
528 &self.label
529 }
530
531 pub fn titled(mut self, label: &str) -> Self {
533 label.clone_into(&mut self.label);
534 self
535 }
536
537 pub fn options(&self) -> &[(String, String)] {
540 &self.options
541 }
542}
543
544#[derive(Debug, Clone)]
547#[non_exhaustive]
548pub struct Grid {
549 id: String,
550 title: Option<String>,
551 columns: Vec<Column>,
552 per_page: u32,
553 per_page_options: Vec<u32>,
554 sort: Option<String>,
555 audit: bool,
556 details: bool,
557 edit_url: Option<String>,
558 reorder: Option<(String, String)>,
559 exports: bool,
560 prefix: Option<String>,
561 row_url: Option<String>,
562 empty: Option<(String, Option<String>)>,
563 bulk: Vec<Action>,
564 row_actions: Vec<Action>,
565 groups: Vec<String>,
566 group: Option<String>,
567 cards: bool,
568 advanced: bool,
569 poll: Option<u32>,
570 remember: bool,
571}
572
573impl Grid {
574 pub fn new(id: &str) -> Self {
577 Self {
578 id: id.to_owned(),
579 title: None,
580 columns: Vec::new(),
581 per_page: 25,
582 per_page_options: PER_PAGE_OPTIONS.to_vec(),
583 sort: None,
584 audit: false,
585 details: false,
586 edit_url: None,
587 reorder: None,
588 exports: false,
589 prefix: None,
590 row_url: None,
591 empty: None,
592 bulk: Vec::new(),
593 row_actions: Vec::new(),
594 groups: Vec::new(),
595 group: None,
596 cards: false,
597 advanced: false,
598 poll: None,
599 remember: false,
600 }
601 }
602
603 pub fn poll(mut self, seconds: u32) -> Self {
606 self.poll = Some(seconds.max(5));
607 self
608 }
609
610 pub fn remember(mut self) -> Self {
613 self.remember = true;
614 self
615 }
616
617 fn effective_params(&self, request: &GridRequest) -> Vec<(String, String)> {
621 let own = self.own_params(&request.params);
622 if !self.remember {
623 return own;
624 }
625 let key = format!("_grid_q.{}", self.id);
626 let Some(session) = &request.session else {
627 return own;
628 };
629 if own.iter().any(|(k, _)| k == "state") {
630 let kept: Vec<(String, String)> = own
631 .iter()
632 .filter(|(k, _)| !matches!(k.as_str(), "state" | "export" | "page"))
633 .cloned()
634 .collect();
635 let _ = session.put(&key, &kept);
636 own
637 } else {
638 session.get::<Vec<(String, String)>>(&key).unwrap_or(own)
639 }
640 }
641
642 pub fn advanced_filter(mut self) -> Self {
648 self.advanced = true;
649 self
650 }
651
652 pub fn cards_on_mobile(mut self) -> Self {
656 self.cards = true;
657 self
658 }
659
660 pub fn groups(mut self, keys: &[&str]) -> Self {
665 self.groups = keys.iter().map(|k| (*k).to_owned()).collect();
666 self
667 }
668
669 pub fn group_by(mut self, key: &str) -> Self {
672 if !self.groups.iter().any(|g| g == key) {
673 self.groups.insert(0, key.to_owned());
674 }
675 self.group = Some(key.to_owned());
676 self
677 }
678
679 pub fn bulk_action(mut self, action: Action) -> Self {
684 self.bulk.push(action);
685 self
686 }
687
688 pub fn row_action(mut self, action: Action) -> Self {
690 self.row_actions.push(action);
691 self
692 }
693
694 pub fn selected<M: Model>(
717 &self,
718 query: Query<M>,
719 request: &GridRequest,
720 selection: &Selection,
721 ) -> Result<Query<M>> {
722 if selection.all {
723 return Ok(self.filter(query, request));
724 }
725 let mut keys = Vec::with_capacity(selection.ids.len());
726 for id in selection.ids.iter().take(10_000) {
727 let key: M::Key = id
728 .parse()
729 .map_err(|_| Error::BadRequest(format!("`{id}` is not an id")))?;
730 keys.push(key);
731 }
732 Ok(query.where_in("id", keys))
733 }
734
735 pub fn prefix(mut self, prefix: &str) -> Self {
740 self.prefix = Some(prefix.to_owned());
741 self
742 }
743
744 pub fn row_url(mut self, url: &str) -> Self {
749 self.row_url = Some(url.to_owned());
750 self
751 }
752
753 pub fn empty_state(mut self, heading: &str, description: Option<&str>) -> Self {
756 self.empty = Some((heading.to_owned(), description.map(str::to_owned)));
757 self
758 }
759
760 fn own<'a>(&self, name: &'a str) -> Option<&'a str> {
763 let name = match &self.prefix {
764 Some(prefix) => name.strip_prefix(prefix.as_str())?.strip_prefix('.')?,
765 None => name,
766 };
767 let known = matches!(
768 name,
769 "page" | "per_page" | "sort" | "search" | "export" | "group" | "match" | "state"
770 ) || name.split_once('.').is_some_and(|(part, _)| {
771 matches!(part, "q" | "m" | "min" | "max" | "from" | "to" | "in" | "r")
772 });
773 known.then_some(name)
774 }
775
776 fn own_params(&self, params: &[(String, String)]) -> Vec<(String, String)> {
778 params
779 .iter()
780 .filter_map(|(k, v)| self.own(k).map(|k| (k.to_owned(), v.clone())))
781 .collect()
782 }
783
784 fn name(&self, name: &str) -> String {
786 match &self.prefix {
787 Some(prefix) => format!("{prefix}.{name}"),
788 None => name.to_owned(),
789 }
790 }
791
792 pub fn exports(mut self) -> Self {
797 self.exports = true;
798 self
799 }
800
801 pub fn audit(mut self) -> Self {
805 self.audit = true;
806 self
807 }
808
809 pub fn details(mut self) -> Self {
813 self.details = true;
814 self
815 }
816
817 pub fn edit_url(mut self, url: &str) -> Self {
823 self.edit_url = Some(url.to_owned());
824 self
825 }
826
827 pub fn reorder(mut self, column: &str, url: &str) -> Self {
832 self.reorder = Some((column.to_owned(), url.to_owned()));
833 self
834 }
835
836 pub fn title(mut self, title: &str) -> Self {
838 self.title = Some(title.to_owned());
839 self
840 }
841
842 pub fn column(mut self, column: Column) -> Self {
844 self.columns.push(column);
845 self
846 }
847
848 pub fn per_page(mut self, per_page: u32) -> Self {
850 self.per_page = per_page.clamp(1, 500);
851 if !self.per_page_options.contains(&self.per_page) {
852 self.per_page_options.push(self.per_page);
853 self.per_page_options.sort_unstable();
854 }
855 self
856 }
857
858 pub fn sort_by(mut self, sort: &str) -> Self {
863 self.sort = Some(sort.to_owned());
864 self
865 }
866
867 pub fn id(&self) -> &str {
869 &self.id
870 }
871
872 pub fn columns(&self) -> &[Column] {
874 &self.columns
875 }
876
877 fn find(&self, key: &str) -> Option<&Column> {
878 self.columns.iter().find(|c| c.key == key)
879 }
880
881 pub async fn page<M: Model + Serialize>(
884 &self,
885 query: Query<M>,
886 request: &GridRequest,
887 ) -> Result<GridPage<M>> {
888 if !valid_key(&self.id) {
889 return Err(Error::Internal(anyhow::anyhow!(
890 "grid id `{}` may only have letters, digits, `_` and `-`",
891 self.id
892 )));
893 }
894 let prefs = load_prefs(request, &self.id).await;
895 let state = State_::parse(self, &self.effective_params(request));
896 let query = self.filtered(query, &state, request);
897 let unsorted = query.clone();
898 let query = self.sorted(query, &state);
899 let rows = query
900 .paginate(&request.db, state.page, state.per_page)
901 .await?;
902 let related = self.related_values(&request.db, &rows.items).await?;
903 let summaries = self.summarize(&unsorted, &request.db, None).await?;
904 let group_summaries = match &state.group {
905 Some(group) if self.find(group).is_some() => {
906 self.summarize(&unsorted, &request.db, Some(group)).await?
907 }
908 _ => BTreeMap::new(),
909 };
910 let keep = request
911 .params
912 .iter()
913 .filter(|(k, _)| self.own(k).is_none())
914 .cloned()
915 .collect();
916 Ok(GridPage {
917 grid: self.clone(),
918 prefs,
919 state,
920 path: request.path.clone(),
921 keep,
922 rows,
923 extra: Vec::new(),
924 related,
925 summaries,
926 group_summaries,
927 money_decimals: request.money_decimals,
928 })
929 }
930
931 async fn summarize<M: Model>(
936 &self,
937 query: &Query<M>,
938 db: &Db,
939 group: Option<&String>,
940 ) -> Result<BTreeMap<String, Map<String, Value>>> {
941 let mut out: BTreeMap<String, Map<String, Value>> = BTreeMap::new();
942 let sql_of = |key: &str| -> Option<String> {
944 match self.find(key)?.target::<M>() {
945 Target::Expr(expr) => Some(expr),
946 Target::Column(key) if M::COLUMNS.contains(&key.as_str()) => {
947 Some(format!("\"{key}\""))
948 }
949 Target::Column(_) => None,
950 }
951 };
952 let group_expr = match group {
953 Some(g) => match sql_of(g) {
954 Some(expr) => Some(expr),
955 None => return Ok(out),
956 },
957 None => None,
958 };
959 let group_sql = group_expr.as_ref().map(|e| format!("CAST({e} AS TEXT)"));
960 if let (Some(group_sql), Some(expr)) = (&group_sql, &group_expr) {
961 let counts: Vec<(Option<String>, i64)> = query
962 .clone()
963 .group_by_raw(expr)
964 .select_as(db, &format!("{group_sql}, COUNT(*)"))
965 .await?;
966 for (value, count) in counts {
967 out.entry(value.unwrap_or_default())
968 .or_default()
969 .insert("_rows".into(), count.into());
970 }
971 }
972 for column in self.columns.iter().filter(|c| !c.summaries.is_empty()) {
973 let Some(sql) = sql_of(&column.key) else {
974 continue;
975 };
976 let key = &column.key;
977 let numeric = matches!(column.kind, Kind::Number | Kind::Money);
978 let figures = if numeric {
979 format!(
980 "CAST(SUM({sql}) AS DOUBLE PRECISION), CAST(AVG({sql}) AS DOUBLE PRECISION), \
981 CAST(MIN({sql}) AS DOUBLE PRECISION), CAST(MAX({sql}) AS DOUBLE PRECISION), COUNT({sql})"
982 )
983 } else {
984 format!("NULL, NULL, NULL, NULL, COUNT({sql})")
985 };
986 type Figures = (Option<f64>, Option<f64>, Option<f64>, Option<f64>, i64);
987 let rows: Vec<(String, Figures)> = match (&group_sql, &group_expr) {
988 (Some(group_sql), Some(group)) => query
989 .clone()
990 .group_by_raw(group)
991 .select_as::<(
992 Option<String>,
993 Option<f64>,
994 Option<f64>,
995 Option<f64>,
996 Option<f64>,
997 i64,
998 ), _>(db, &format!("{group_sql}, {figures}"))
999 .await?
1000 .into_iter()
1001 .map(|(g, a, b, c, d, e)| (g.unwrap_or_default(), (a, b, c, d, e)))
1002 .collect(),
1003 _ => query
1004 .clone()
1005 .select_as::<Figures, _>(db, &figures)
1006 .await?
1007 .into_iter()
1008 .map(|f| (String::new(), f))
1009 .collect(),
1010 };
1011 for (value, (sum, average, min, max, count)) in rows {
1012 out.entry(value).or_default().insert(
1013 key.clone(),
1014 json!({ "sum": sum, "average": average, "min": min, "max": max, "count": count }),
1015 );
1016 }
1017 }
1018 Ok(out)
1019 }
1020
1021 async fn related_values<M: Model + Serialize>(
1023 &self,
1024 db: &Db,
1025 items: &[M],
1026 ) -> Result<Vec<Map<String, Value>>> {
1027 let mut out = vec![Map::new(); items.len()];
1028 let columns: Vec<&Column> = self.columns.iter().filter(|c| c.expr.is_some()).collect();
1029 if columns.is_empty() || items.is_empty() {
1030 return Ok(out);
1031 }
1032 let ids: Vec<String> = items
1033 .iter()
1034 .map(|item| {
1035 match serde_json::to_value(item)
1036 .ok()
1037 .and_then(|v| v.get("id").cloned())
1038 {
1039 Some(Value::String(id)) => id,
1040 Some(other) => other.to_string(),
1041 None => String::new(),
1042 }
1043 })
1044 .collect();
1045 let keys: Vec<M::Key> = ids.iter().filter_map(|id| id.parse().ok()).collect();
1046 for column in columns {
1049 let Target::Expr(expr) = column.target::<M>() else {
1050 continue;
1051 };
1052 let numeric = matches!(column.kind, Kind::Number | Kind::Money);
1053 let values: BTreeMap<String, Value> = if numeric {
1054 M::unscoped()
1055 .where_in("id", keys.clone())
1056 .select_as::<(String, Option<f64>), _>(
1057 db,
1058 &format!("CAST(\"id\" AS TEXT), CAST({expr} AS DOUBLE PRECISION)"),
1059 )
1060 .await?
1061 .into_iter()
1062 .map(|(id, v)| {
1063 let v = match v {
1064 Some(f) if f.fract() == 0.0 && f.abs() < 9e15 => Value::from(f as i64),
1065 Some(f) => Value::from(f),
1066 None => Value::Null,
1067 };
1068 (id, v)
1069 })
1070 .collect()
1071 } else {
1072 M::unscoped()
1073 .where_in("id", keys.clone())
1074 .select_as::<(String, Option<String>), _>(
1075 db,
1076 &format!("CAST(\"id\" AS TEXT), CAST({expr} AS TEXT)"),
1077 )
1078 .await?
1079 .into_iter()
1080 .map(|(id, v)| (id, v.map_or(Value::Null, Value::from)))
1081 .collect()
1082 };
1083 for (i, id) in ids.iter().enumerate() {
1084 out[i].insert(
1085 column.key.clone(),
1086 values.get(id).cloned().unwrap_or(Value::Null),
1087 );
1088 }
1089 }
1090 Ok(out)
1091 }
1092
1093 pub fn filter<M: Model>(&self, query: Query<M>, request: &GridRequest) -> Query<M> {
1096 let state = State_::parse(self, &self.effective_params(request));
1097 let query = self.filtered(query, &state, request);
1098 self.sorted(query, &state)
1099 }
1100
1101 fn filtered<M: Model>(
1102 &self,
1103 mut query: Query<M>,
1104 state: &State_,
1105 request: &GridRequest,
1106 ) -> Query<M> {
1107 let zone = &request.zone;
1108 let money = 10f64.powi(request.money_decimals as i32);
1109 for (key, filter) in &state.filters {
1110 let Some(column) = self.find(key) else {
1111 continue;
1112 };
1113 query = filter.apply(query, column, zone, money);
1114 }
1115 if !state.rules.is_empty() {
1116 let mut parts = Vec::new();
1117 let mut binds: Vec<DbValue> = Vec::new();
1118 for rule in &state.rules {
1119 let Some(column) = self.find(&rule.column) else {
1120 continue;
1121 };
1122 let target = match column.target::<M>() {
1123 Target::Expr(expr) => expr,
1124 Target::Column(key) if M::COLUMNS.contains(&key.as_str()) => {
1125 format!("\"{key}\"")
1126 }
1127 Target::Column(_) => continue,
1128 };
1129 if let Some((sql, values)) = rule.sql(&target, column.kind, zone, money) {
1130 parts.push(format!("({sql})"));
1131 binds.extend(values);
1132 }
1133 }
1134 if !parts.is_empty() {
1135 let joined = parts.join(if state.any { " OR " } else { " AND " });
1136 query = query.where_raw(&joined, binds);
1137 }
1138 }
1139 if let Some(search) = &state.search {
1140 let indexed = !M::SEARCHABLE.is_empty();
1144 let columns: Vec<String> = self
1145 .columns
1146 .iter()
1147 .filter(|c| c.searchable)
1148 .filter_map(|c| match c.target::<M>() {
1149 Target::Expr(expr) => Some(expr),
1150 Target::Column(key) if indexed && M::SEARCHABLE.contains(&key.as_str()) => None,
1151 Target::Column(key) if M::COLUMNS.contains(&key.as_str()) => {
1152 Some(format!("\"{key}\""))
1153 }
1154 Target::Column(_) => None,
1155 })
1156 .collect();
1157 if indexed && columns.is_empty() {
1158 query = query.where_search(search);
1159 } else if indexed || !columns.is_empty() {
1160 for word in search.split_whitespace().take(8) {
1162 let pattern = format!("%{}%", word.to_lowercase());
1163 let sql = columns
1164 .iter()
1165 .map(|target| format!("LOWER(CAST({target} AS TEXT)) LIKE ?"))
1166 .collect::<Vec<_>>()
1167 .join(" OR ");
1168 query = if indexed {
1169 query.where_any(|q| {
1170 q.where_search(word)
1171 .where_raw(&sql, vec![pattern.clone(); columns.len()])
1172 })
1173 } else {
1174 query.where_raw(&sql, vec![pattern; columns.len()])
1175 };
1176 }
1177 }
1178 }
1179 query
1180 }
1181
1182 fn sorted<M: Model>(&self, mut query: Query<M>, state: &State_) -> Query<M> {
1183 let mut keys: Vec<(String, bool)> = if state.defaulted {
1184 self.default_sort()
1185 } else {
1186 state.sort.iter().cloned().collect()
1187 };
1188 if let Some(group) = &state.group
1190 && keys.first().is_none_or(|(k, _)| k != group)
1191 {
1192 let desc = keys
1193 .iter()
1194 .find(|(k, _)| k == group)
1195 .is_some_and(|(_, d)| *d);
1196 keys.retain(|(k, _)| k != group);
1197 keys.insert(0, (group.clone(), desc));
1198 }
1199 let mut relevance = match &state.search {
1202 Some(search) if state.defaulted && !M::SEARCHABLE.is_empty() => Some(search),
1203 _ => None,
1204 };
1205 if state.group.is_none()
1206 && let Some(search) = relevance.take()
1207 {
1208 query = query.order_by_relevance(search);
1209 }
1210 for (key, desc) in &keys {
1211 let dir = if *desc { "DESC" } else { "ASC" };
1212 query = match self.find(key).map(Column::target::<M>) {
1215 Some(Target::Expr(expr)) => {
1216 query.order_by_raw(&format!("({expr} IS NULL), {expr} {dir}"))
1217 }
1218 _ if M::COLUMNS.contains(&key.as_str()) => {
1219 query.order_by_raw(&format!("(\"{key}\" IS NULL), \"{key}\" {dir}"))
1220 }
1221 _ if *desc => query.order_by_desc(key),
1222 _ => query.order_by(key),
1223 };
1224 if let Some(search) = relevance.take() {
1226 query = query.order_by_relevance(search);
1227 }
1228 }
1229 if keys.iter().any(|(key, _)| key == "id") {
1230 query
1231 } else {
1232 query.order_by("id")
1233 }
1234 }
1235
1236 fn default_sort(&self) -> Vec<(String, bool)> {
1238 self.sort
1239 .as_deref()
1240 .unwrap_or("")
1241 .split(',')
1242 .map(str::trim)
1243 .filter(|k| !k.is_empty())
1244 .map(|k| match k.strip_prefix('-') {
1245 Some(key) => (key.to_owned(), true),
1246 None => (k.to_owned(), false),
1247 })
1248 .collect()
1249 }
1250
1251 fn ordered(&self, prefs: &GridPrefs) -> Vec<(&Column, Option<Pin>)> {
1254 let mut columns: Vec<&Column> = self.columns.iter().collect();
1255 if let Some(order) = &prefs.order {
1256 columns.sort_by_key(|c| order.iter().position(|k| *k == c.key).unwrap_or(usize::MAX));
1257 }
1258 let pin = |c: &Column| match (&prefs.left, &prefs.right) {
1259 (None, None) => c.pin,
1260 (left, right) => {
1261 if left.as_ref().is_some_and(|l| l.contains(&c.key)) {
1262 Some(Pin::Left)
1263 } else if right.as_ref().is_some_and(|r| r.contains(&c.key)) {
1264 Some(Pin::Right)
1265 } else {
1266 None
1267 }
1268 }
1269 };
1270 let mut out: Vec<(&Column, Option<Pin>)> = Vec::new();
1271 for side in [Some(Pin::Left), None, Some(Pin::Right)] {
1272 out.extend(
1273 columns
1274 .iter()
1275 .filter(|c| pin(c) == side)
1276 .map(|c| (*c, side)),
1277 );
1278 }
1279 out
1280 }
1281
1282 fn default_visible(&self, compact: bool) -> Vec<String> {
1283 if compact {
1284 let flagged: Vec<String> = self
1285 .columns
1286 .iter()
1287 .filter(|c| c.mobile)
1288 .map(|c| c.key.clone())
1289 .collect();
1290 if !flagged.is_empty() {
1291 return flagged;
1292 }
1293 return self
1294 .columns
1295 .iter()
1296 .filter(|c| !c.hidden)
1297 .take(3)
1298 .map(|c| c.key.clone())
1299 .collect();
1300 }
1301 self.columns
1302 .iter()
1303 .filter(|c| !c.hidden)
1304 .map(|c| c.key.clone())
1305 .collect()
1306 }
1307}
1308
1309fn plain_name(name: &str) -> bool {
1311 !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
1312}
1313
1314enum Target {
1316 Column(String),
1317 Expr(String),
1318}
1319
1320impl Target {
1321 fn compare<M: Model>(&self, query: Query<M>, op: &str, value: impl ToDbValue) -> Query<M> {
1322 match self {
1323 Target::Column(key) => query.where_op(key, op, value),
1324 Target::Expr(expr) => query.where_raw(&format!("{expr} {op} ?"), [value]),
1325 }
1326 }
1327
1328 fn like<M: Model>(&self, query: Query<M>, pattern: String) -> Query<M> {
1329 match self {
1330 Target::Column(key) => query.where_like(key, pattern),
1331 Target::Expr(expr) => query.where_raw(
1332 &format!("LOWER(CAST({expr} AS TEXT)) LIKE ?"),
1333 [pattern.to_lowercase()],
1334 ),
1335 }
1336 }
1337
1338 fn among<M: Model, V: ToDbValue>(&self, query: Query<M>, values: Vec<V>) -> Query<M> {
1339 match self {
1340 Target::Column(key) => query.where_in(key, values),
1341 Target::Expr(expr) => {
1342 let marks = vec!["?"; values.len()].join(", ");
1343 query.where_raw(&format!("{expr} IN ({marks})"), values)
1344 }
1345 }
1346 }
1347}
1348
1349fn valid_key(key: &str) -> bool {
1350 !key.is_empty()
1351 && key.len() <= 64
1352 && key
1353 .chars()
1354 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1355}
1356
1357#[derive(Debug, Clone, Default, PartialEq)]
1359struct Filter {
1360 text: Option<String>,
1361 mode: Option<String>,
1362 min: Option<f64>,
1363 max: Option<f64>,
1364 from: Option<NaiveDate>,
1365 to: Option<NaiveDate>,
1366 any_of: Vec<String>,
1367}
1368
1369impl Filter {
1370 fn is_empty(&self) -> bool {
1371 *self == Self::default()
1372 }
1373
1374 fn pattern(&self) -> Option<String> {
1376 let text = self.text.as_deref()?.trim();
1377 if text.is_empty() {
1378 return None;
1379 }
1380 if text.contains('%') {
1381 return Some(text.to_owned());
1382 }
1383 Some(match self.mode.as_deref() {
1384 Some("starts") => format!("{text}%"),
1385 Some("ends") => format!("%{text}"),
1386 Some("equals") => text.to_owned(),
1387 _ => format!("%{text}%"),
1388 })
1389 }
1390
1391 fn apply<M: Model>(
1392 &self,
1393 mut query: Query<M>,
1394 column: &Column,
1395 zone: &crate::timezone::Zone,
1396 money: f64,
1397 ) -> Query<M> {
1398 let key = column.key.as_str();
1399 let target = column.target::<M>();
1400 match column.kind {
1401 Kind::Text | Kind::Color => {
1402 if let Some(pattern) = self.pattern() {
1403 query = target.like(query, pattern);
1404 }
1405 }
1406 Kind::Number | Kind::Money => {
1407 for (value, op) in [(self.min, ">="), (self.max, "<=")] {
1408 let Some(value) = value else { continue };
1409 let value = if column.kind == Kind::Money {
1411 smallest_unit(value, money)
1412 } else {
1413 value
1414 };
1415 query = if value.fract() == 0.0 && value.abs() < 9e15 {
1416 target.compare(query, op, value as i64)
1417 } else {
1418 target.compare(query, op, value)
1419 };
1420 }
1421 }
1422 Kind::Date => {
1423 if let Some(from) = self.from {
1424 query = target.compare(query, ">=", from);
1425 }
1426 if let Some(to) = self
1427 .to
1428 .and_then(|d| d.checked_add_signed(Duration::days(1)))
1429 {
1430 query = target.compare(query, "<", to);
1431 }
1432 }
1433 Kind::DateTime => {
1434 let at = |d: NaiveDate| day_start(zone, d);
1435 if let Some(from) = self.from.and_then(at) {
1436 query = target.compare(query, ">=", from);
1437 }
1438 if let Some(to) = self
1439 .to
1440 .and_then(|d| d.checked_add_signed(Duration::days(1)))
1441 .and_then(at)
1442 {
1443 query = target.compare(query, "<", to);
1444 }
1445 }
1446 Kind::Bool => {
1447 let picked: Vec<bool> = self
1448 .any_of
1449 .iter()
1450 .filter_map(|v| match v.as_str() {
1451 "1" | "true" => Some(true),
1452 "0" | "false" => Some(false),
1453 _ => None,
1454 })
1455 .collect();
1456 if !picked.is_empty() {
1457 query = target.among(query, picked);
1458 }
1459 }
1460 Kind::Select => {
1461 let picked: Vec<&String> = self
1462 .any_of
1463 .iter()
1464 .filter(|v| column.options.iter().any(|(o, _)| o == *v))
1465 .collect();
1466 if !picked.is_empty() {
1467 let values: Vec<String> = picked.into_iter().cloned().collect();
1468 let numeric = column.options.iter().all(|(o, _)| o.parse::<i64>().is_ok());
1472 query = match (&target, numeric) {
1473 (Target::Column(key), true) if M::COLUMNS.contains(&key.as_str()) => {
1474 let marks = vec!["?"; values.len()].join(", ");
1475 query
1476 .where_raw(&format!("CAST(\"{key}\" AS TEXT) IN ({marks})"), values)
1477 }
1478 (Target::Expr(expr), true) => {
1479 let marks = vec!["?"; values.len()].join(", ");
1480 query.where_raw(&format!("CAST({expr} AS TEXT) IN ({marks})"), values)
1481 }
1482 _ => target.among(query, values),
1483 };
1484 }
1485 }
1486 Kind::Tags => {
1487 let picked: Vec<String> = self
1488 .any_of
1489 .iter()
1490 .filter(|v| column.options.iter().any(|(o, _)| o == *v))
1491 .map(|v| format!("%{}%", serde_json::to_string(v).unwrap_or_default()))
1492 .collect();
1493 if !picked.is_empty() && M::COLUMNS.contains(&key) {
1494 let sql =
1496 vec![format!("CAST(\"{key}\" AS TEXT) LIKE ?"); picked.len()].join(" OR ");
1497 query = query.where_raw(&sql, picked);
1498 }
1499 }
1500 Kind::Custom | Kind::Image => {}
1501 }
1502 query
1503 }
1504
1505 fn to_value(&self) -> Value {
1506 json!({
1507 "text": self.text,
1508 "mode": self.mode.clone().unwrap_or_else(|| "contains".into()),
1509 "min": self.min,
1510 "max": self.max,
1511 "from": self.from.map(|d| d.to_string()),
1512 "to": self.to.map(|d| d.to_string()),
1513 "any_of": self.any_of,
1514 })
1515 }
1516}
1517
1518#[derive(Debug, Clone)]
1520struct State_ {
1521 filters: BTreeMap<String, Filter>,
1522 search: Option<String>,
1524 rules: Vec<Rule>,
1526 any: bool,
1527 group: Option<String>,
1529 grid_group: Option<String>,
1531 sort: Option<(String, bool)>,
1532 defaulted: bool,
1534 page: u32,
1535 per_page: u32,
1536}
1537
1538impl State_ {
1539 fn parse(grid: &Grid, params: &[(String, String)]) -> Self {
1540 let mut filters: BTreeMap<String, Filter> = BTreeMap::new();
1541 let mut sort = None;
1542 let mut search = None;
1543 let mut group = grid.group.clone();
1544 let mut rules: BTreeMap<u32, Rule> = BTreeMap::new();
1545 let mut any = false;
1546 let mut page = 1;
1547 let mut per_page = grid.per_page;
1548 for (name, value) in params {
1549 let value = value.trim();
1550 match name.as_str() {
1551 "page" => page = value.parse().unwrap_or(1).max(1),
1552 "group" => {
1553 group = grid.groups.iter().find(|g| *g == value).cloned();
1554 }
1555 "match" => any = value == "any",
1556 _ if name.starts_with("r.") => {
1557 let mut parts = name.splitn(3, '.').skip(1);
1559 let (Some(n), Some(field)) = (parts.next(), parts.next()) else {
1560 continue;
1561 };
1562 let Ok(n) = n.parse::<u32>() else { continue };
1563 if n >= 20 {
1564 continue;
1565 }
1566 let rule = rules.entry(n).or_default();
1567 match field {
1568 "c" => rule.column = value.to_owned(),
1569 "o" => rule.op = value.to_owned(),
1570 "v" => rule.value = value.chars().take(200).collect(),
1571 _ => {}
1572 }
1573 }
1574 "search" if !value.is_empty() && grid.columns.iter().any(|c| c.searchable) => {
1575 search = Some(value.chars().take(200).collect());
1576 }
1577 "per_page" => {
1578 if let Ok(n) = value.parse::<u32>()
1579 && grid.per_page_options.contains(&n)
1580 {
1581 per_page = n;
1582 }
1583 }
1584 "sort" => {
1585 let (key, desc) = match value.strip_prefix('-') {
1586 Some(key) => (key, true),
1587 None => (value, false),
1588 };
1589 if grid.find(key).is_some_and(|c| c.sortable) || key == "id" {
1590 sort = Some((key.to_owned(), desc));
1591 }
1592 }
1593 _ => {
1594 let Some((part, key)) = name.split_once('.') else {
1595 continue;
1596 };
1597 let Some(column) = grid.find(key).filter(|c| c.filterable) else {
1598 continue;
1599 };
1600 if value.is_empty() {
1601 continue;
1602 }
1603 let filter = filters.entry(column.key.clone()).or_default();
1604 match part {
1605 "q" => filter.text = Some(value.chars().take(200).collect()),
1606 "m" if ["contains", "starts", "ends", "equals"].contains(&value) => {
1607 filter.mode = Some(value.to_owned())
1608 }
1609 "min" => filter.min = value.parse().ok().filter(|v: &f64| v.is_finite()),
1610 "max" => filter.max = value.parse().ok().filter(|v: &f64| v.is_finite()),
1611 "from" => filter.from = value.parse().ok(),
1612 "to" => filter.to = value.parse().ok(),
1613 "in" if filter.any_of.len() < 100 => filter.any_of.push(value.to_owned()),
1614 _ => {}
1615 }
1616 }
1617 }
1618 }
1619 filters.retain(|_, f| {
1621 !Filter {
1622 mode: None,
1623 ..f.clone()
1624 }
1625 .is_empty()
1626 });
1627 let defaulted = sort.is_none();
1630 if defaulted {
1631 sort = grid.default_sort().into_iter().next();
1632 }
1633 let rules: Vec<Rule> = rules
1634 .into_values()
1635 .filter(|r| {
1636 grid.find(&r.column)
1637 .is_some_and(|c| c.filterable && ops_for(c.kind).contains(&r.op.as_str()))
1638 && (!op_takes_value(&r.op) || !r.value.is_empty())
1639 })
1640 .collect();
1641 Self {
1642 filters,
1643 search,
1644 rules,
1645 any,
1646 grid_group: grid.group.clone(),
1647 group,
1648 sort,
1649 defaulted,
1650 page,
1651 per_page,
1652 }
1653 }
1654
1655 fn query_string(&self) -> Vec<(String, String)> {
1657 let mut out = Vec::new();
1658 if let Some(search) = &self.search {
1659 out.push(("search".into(), search.clone()));
1660 }
1661 if !self.rules.is_empty() {
1662 out.push(("match".into(), if self.any { "any" } else { "all" }.into()));
1663 for (n, rule) in self.rules.iter().enumerate() {
1664 out.push((format!("r.{n}.c"), rule.column.clone()));
1665 out.push((format!("r.{n}.o"), rule.op.clone()));
1666 if op_takes_value(&rule.op) {
1667 out.push((format!("r.{n}.v"), rule.value.clone()));
1668 }
1669 }
1670 }
1671 if self.group != self.grid_group {
1672 out.push(("group".into(), self.group.clone().unwrap_or_default()));
1673 }
1674 for (key, f) in &self.filters {
1675 if let Some(text) = &f.text {
1676 out.push((format!("q.{key}"), text.clone()));
1677 }
1678 if let Some(mode) = &f.mode {
1679 out.push((format!("m.{key}"), mode.clone()));
1680 }
1681 for (name, value) in [("min", f.min), ("max", f.max)] {
1682 if let Some(v) = value {
1683 out.push((format!("{name}.{key}"), v.to_string()));
1684 }
1685 }
1686 for (name, value) in [("from", f.from), ("to", f.to)] {
1687 if let Some(v) = value {
1688 out.push((format!("{name}.{key}"), v.to_string()));
1689 }
1690 }
1691 for v in &f.any_of {
1692 out.push((format!("in.{key}"), v.clone()));
1693 }
1694 }
1695 if let Some((key, desc)) = self.sort.as_ref().filter(|_| !self.defaulted) {
1696 out.push((
1697 "sort".into(),
1698 format!("{}{key}", if *desc { "-" } else { "" }),
1699 ));
1700 }
1701 out.push(("per_page".into(), self.per_page.to_string()));
1702 out
1703 }
1704}
1705
1706fn smallest_unit(units: f64, scale: f64) -> f64 {
1709 (units * scale * 1e6).round() / 1e6
1710}
1711
1712fn day_start(
1716 zone: &crate::timezone::Zone,
1717 day: NaiveDate,
1718) -> Option<chrono::DateTime<chrono::Utc>> {
1719 let midnight = day.and_hms_opt(0, 0, 0)?;
1720 let at = (0..=2).find_map(|h| zone.resolve(midnight + Duration::hours(h)))?;
1721 chrono::DateTime::from_timestamp(at, 0)
1722}
1723
1724#[derive(Debug, Clone, Default, PartialEq)]
1726struct Rule {
1727 column: String,
1728 op: String,
1729 value: String,
1730}
1731
1732fn ops_for(kind: Kind) -> &'static [&'static str] {
1734 match kind {
1735 Kind::Text | Kind::Color | Kind::Tags => &[
1736 "contains",
1737 "not_contains",
1738 "equals",
1739 "not_equals",
1740 "starts",
1741 "ends",
1742 "empty",
1743 "not_empty",
1744 ],
1745 Kind::Number | Kind::Money => &["eq", "ne", "gt", "gte", "lt", "lte", "empty", "not_empty"],
1746 Kind::Date | Kind::DateTime => &["on", "before", "after", "empty", "not_empty"],
1747 Kind::Bool => &["is_true", "is_false"],
1748 Kind::Select => &["is", "is_not", "empty", "not_empty"],
1749 Kind::Custom | Kind::Image => &[],
1750 }
1751}
1752
1753fn op_takes_value(op: &str) -> bool {
1754 !matches!(op, "empty" | "not_empty" | "is_true" | "is_false")
1755}
1756
1757impl Rule {
1758 fn sql(
1761 &self,
1762 target: &str,
1763 kind: Kind,
1764 zone: &crate::timezone::Zone,
1765 money: f64,
1766 ) -> Option<(String, Vec<DbValue>)> {
1767 let text = format!("LOWER(CAST({target} AS TEXT))");
1768 let lower = self.value.to_lowercase();
1769 let like = |pattern: String| (format!("{text} LIKE ?"), vec![pattern.to_db_value()]);
1770 Some(match self.op.as_str() {
1771 "empty" => (
1772 format!("{target} IS NULL OR CAST({target} AS TEXT) = ''"),
1773 vec![],
1774 ),
1775 "not_empty" => (
1776 format!("{target} IS NOT NULL AND CAST({target} AS TEXT) <> ''"),
1777 vec![],
1778 ),
1779 "is_true" => (format!("{target} = ?"), vec![true.to_db_value()]),
1780 "is_false" => (
1781 format!("{target} = ? OR {target} IS NULL"),
1782 vec![false.to_db_value()],
1783 ),
1784 "contains" => like(format!("%{lower}%")),
1785 "not_contains" => (
1786 format!("{target} IS NULL OR {text} NOT LIKE ?"),
1787 vec![format!("%{lower}%").to_db_value()],
1788 ),
1789 "equals" => like(lower),
1790 "not_equals" => (
1791 format!("{target} IS NULL OR {text} <> ?"),
1792 vec![lower.to_db_value()],
1793 ),
1794 "starts" => like(format!("{lower}%")),
1795 "ends" => like(format!("%{lower}")),
1796 "is" => (format!("{target} = ?"), vec![self.value.to_db_value()]),
1797 "is_not" => (
1798 format!("{target} IS NULL OR {target} <> ?"),
1799 vec![self.value.to_db_value()],
1800 ),
1801 "eq" | "ne" | "gt" | "gte" | "lt" | "lte" => {
1802 let n: f64 = self
1803 .value
1804 .trim()
1805 .parse()
1806 .ok()
1807 .filter(|n: &f64| n.is_finite())?;
1808 let n = if kind == Kind::Money {
1809 smallest_unit(n, money)
1810 } else {
1811 n
1812 };
1813 let op = match self.op.as_str() {
1814 "eq" => "=",
1815 "ne" => "<>",
1816 "gt" => ">",
1817 "gte" => ">=",
1818 "lt" => "<",
1819 _ => "<=",
1820 };
1821 let value = if n.fract() == 0.0 && n.abs() < 9e15 {
1822 (n as i64).to_db_value()
1823 } else {
1824 n.to_db_value()
1825 };
1826 (format!("{target} {op} ?"), vec![value])
1827 }
1828 "on" | "before" | "after" => {
1829 let day: NaiveDate = self.value.trim().parse().ok()?;
1830 let next = day.checked_add_signed(Duration::days(1))?;
1831 let bound = |d: NaiveDate| -> DbValue {
1832 if kind == Kind::DateTime {
1833 day_start(zone, d).to_db_value()
1834 } else {
1835 d.to_db_value()
1836 }
1837 };
1838 match self.op.as_str() {
1839 "on" => (
1840 format!("{target} >= ? AND {target} < ?"),
1841 vec![bound(day), bound(next)],
1842 ),
1843 "before" => (format!("{target} < ?"), vec![bound(day)]),
1844 _ => (format!("{target} >= ?"), vec![bound(next)]),
1845 }
1846 }
1847 _ => return None,
1848 })
1849 }
1850}
1851
1852#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1855#[non_exhaustive]
1856pub struct GridPrefs {
1857 #[serde(default)]
1859 pub order: Option<Vec<String>>,
1860 #[serde(default)]
1862 pub left: Option<Vec<String>>,
1863 #[serde(default)]
1865 pub right: Option<Vec<String>>,
1866 #[serde(default)]
1868 pub compact: Option<Vec<String>>,
1869 #[serde(default)]
1871 pub wide: Option<Vec<String>>,
1872 #[serde(default)]
1874 pub widths: Option<BTreeMap<String, u32>>,
1875}
1876
1877impl GridPrefs {
1878 fn is_valid(&self) -> bool {
1879 [
1880 &self.order,
1881 &self.left,
1882 &self.right,
1883 &self.compact,
1884 &self.wide,
1885 ]
1886 .into_iter()
1887 .flatten()
1888 .all(|keys| keys.len() <= 200 && keys.iter().all(|k| valid_key(k)))
1889 && self.widths.as_ref().is_none_or(|widths| {
1890 widths.len() <= 200
1891 && widths
1892 .iter()
1893 .all(|(k, w)| valid_key(k) && (40..=2000).contains(w))
1894 })
1895 }
1896}
1897
1898fn session_key(grid: &str) -> String {
1899 format!("_grid.{grid}")
1900}
1901
1902async fn load_prefs(request: &GridRequest, grid: &str) -> GridPrefs {
1903 match request.user_id {
1904 Some(user_id) => {
1905 let data: Option<String> =
1906 crate::db::sql("SELECT data FROM grid_preferences WHERE user_id = ? AND grid = ?")
1907 .bind(user_id)
1908 .bind(grid)
1909 .scalar_optional(&request.db)
1910 .await
1911 .ok()
1912 .flatten();
1913 data.and_then(|d| serde_json::from_str(&d).ok())
1914 .unwrap_or_default()
1915 }
1916 None => request
1917 .session
1918 .as_ref()
1919 .and_then(|s| s.get(&session_key(grid)))
1920 .unwrap_or_default(),
1921 }
1922}
1923
1924pub async fn save_prefs(db: &Db, user_id: i64, grid: &str, prefs: &GridPrefs) -> Result {
1926 let data = serde_json::to_string(prefs)?;
1927 crate::db::sql(
1928 "INSERT INTO grid_preferences (user_id, grid, data, updated_at) VALUES (?, ?, ?, ?)
1929 ON CONFLICT (user_id, grid) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at",
1930 )
1931 .bind(user_id)
1932 .bind(grid)
1933 .bind(data)
1934 .bind(crate::db::now())
1935 .execute(db)
1936 .await?;
1937 Ok(())
1938}
1939
1940#[derive(Clone)]
1943pub struct GridRequest {
1944 params: Vec<(String, String)>,
1945 path: String,
1946 db: Db,
1947 session: Option<Session>,
1948 user_id: Option<i64>,
1949 lang: Option<crate::Lang>,
1950 zone: crate::timezone::Zone,
1951 money_decimals: u32,
1953}
1954
1955impl GridRequest {
1956 pub fn new(db: &Db, path: &str, params: &[(&str, &str)]) -> Self {
1959 Self {
1960 params: params
1961 .iter()
1962 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1963 .collect(),
1964 path: path.to_owned(),
1965 db: db.clone(),
1966 session: None,
1967 user_id: None,
1968 lang: None,
1969 zone: crate::timezone::Zone::UTC,
1970 money_decimals: crate::view_filters::currency_decimals(
1971 &crate::Config::default().currency,
1972 ),
1973 }
1974 }
1975
1976 pub fn param(&self, name: &str) -> Option<&str> {
1978 self.params
1979 .iter()
1980 .find(|(k, _)| k == name)
1981 .map(|(_, v)| v.as_str())
1982 }
1983}
1984
1985impl<S: Send + Sync> FromRequestParts<S> for GridRequest {
1986 type Rejection = Response;
1987
1988 async fn from_request_parts(
1989 parts: &mut Parts,
1990 state: &S,
1991 ) -> std::result::Result<Self, Response> {
1992 let Some(app) = parts.extensions.get::<AppState>().cloned() else {
1993 return Err(
1994 Error::Internal(anyhow::anyhow!("GridRequest needs Renox's middleware"))
1995 .into_response(),
1996 );
1997 };
1998 let params: Vec<(String, String)> = parts
1999 .uri
2000 .query()
2001 .and_then(|q| serde_html_form::from_str(q).ok())
2002 .unwrap_or_default();
2003 let session = Session::from_request_parts(parts, state).await.ok();
2004 let user = <AuthUser as axum::extract::OptionalFromRequestParts<S>>::from_request_parts(
2005 parts, state,
2006 )
2007 .await
2008 .ok()
2009 .flatten();
2010 let lang = crate::Lang::from_request_parts(parts, state).await.ok();
2011 let path = parts
2014 .extensions
2015 .get::<axum::extract::OriginalUri>()
2016 .map_or_else(|| parts.uri.path(), |original| original.0.path())
2017 .to_owned();
2018 Ok(Self {
2019 params,
2020 path,
2021 db: app.db.clone(),
2022 session,
2023 user_id: user.map(|u| u.id),
2024 lang,
2025 zone: app.config.timezone,
2026 money_decimals: crate::view_filters::currency_decimals(&app.config.currency),
2027 })
2028 }
2029}
2030
2031pub struct GridPage<M> {
2035 grid: Grid,
2036 prefs: GridPrefs,
2037 state: State_,
2038 path: String,
2039 keep: Vec<(String, String)>,
2041 rows: Paginated<M>,
2042 extra: Vec<Map<String, Value>>,
2043 related: Vec<Map<String, Value>>,
2045 summaries: BTreeMap<String, Map<String, Value>>,
2046 group_summaries: BTreeMap<String, Map<String, Value>>,
2047 money_decimals: u32,
2049}
2050
2051impl<M: Serialize> GridPage<M> {
2052 pub fn extend(mut self, mut values: impl FnMut(&M) -> Value) -> Self {
2056 self.extra = self
2057 .rows
2058 .items
2059 .iter()
2060 .map(|item| match values(item) {
2061 Value::Object(map) => map,
2062 _ => Map::new(),
2063 })
2064 .collect();
2065 self
2066 }
2067
2068 pub fn items(&self) -> &[M] {
2070 &self.rows.items
2071 }
2072
2073 pub fn total(&self) -> u64 {
2075 self.rows.total
2076 }
2077
2078 fn to_value(&self) -> Value {
2079 let ordered = self.grid.ordered(&self.prefs);
2080 let compact = self
2081 .prefs
2082 .compact
2083 .clone()
2084 .unwrap_or_else(|| self.grid.default_visible(true));
2085 let wide = self
2086 .prefs
2087 .wide
2088 .clone()
2089 .unwrap_or_else(|| self.grid.default_visible(false));
2090 let sort = self.state.sort.clone();
2091 let grouped = self
2093 .state
2094 .group
2095 .clone()
2096 .filter(|g| self.grid.find(g).is_some());
2097 let dragging = grouped.is_none()
2098 && matches!((&self.grid.reorder, &sort), (Some((column, _)), Some((key, false))) if column == key);
2099 let columns: Vec<Value> = ordered
2100 .iter()
2101 .map(|(c, pin)| {
2102 let filter = self.state.filters.get(&c.key);
2103 json!({
2104 "key": c.key,
2105 "label": c.label,
2106 "kind": c.kind,
2107 "pin": pin,
2108 "sortable": c.sortable,
2109 "filterable": c.filterable,
2110 "decimals": match c.kind {
2111 Kind::Money => Some(c.decimals.map_or(self.money_decimals, u32::from)),
2112 _ => c.decimals.map(u32::from),
2113 },
2114 "scale": match c.kind {
2116 Kind::Money => 10u64.pow(self.money_decimals),
2117 _ => 1,
2118 },
2119 "width": c.width,
2120 "numeric": matches!(c.kind, Kind::Number | Kind::Money),
2121 "options": c.options.iter().map(|(v, l)| json!({"value": v, "label": l})).collect::<Vec<_>>(),
2122 "labels": c.options.iter().map(|(v, l)| (v.clone(), Value::from(l.clone()))).collect::<Map<_, _>>(),
2123 "filter": filter.map(Filter::to_value).unwrap_or_else(|| Filter::default().to_value()),
2124 "filtered": filter.is_some(),
2125 "sort": match &sort {
2126 Some((key, desc)) if *key == c.key => Value::from(if *desc { "desc" } else { "asc" }),
2127 _ => Value::Null,
2128 },
2129 "compact": compact.contains(&c.key),
2130 "wide": wide.contains(&c.key),
2131 "editable": c.editable && self.grid.edit_url.is_some(),
2132 "merge": c.merge && !dragging && grouped.is_none(),
2133 "summaries": c.summaries,
2134 "badges": c.badges.as_ref().map(|b| b.iter().map(|(v, t)| (v.clone(), Value::from(t.clone()))).collect::<Map<_, _>>()),
2135 "icons": c.icons,
2136 "description": c.description,
2137 "tooltip": c.tooltip,
2138 "wrap": c.wrap,
2139 "limit": c.limit,
2140 "link": c.link,
2141 "copyable": c.copyable,
2142 "round": c.round,
2143 })
2144 })
2145 .collect();
2146 let rows: Vec<Value> = self
2147 .rows
2148 .items
2149 .iter()
2150 .enumerate()
2151 .map(|(i, item)| {
2152 let mut row = match serde_json::to_value(item) {
2153 Ok(Value::Object(map)) => map,
2154 _ => Map::new(),
2155 };
2156 if let Some(related) = self.related.get(i) {
2157 row.extend(related.clone());
2158 }
2159 if let Some(extra) = self.extra.get(i) {
2160 row.extend(extra.clone());
2161 }
2162 Value::Object(row)
2163 })
2164 .collect();
2165 let merged: Vec<&str> = if dragging || grouped.is_some() {
2166 Vec::new()
2167 } else {
2168 ordered
2169 .iter()
2170 .filter(|(c, _)| c.merge)
2171 .map(|(c, _)| c.key.as_str())
2172 .collect()
2173 };
2174 let spans = merge_spans(&rows, &merged);
2175 let group_text = |row: &Value| -> Option<String> {
2177 let key = grouped.as_ref()?;
2178 Some(match row.get(key) {
2179 Some(Value::String(s)) => s.clone(),
2180 Some(Value::Null) | None => String::new(),
2181 Some(Value::Bool(b)) => {
2182 if *b {
2183 "1".into()
2184 } else {
2185 "0".into()
2186 }
2187 }
2188 Some(other) => other.to_string(),
2189 })
2190 };
2191 let texts: Vec<Option<String>> = rows.iter().map(group_text).collect();
2192 let mut run_start = vec![0; texts.len()];
2194 for i in 1..texts.len() {
2195 run_start[i] = if texts[i] == texts[i - 1] {
2196 run_start[i - 1]
2197 } else {
2198 i
2199 };
2200 }
2201 let group_figures = |text: &str| -> Option<&Map<String, Value>> {
2202 self.group_summaries.get(text).or_else(|| {
2203 let alt = match text {
2205 "1" => "true",
2206 "0" => "false",
2207 _ => return None,
2208 };
2209 self.group_summaries.get(alt)
2210 })
2211 };
2212 let rows: Vec<Value> = rows
2213 .into_iter()
2214 .zip(spans)
2215 .enumerate()
2216 .map(|(i, (mut row, merge))| {
2217 let text = texts[i].clone();
2218 let starts = text.is_some() && (i == 0 || texts[i - 1] != text);
2219 let ends = text.is_some() && texts.get(i + 1).is_none_or(|next| *next != text);
2220 let group = text.as_ref().map(|t| {
2221 json!({
2222 "id": format!("g{}", run_start[i]),
2223 "value": row.get(grouped.as_deref().unwrap_or_default()).cloned(),
2224 "rows": group_figures(t).and_then(|f| f.get("_rows").cloned()),
2225 "figures": group_figures(t),
2226 "starts": starts,
2227 "ends": ends,
2228 })
2229 });
2230 let id = row.get("id").map(|id| match id {
2231 Value::String(s) => s.clone(),
2232 other => other.to_string(),
2233 });
2234 if let Value::Object(map) = &mut row {
2235 map.insert(
2236 "_rx".into(),
2237 json!({
2238 "id": id,
2239 "spans": merge.spans,
2240 "covered": merge.covered,
2241 "groups": merge.groups,
2242 "edit": self.grid.edit_url.as_ref().zip(id.as_ref()).map(|(url, id)| url.replace("{id}", id)),
2243 "href": self.grid.row_url.as_ref().zip(id.as_ref()).map(|(url, id)| url.replace("{id}", id)),
2244 "actions": self.grid.row_actions.iter().map(|a| a.to_value(id.as_deref())).collect::<Vec<_>>(),
2245 "group": group,
2246 }),
2247 );
2248 }
2249 row
2250 })
2251 .collect();
2252 let editable = self.grid.edit_url.is_some() && self.grid.columns.iter().any(|c| c.editable);
2253 let own: Vec<(String, String)> = self
2255 .state
2256 .query_string()
2257 .into_iter()
2258 .map(|(k, v)| (self.grid.name(&k), v))
2259 .collect();
2260 let query: Vec<(String, String)> = self.keep.iter().cloned().chain(own).collect();
2261 let query = &query;
2262 let config = json!({
2263 "id": self.grid.id,
2264 "prefix": self.grid.prefix.as_ref().map(|p| format!("{p}.")).unwrap_or_default(),
2265 "poll": self.grid.poll,
2266 "prefs": format!("/_renox/grid/{}/prefs", self.grid.id),
2267 "order": ordered.iter().map(|(c, _)| c.key.clone()).collect::<Vec<_>>(),
2268 "left": ordered.iter().filter(|(_, p)| *p == Some(Pin::Left)).map(|(c, _)| c.key.clone()).collect::<Vec<_>>(),
2269 "right": ordered.iter().filter(|(_, p)| *p == Some(Pin::Right)).map(|(c, _)| c.key.clone()).collect::<Vec<_>>(),
2270 "compact": compact,
2271 "wide": wide,
2272 "widths": self.prefs.widths.clone().unwrap_or_default(),
2273 "defaults": {
2274 "compact": self.grid.default_visible(true),
2275 "wide": self.grid.default_visible(false),
2276 },
2277 "columns": ordered.iter().map(|(c, _)| (c.key.clone(), json!({
2278 "kind": c.kind,
2279 "label": c.label,
2280 "options": c.options,
2281 "editable": c.editable && self.grid.edit_url.is_some(),
2282 }))).collect::<Map<_, _>>(),
2283 "reorder": self.grid.reorder.as_ref().filter(|_| dragging).map(|(_, url)| url),
2284 "offset": u64::from(self.rows.page.saturating_sub(1)) * u64::from(self.rows.per_page),
2285 });
2286 json!({
2287 "id": self.grid.id,
2288 "title": self.grid.title,
2289 "path": self.path,
2290 "columns": columns,
2291 "header": header_rows(&ordered),
2292 "rows": rows,
2293 "default_per_page": self.grid.per_page,
2294 "page": {
2295 "page": self.rows.page,
2296 "per_page": self.rows.per_page,
2297 "total": self.rows.total,
2298 "last_page": self.rows.last_page,
2299 "from": self.rows.from,
2300 "to": self.rows.to,
2301 "has_prev": self.rows.has_prev,
2302 "has_next": self.rows.has_next,
2303 "pages": self.rows.pages,
2304 },
2305 "per_page_options": self.grid.per_page_options,
2306 "sort": sort.filter(|_| !self.state.defaulted).map(|(key, desc)| format!("{}{key}", if desc { "-" } else { "" })),
2307 "query": query,
2308 "filtered": self.state.filters.len() + usize::from(!self.state.rules.is_empty()),
2309 "config": config.to_string(),
2310 "audit": self.grid.audit,
2311 "details": self.grid.audit || self.grid.details,
2312 "custom_details": self.grid.details,
2313 "editable": editable,
2314 "reorder": self.grid.reorder.as_ref().map(|(column, _)| json!({
2315 "column": column,
2316 "active": dragging,
2317 })),
2318 "tools": self.grid.audit || self.grid.details || editable || self.grid.reorder.is_some()
2319 || !self.grid.bulk.is_empty() || !self.grid.row_actions.is_empty(),
2320 "bulk": self.grid.bulk.iter().map(|a| a.to_value(None)).collect::<Vec<_>>(),
2321 "row_actions": !self.grid.row_actions.is_empty(),
2322 "cards": self.grid.cards,
2323 "remember": self.grid.remember,
2324 "advanced": {
2325 "on": self.grid.advanced,
2326 "any": self.state.any,
2327 "rules": self.state.rules.iter().map(|r| json!({"column": r.column, "op": r.op, "value": r.value})).collect::<Vec<_>>(),
2328 "columns": ordered.iter().filter(|(c, _)| c.filterable && !ops_for(c.kind).is_empty()).map(|(c, _)| json!({
2329 "key": c.key,
2330 "label": c.label,
2331 "kind": c.kind,
2332 "ops": ops_for(c.kind),
2333 "options": c.options.iter().map(|(v, l)| json!({"value": v, "label": l})).collect::<Vec<_>>(),
2334 })).collect::<Vec<_>>(),
2335 },
2336 "summary": self.summaries.get("").filter(|_| self.grid.columns.iter().any(|c| !c.summaries.is_empty())),
2337 "grouping": (!self.grid.groups.is_empty()).then(|| json!({
2338 "options": self.grid.groups.iter().filter_map(|g| self.grid.find(g)).map(|c| json!({"key": c.key, "label": c.label})).collect::<Vec<_>>(),
2339 "current": grouped,
2340 "column": grouped.as_ref().and_then(|g| ordered.iter().position(|(c, _)| c.key == *g)),
2341 })),
2342 "exports": self.grid.exports.then(|| export::urls(&self.path, query, &self.grid.name("per_page"), &self.grid.name("export"))),
2343 "p": self.grid.prefix.as_ref().map(|p| format!("{p}.")).unwrap_or_default(),
2345 "keep": self.keep,
2346 "search": {
2347 "on": self.grid.columns.iter().any(|c| c.searchable),
2348 "value": self.state.search,
2349 },
2350 "empty": self.grid.empty.as_ref().map(|(heading, description)| json!({
2351 "heading": heading,
2352 "description": description,
2353 })),
2354 "row_links": self.grid.row_url.is_some(),
2355 })
2356 }
2357}
2358
2359impl<M: Serialize> Serialize for GridPage<M> {
2360 fn serialize<S: serde::Serializer>(
2361 &self,
2362 serializer: S,
2363 ) -> std::result::Result<S::Ok, S::Error> {
2364 self.to_value().serialize(serializer)
2365 }
2366}
2367
2368#[derive(Debug, Clone)]
2371#[non_exhaustive]
2372pub struct Action {
2373 label: String,
2374 url: String,
2375 method: String,
2376 confirm: Option<String>,
2377 danger: bool,
2378 link: bool,
2379 sheet: Option<String>,
2380}
2381
2382impl Action {
2383 pub fn new(label: &str, url: &str) -> Self {
2386 Self {
2387 label: label.to_owned(),
2388 url: url.to_owned(),
2389 method: "POST".into(),
2390 confirm: None,
2391 danger: false,
2392 link: false,
2393 sheet: None,
2394 }
2395 }
2396
2397 pub fn link(label: &str, url: &str) -> Self {
2399 Self {
2400 link: true,
2401 ..Self::new(label, url)
2402 }
2403 }
2404
2405 pub fn method(mut self, method: &str) -> Self {
2408 self.method = method.to_ascii_uppercase();
2409 self
2410 }
2411
2412 pub fn confirm(mut self, question: &str) -> Self {
2414 self.confirm = Some(question.to_owned());
2415 self
2416 }
2417
2418 pub fn danger(mut self) -> Self {
2420 self.danger = true;
2421 self
2422 }
2423
2424 pub fn sheet(mut self, id: &str) -> Self {
2430 self.sheet = Some(id.to_owned());
2431 self
2432 }
2433
2434 fn to_value(&self, id: Option<&str>) -> Value {
2435 json!({
2436 "sheet": self.sheet,
2437 "label": self.label,
2438 "url": id.map_or_else(|| self.url.clone(), |id| self.url.replace("{id}", id)),
2439 "method": self.method,
2440 "confirm": self.confirm,
2441 "danger": self.danger,
2442 "link": self.link,
2443 })
2444 }
2445}
2446
2447#[derive(Debug, Clone, Default, Deserialize)]
2451#[non_exhaustive]
2452pub struct Selection {
2453 #[serde(default, deserialize_with = "comma_list")]
2455 pub ids: Vec<String>,
2456 #[serde(default)]
2458 pub all: bool,
2459}
2460
2461#[derive(Debug, Default, Clone, PartialEq)]
2463struct MergeInfo {
2464 spans: Map<String, Value>,
2466 covered: Vec<String>,
2468 groups: Map<String, Value>,
2470}
2471
2472fn merge_spans(rows: &[Value], merged: &[&str]) -> Vec<MergeInfo> {
2475 let mut info = vec![MergeInfo::default(); rows.len()];
2476 let mut starts: Vec<Vec<usize>> = vec![vec![0; merged.len()]; rows.len()];
2478 for i in 0..rows.len() {
2479 let mut outer_break = i == 0;
2480 for (k, key) in merged.iter().enumerate() {
2481 let same = !outer_break && rows[i].get(*key) == rows[i - 1].get(*key);
2482 if same {
2483 starts[i][k] = starts[i - 1][k];
2484 } else {
2485 starts[i][k] = i;
2486 outer_break = true;
2487 }
2488 }
2489 }
2490 for (k, key) in merged.iter().enumerate() {
2491 for i in 0..rows.len() {
2492 let start = starts[i][k];
2493 info[i]
2494 .groups
2495 .insert((*key).to_owned(), format!("{key}-{start}").into());
2496 if start == i {
2497 let len = (i..rows.len()).take_while(|&j| starts[j][k] == i).count();
2498 info[i].spans.insert((*key).to_owned(), len.into());
2499 } else {
2500 info[i].covered.push((*key).to_owned());
2501 }
2502 }
2503 }
2504 info
2505}
2506
2507#[derive(Debug, Clone, Default, Deserialize)]
2521#[non_exhaustive]
2522pub struct RowOrder {
2523 #[serde(default, deserialize_with = "comma_list")]
2525 pub ids: Vec<String>,
2526 #[serde(default)]
2528 pub offset: u64,
2529}
2530
2531fn comma_list<'de, D: serde::Deserializer<'de>>(
2532 d: D,
2533) -> std::result::Result<Vec<String>, D::Error> {
2534 let text = String::deserialize(d)?;
2535 Ok(text
2536 .split(',')
2537 .map(str::trim)
2538 .filter(|id| !id.is_empty())
2539 .map(str::to_owned)
2540 .collect())
2541}
2542
2543impl RowOrder {
2544 pub async fn save<M: Model>(&self, db: &Db, column: &str) -> Result<u64> {
2548 if !M::COLUMNS.contains(&column) || column == "id" {
2549 return Err(Error::Internal(anyhow::anyhow!(
2550 "`{}` has no column `{column}` to order by",
2551 M::TABLE
2552 )));
2553 }
2554 if self.ids.len() > 1000 {
2555 return Err(Error::BadRequest("too many rows".into()));
2556 }
2557 let mut keys = Vec::with_capacity(self.ids.len());
2558 for id in &self.ids {
2559 let key: M::Key = id
2560 .parse()
2561 .map_err(|_| Error::BadRequest(format!("`{id}` is not an id")))?;
2562 keys.push(key);
2563 }
2564 let sql = format!(
2565 "UPDATE \"{}\" SET \"{column}\" = ? WHERE \"id\" = ?",
2566 M::TABLE
2567 );
2568 let mut tx = db.begin().await?;
2569 let mut changed = 0;
2570 for (n, key) in keys.into_iter().enumerate() {
2571 let position = i64::try_from(self.offset + n as u64).unwrap_or(i64::MAX);
2572 changed += crate::db::sql(&sql)
2573 .bind(position)
2574 .bind(key)
2575 .execute(&mut tx)
2576 .await?;
2577 }
2578 tx.commit().await?;
2579 Ok(changed)
2580 }
2581}
2582
2583fn header_rows(columns: &[(&Column, Option<Pin>)]) -> Vec<Vec<Value>> {
2587 let depth = columns
2588 .iter()
2589 .map(|(c, _)| c.under.len() + 1)
2590 .max()
2591 .unwrap_or(1);
2592 let mut rows: Vec<Vec<Value>> = vec![Vec::new(); depth];
2593 for (level, row) in rows.iter_mut().enumerate() {
2594 let mut previous: Option<(Vec<String>, Option<Pin>)> = None;
2595 for (index, (column, pin)) in columns.iter().enumerate() {
2596 if level < column.under.len() {
2597 let path = column.under[..=level].to_vec();
2598 let same = previous
2599 .as_ref()
2600 .is_some_and(|(p, side)| *p == path && side == pin);
2601 if same && let Some(Value::Object(cell)) = row.last_mut() {
2602 let span = cell["colspan"].as_u64().unwrap_or(1) + 1;
2603 cell.insert("colspan".into(), span.into());
2604 if let Some(Value::Array(keys)) = cell.get_mut("keys") {
2605 keys.push(column.key.clone().into());
2606 }
2607 } else {
2608 row.push(json!({
2609 "group": true,
2610 "label": column.under[level],
2611 "colspan": 1,
2612 "rowspan": 1,
2613 "keys": [column.key],
2614 "pin": pin,
2615 }));
2616 }
2617 previous = Some((path, *pin));
2618 } else {
2619 if level == column.under.len() {
2620 row.push(json!({
2621 "group": false,
2622 "key": column.key,
2623 "index": index,
2624 "colspan": 1,
2625 "rowspan": depth - level,
2626 "keys": [column.key],
2627 "pin": pin,
2628 }));
2629 }
2630 previous = None;
2631 }
2632 }
2633 }
2634 rows
2635}
2636
2637pub(crate) fn router() -> Router<AppState> {
2640 Router::new().route(
2641 "/_renox/grid/{grid}/prefs",
2642 post(save_route).delete(reset_route),
2643 )
2644}
2645
2646async fn save_route(
2647 State(state): State<AppState>,
2648 axum::extract::Path(grid): axum::extract::Path<String>,
2649 session: Session,
2650 user: Option<AuthUser>,
2651 axum::Json(prefs): axum::Json<GridPrefs>,
2652) -> Result<Response> {
2653 if !valid_key(&grid) || !prefs.is_valid() {
2654 return Ok(StatusCode::UNPROCESSABLE_ENTITY.into_response());
2655 }
2656 match user {
2657 Some(user) => save_prefs(&state.db, user.id, &grid, &prefs).await?,
2658 None => session.put(&session_key(&grid), &prefs)?,
2659 }
2660 Ok(StatusCode::NO_CONTENT.into_response())
2661}
2662
2663async fn reset_route(
2664 State(state): State<AppState>,
2665 axum::extract::Path(grid): axum::extract::Path<String>,
2666 session: Session,
2667 user: Option<AuthUser>,
2668) -> Result<Response> {
2669 match user {
2670 Some(user) => {
2671 crate::db::sql("DELETE FROM grid_preferences WHERE user_id = ? AND grid = ?")
2672 .bind(user.id)
2673 .bind(&grid)
2674 .execute(&state.db)
2675 .await?;
2676 }
2677 None => {
2678 session.remove(&session_key(&grid));
2679 }
2680 }
2681 Ok(StatusCode::NO_CONTENT.into_response())
2682}
2683
2684#[cfg(test)]
2685mod tests {
2686 use super::*;
2687
2688 fn grid() -> Grid {
2689 Grid::new("orders")
2690 .column(Column::text("number", "Order").frozen())
2691 .column(Column::text("name", "Name").under(["Customer"]))
2692 .column(Column::text("email", "Email").under(["Customer"]).hidden())
2693 .column(Column::money("total", "Total").under(["Amounts", "Sum"]))
2694 .column(Column::number("tax", "Tax").under(["Amounts", "Sum"]))
2695 .column(Column::select("status", "Status", [("new", "New"), ("paid", "Paid")]).mobile())
2696 .column(Column::custom("trend", "Trend"))
2697 .column(Column::custom("actions", "").frozen_right())
2698 }
2699
2700 fn params(list: &[(&str, &str)]) -> Vec<(String, String)> {
2701 list.iter()
2702 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
2703 .collect()
2704 }
2705
2706 #[test]
2707 fn the_query_string_is_checked_against_the_grid() {
2708 let grid = grid().sort_by("-total");
2709 let state = State_::parse(
2710 &grid,
2711 ¶ms(&[
2712 ("q.name", "coffee"),
2713 ("m.name", "starts"),
2714 ("min.total", "1000"),
2715 ("max.total", "x"),
2716 ("in.status", "paid"),
2717 ("q.trend", "custom columns don't filter"),
2718 ("q.missing", "nor unknown ones"),
2719 ("m.email", "starts"),
2720 ("page", "3"),
2721 ("per_page", "1000"),
2722 ("sort", "trend"),
2723 ]),
2724 );
2725 assert_eq!(state.filters.len(), 3, "{:?}", state.filters);
2726 assert_eq!(state.filters["name"].pattern().as_deref(), Some("coffee%"));
2727 assert_eq!(state.filters["total"].min, Some(1000.0));
2728 assert_eq!(state.filters["total"].max, None);
2729 assert_eq!(state.page, 3);
2730 assert_eq!(state.per_page, 25, "only the offered sizes");
2731 assert_eq!(state.sort, Some(("total".into(), true)), "the default");
2732
2733 let sorted = State_::parse(&grid, ¶ms(&[("sort", "name"), ("per_page", "50")]));
2734 assert_eq!(sorted.sort, Some(("name".into(), false)));
2735 assert_eq!(sorted.per_page, 50);
2736 assert!(
2737 sorted
2738 .query_string()
2739 .contains(&("sort".into(), "name".into()))
2740 );
2741 }
2742
2743 #[test]
2744 fn text_filters_make_like_patterns() {
2745 let pattern = |text: &str, mode: Option<&str>| {
2746 Filter {
2747 text: Some(text.into()),
2748 mode: mode.map(Into::into),
2749 ..Default::default()
2750 }
2751 .pattern()
2752 };
2753 assert_eq!(pattern("coffee", None).as_deref(), Some("%coffee%"));
2754 assert_eq!(pattern("coffee", Some("ends")).as_deref(), Some("%coffee"));
2755 assert_eq!(pattern("coffee", Some("equals")).as_deref(), Some("coffee"));
2756 assert_eq!(
2757 pattern("ko%pi", Some("equals")).as_deref(),
2758 Some("ko%pi"),
2759 "% wins"
2760 );
2761 assert_eq!(pattern(" ", None), None);
2762 }
2763
2764 #[test]
2765 fn headings_group_and_span() {
2766 let grid = grid();
2767 let ordered = grid.ordered(&GridPrefs::default());
2768 let keys: Vec<&str> = ordered.iter().map(|(c, _)| c.key.as_str()).collect();
2769 assert_eq!(
2770 keys,
2771 [
2772 "number", "name", "email", "total", "tax", "status", "trend", "actions"
2773 ]
2774 );
2775 let rows = header_rows(&ordered);
2776 assert_eq!(rows.len(), 3);
2777 let labels: Vec<(String, u64, u64)> = rows[0]
2778 .iter()
2779 .map(|c| {
2780 (
2781 c["label"]
2782 .as_str()
2783 .or(c["key"].as_str())
2784 .unwrap()
2785 .to_owned(),
2786 c["colspan"].as_u64().unwrap(),
2787 c["rowspan"].as_u64().unwrap(),
2788 )
2789 })
2790 .collect();
2791 assert_eq!(
2792 labels,
2793 [
2794 ("number".into(), 1, 3),
2795 ("Customer".into(), 2, 1),
2796 ("Amounts".into(), 2, 1),
2797 ("status".into(), 1, 3),
2798 ("trend".into(), 1, 3),
2799 ("actions".into(), 1, 3),
2800 ]
2801 );
2802 let second: Vec<&str> = rows[1]
2804 .iter()
2805 .map(|c| c["label"].as_str().or(c["key"].as_str()).unwrap())
2806 .collect();
2807 assert_eq!(second, ["name", "email", "Sum"]);
2808 assert_eq!(rows[1][0]["rowspan"], 2);
2809 let third: Vec<&str> = rows[2].iter().map(|c| c["key"].as_str().unwrap()).collect();
2810 assert_eq!(third, ["total", "tax"]);
2811 }
2812
2813 #[test]
2814 fn preferences_reorder_and_freeze() {
2815 let grid = grid();
2816 let prefs = GridPrefs {
2817 order: Some(vec!["status".into(), "total".into(), "number".into()]),
2818 left: Some(vec!["total".into()]),
2819 right: Some(vec![]),
2820 ..Default::default()
2821 };
2822 let ordered = grid.ordered(&prefs);
2823 let keys: Vec<(&str, Option<Pin>)> =
2824 ordered.iter().map(|(c, p)| (c.key.as_str(), *p)).collect();
2825 assert_eq!(keys[0], ("total", Some(Pin::Left)));
2826 assert_eq!(keys[1], ("status", None));
2827 assert_eq!(keys[2], ("number", None), "unfrozen by the user");
2828 assert!(keys.iter().all(|(_, p)| *p != Some(Pin::Right)));
2829 let rows = header_rows(&ordered);
2831 let amounts = rows[0].iter().filter(|c| c["label"] == "Amounts").count();
2832 assert_eq!(amounts, 2);
2833 }
2834
2835 #[test]
2836 fn small_screens_start_with_a_few_columns() {
2837 let grid = grid();
2838 assert_eq!(grid.default_visible(true), ["status"]);
2839 assert!(!grid.default_visible(false).contains(&"email".to_string()));
2840 let plain = Grid::new("x")
2841 .column(Column::text("a", "A"))
2842 .column(Column::text("b", "B").hidden())
2843 .column(Column::text("c", "C"))
2844 .column(Column::text("d", "D"))
2845 .column(Column::text("e", "E"));
2846 assert_eq!(plain.default_visible(true), ["a", "c", "d"]);
2847 }
2848
2849 #[test]
2850 fn merged_columns_nest() {
2851 let rows: Vec<Value> = [
2852 ("java", "Bandung"),
2853 ("java", "Bandung"),
2854 ("java", "Jakarta"),
2855 ("bali", "Jakarta"),
2856 ("bali", "Denpasar"),
2857 ("java", "Denpasar"),
2858 ]
2859 .iter()
2860 .map(|(r, c)| json!({ "region": r, "city": c }))
2861 .collect();
2862 let info = merge_spans(&rows, &["region", "city"]);
2863 let spans = |i: usize| {
2864 (
2865 info[i].spans.get("region").cloned(),
2866 info[i].spans.get("city").cloned(),
2867 )
2868 };
2869 assert_eq!(spans(0), (Some(json!(3)), Some(json!(2))));
2870 assert_eq!(info[1].covered, ["region", "city"]);
2871 assert_eq!(spans(2), (None, Some(json!(1))));
2872 assert_eq!(info[2].covered, ["region"]);
2873 assert_eq!(spans(3), (Some(json!(2)), Some(json!(1))));
2875 assert_eq!(spans(4), (None, Some(json!(1))));
2876 assert_eq!(spans(5), (Some(json!(1)), Some(json!(1))));
2877 assert_eq!(info[1].groups["city"], "city-0");
2878 assert!(merge_spans(&rows, &[]).iter().all(|m| m.spans.is_empty()));
2879 }
2880
2881 #[test]
2882 fn preferences_are_checked() {
2883 assert!(GridPrefs::default().is_valid());
2884 let widths = |w: u32| GridPrefs {
2885 widths: Some(BTreeMap::from([("total".to_owned(), w)])),
2886 ..Default::default()
2887 };
2888 assert!(widths(180).is_valid());
2889 assert!(!widths(10).is_valid() && !widths(5000).is_valid());
2890 let bad = GridPrefs {
2891 order: Some(vec!["a; DROP".into()]),
2892 ..Default::default()
2893 };
2894 assert!(!bad.is_valid());
2895 assert!(valid_key("orders-2026"));
2896 assert!(!valid_key(""));
2897 }
2898}