1use std::{marker::PhantomData, num::NonZeroUsize, sync::Arc};
7
8use toasty::stmt::{Expr, List, OrderByExpr};
9
10use self::{column::BoxColumn, filter::BoxFilter};
11use crate::{DeclarationErrorKind, Lens, form::FormScalar, schema::ResolvedLens};
12
13mod column;
14mod export;
15mod filter;
16mod page;
17mod render;
18mod state;
19
20pub use self::{
21 column::{
22 BooleanColumn, Column, ColumnWidth, ComputedColumn, Includes, IntoColumns, TextColumn,
23 contains_expr,
24 },
25 filter::{
26 DateFilter, Filter, FilterInput, IntoFilters, QueryFilter, SelectFilter, TernaryFilter,
27 },
28 page::TablePage,
29 state::{Cursor, Sort, TableState},
30};
31pub(crate) use self::{
32 page::{Past, row_exists_past},
33 render::TABLE_CARD_CLASS,
34 state::{
35 ACTION_ROUTE_PARAM, ACTIONS_ROUTE_SEGMENT, BULK_DELETE_ROUTE_SEGMENT, CREATE_ROUTE_SEGMENT,
36 DASH_ROUTE_SEGMENT, DELETE_ROUTE_SEGMENT, EDIT_ROUTE_SEGMENT, RECORD_ROUTE_PARAM,
37 RETURN_PARAM, TableSignals, create_page_url, query_of, request_query, with_return,
38 },
39};
40
41pub(crate) type GroupKey<M> = Arc<dyn Fn(&M) -> String + Send + Sync>;
43
44pub(crate) type RowPolicy<M> = Arc<dyn Fn(&M) -> RowActions + Send + Sync>;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub(crate) struct RowActions {
52 pub view: bool,
54 pub edit: bool,
56 pub delete: bool,
58}
59
60impl RowActions {
61 pub const ALL: Self = Self {
63 view: true,
64 edit: true,
65 delete: true,
66 };
67}
68
69pub(crate) struct GroupDef<M> {
71 name: String,
72 key: GroupKey<M>,
73}
74
75impl<M> Clone for GroupDef<M> {
76 fn clone(&self) -> Self {
77 Self {
78 name: self.name.clone(),
79 key: Arc::clone(&self.key),
80 }
81 }
82}
83
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
86pub(crate) struct TableChrome {
87 pub(crate) delete: bool,
89 pub(crate) edit: bool,
91 pub(crate) view: bool,
93 pub(crate) actions: bool,
95}
96
97pub(crate) struct TableAction<M> {
99 pub(crate) name: &'static str,
100 pub(crate) label: String,
101 pub(crate) row: bool,
102 pub(crate) bulk: bool,
103 pub(crate) confirm: bool,
104 pub(crate) allowed: Arc<dyn Fn(&M) -> bool + Send + Sync>,
105}
106
107impl<M> Clone for TableAction<M> {
108 fn clone(&self) -> Self {
109 Self {
110 name: self.name,
111 label: self.label.clone(),
112 row: self.row,
113 bulk: self.bulk,
114 confirm: self.confirm,
115 allowed: Arc::clone(&self.allowed),
116 }
117 }
118}
119
120pub const DEFAULT_PAGE_SIZE: NonZeroUsize = NonZeroUsize::new(25).unwrap();
122
123pub struct Table<M> {
125 columns: Vec<BoxColumn<M>>,
126 misdeclared: Vec<DeclarationErrorKind>,
128 filters: Vec<BoxFilter<M>>,
129 group_by: Option<GroupDef<M>>,
130 key: fn(&M) -> String,
132 addressable: bool,
135 row_policy: Option<RowPolicy<M>>,
136 page_size: NonZeroUsize,
137 hide_search: bool,
138 hide_filter_bar: bool,
139 delete_prefix: Option<String>,
140 edit_prefix: Option<String>,
141 view_prefix: Option<String>,
142 bulk_delete: bool,
143 custom_actions: Vec<TableAction<M>>,
145 actions_prefix: Option<String>,
146 live_search: bool,
147 framed: bool,
149 return_to: Option<String>,
151 _marker: PhantomData<M>,
152}
153
154impl<M> Clone for Table<M> {
156 fn clone(&self) -> Self {
157 Self {
158 columns: self.columns.clone(),
159 misdeclared: self.misdeclared.clone(),
160 filters: self.filters.clone(),
161 group_by: self.group_by.clone(),
162 key: self.key,
163 addressable: self.addressable,
164 row_policy: self.row_policy.clone(),
165 page_size: self.page_size,
166 hide_search: self.hide_search,
167 hide_filter_bar: self.hide_filter_bar,
168 delete_prefix: self.delete_prefix.clone(),
169 edit_prefix: self.edit_prefix.clone(),
170 view_prefix: self.view_prefix.clone(),
171 bulk_delete: self.bulk_delete,
172 custom_actions: self.custom_actions.clone(),
173 actions_prefix: self.actions_prefix.clone(),
174 live_search: self.live_search,
175 framed: self.framed,
176 return_to: self.return_to.clone(),
177 _marker: PhantomData,
178 }
179 }
180}
181
182impl<M> std::fmt::Debug for Table<M> {
183 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184 f.debug_struct("Table")
185 .field(
186 "columns",
187 &self.columns.iter().map(|c| c.name()).collect::<Vec<_>>(),
188 )
189 .field("filters", &self.filters.len())
190 .field("group_by", &self.group_by.is_some())
191 .field("row_policy", &self.row_policy.is_some())
192 .field("page_size", &self.page_size)
193 .field("hide_search", &self.hide_search)
194 .field("hide_filter_bar", &self.hide_filter_bar)
195 .field("delete_prefix", &self.delete_prefix)
196 .field("edit_prefix", &self.edit_prefix)
197 .field("view_prefix", &self.view_prefix)
198 .field("bulk_delete", &self.bulk_delete)
199 .field(
200 "custom_actions",
201 &self
202 .custom_actions
203 .iter()
204 .map(|a| a.name)
205 .collect::<Vec<_>>(),
206 )
207 .field("live_search", &self.live_search)
208 .field("framed", &self.framed)
209 .field("return_to", &self.return_to)
210 .finish()
211 }
212}
213
214impl<M> Table<M> {
215 pub fn new(cols: impl IntoColumns<M>) -> Self
219 where
220 M: toasty::schema::Model + toasty::stmt::IntoExpr<M>,
221 {
222 Self {
223 columns: cols.into_columns(),
224 misdeclared: Vec::new(),
225 filters: Vec::new(),
226 group_by: None,
227 key: crate::toasty_compat::pk::pk_text::<M>,
228 addressable: !crate::toasty_compat::pk::pk_is_composite::<M>(),
229 row_policy: None,
230 page_size: DEFAULT_PAGE_SIZE,
231 hide_search: false,
232 hide_filter_bar: false,
233 delete_prefix: None,
234 edit_prefix: None,
235 view_prefix: None,
236 bulk_delete: false,
237 custom_actions: Vec::new(),
238 actions_prefix: None,
239 live_search: false,
240 framed: true,
241 return_to: None,
242 _marker: PhantomData,
243 }
244 }
245
246 pub fn column(mut self, column: impl Column<M> + 'static) -> Self {
248 self.columns.push(Arc::new(column));
249 self
250 }
251
252 pub(crate) fn row_actions(
254 mut self,
255 policy: impl Fn(&M) -> RowActions + Send + Sync + 'static,
256 ) -> Self {
257 self.row_policy = Some(Arc::new(policy));
258 self
259 }
260
261 pub fn filters(mut self, filters: impl IntoFilters<M>) -> Self
263 where
264 M: toasty::schema::Model,
265 {
266 let filters = filters.into_filters();
267 self.filters = filters;
268 self
269 }
270
271 pub(crate) fn include_relations(
273 &self,
274 mut query: toasty::stmt::Query<List<M>>,
275 ) -> toasty::stmt::Query<List<M>>
276 where
277 M: toasty::schema::Model,
278 {
279 let mut seen: Vec<toasty_core::stmt::Include> = Vec::new();
280 for include in self.columns.iter().flat_map(|c| c.includes().into_vec()) {
281 if !seen.contains(&include) {
282 query = query.include(include.clone());
283 seen.push(include);
284 }
285 }
286 query
287 }
288
289 pub(crate) fn filter_expr(&self, state: &TableState) -> Option<Expr<bool>>
291 where
292 M: toasty::schema::Model,
293 {
294 let mut exprs = Vec::new();
295 for f in &self.filters {
296 if let Some(v) = state.filters.get(f.name())
297 && let Some(e) = f.to_expr(v)
298 {
299 exprs.push(e);
300 }
301 }
302 if exprs.is_empty() {
303 None
304 } else {
305 let mut iter = exprs.into_iter();
306 let first = iter.next().unwrap();
307 Some(iter.fold(first, |acc, e| acc.and(e)))
308 }
309 }
310
311 pub(crate) fn unapplied_filters(&self, state: &TableState) -> Vec<(String, String)>
313 where
314 M: toasty::schema::Model,
315 {
316 let mut out = Vec::new();
317 for (key, value) in &state.filters {
318 match self.filters.iter().find(|f| f.name() == key) {
319 None => out.push((format!("{key}:{value}"), "unknown filter".to_string())),
320 Some(f) if f.to_expr(value).is_none() && !f.is_noop_value(value) => {
321 out.push((format!("{key}:{value}"), "invalid value".to_string()))
322 }
323 Some(_) => {}
324 }
325 }
326 if state.filters_dropped {
327 out.push((
328 "dropped filters".to_string(),
329 format!(
330 "more than {}, over {} bytes, or the retired filters= form",
331 state::MAX_FILTERS,
332 state::MAX_FILTER_LEN
333 ),
334 ));
335 }
336 out.sort();
337 out
338 }
339
340 pub fn group_by<T>(mut self, lens: Lens<M, T>) -> Self
342 where
343 M: toasty::schema::Model + Send + Sync + 'static,
344 T: FormScalar + Send + Sync + 'static,
345 {
346 let binding = ResolvedLens::of(lens.path().clone());
347 self.misdeclared.extend(binding.misdeclared);
348 self.group_by = Some(GroupDef {
349 name: binding.name,
350 key: Arc::new(move |record| lens.read(record).to_form()),
351 });
352 self
353 }
354
355 fn effective_group_key(&self, state: &TableState) -> Option<GroupKey<M>> {
357 match (&self.group_by, &state.group_by) {
358 (Some(def), Some(want)) if def.name == *want => Some(def.key.clone()),
359 _ => None,
360 }
361 }
362
363 pub(crate) fn normalize_state(&self, state: &TableState) -> TableState {
365 let mut out = state.clone();
366 if self.group_by.as_ref().map(|def| def.name.as_str()) != out.group_by.as_deref() {
367 out.group_by = None;
368 }
369 out
370 }
371
372 pub fn paginate(mut self, per_page: usize) -> Self {
374 match NonZeroUsize::new(per_page) {
375 Some(size) => self.page_size = size,
376 None => self.misdeclared.push(DeclarationErrorKind::ZeroPageSize),
377 }
378 self
379 }
380
381 pub fn declaration_errors(&self) -> Vec<DeclarationErrorKind> {
383 let mut errors = self.misdeclared.clone();
384 if self.columns.is_empty() {
385 errors.push(DeclarationErrorKind::NoColumns);
386 }
387 let mut seen = std::collections::HashSet::with_capacity(self.columns.len());
388 for column in &self.columns {
389 match column.misdeclared() {
390 Some(error) => errors.push(error),
391 None if !seen.insert(column.name()) => {
392 errors.push(DeclarationErrorKind::DuplicateColumn {
393 name: column.name().to_string(),
394 });
395 }
396 None => {}
397 }
398 }
399 let mut seen = std::collections::HashSet::with_capacity(self.filters.len());
400 for filter in &self.filters {
401 match filter.misdeclared() {
402 Some(error) => errors.push(error),
403 None if !seen.insert(filter.name()) => {
404 errors.push(DeclarationErrorKind::DuplicateFilter {
405 name: filter.name().to_string(),
406 });
407 }
408 None => {}
409 }
410 }
411 errors
412 }
413
414 pub fn page_size(&self) -> usize {
416 self.page_size.get()
417 }
418
419 pub(crate) fn key_of(&self, record: &M) -> String {
421 (self.key)(record)
422 }
423
424 pub(crate) fn actions_for(&self, record: &M) -> RowActions {
426 self.row_policy
427 .as_ref()
428 .map_or(RowActions::ALL, |policy| policy(record))
429 }
430
431 pub fn hide_search(mut self) -> Self {
433 self.hide_search = true;
434 self
435 }
436
437 pub fn hide_filter_bar(mut self) -> Self {
439 self.hide_filter_bar = true;
440 self
441 }
442
443 pub(crate) fn unframed(mut self) -> Self {
445 self.framed = false;
446 self
447 }
448
449 pub fn live_search(mut self) -> Self {
451 self.live_search = true;
452 self
453 }
454
455 pub(crate) fn with_delete(mut self, prefix: String) -> Self {
457 if self.addressable {
458 self.delete_prefix = Some(prefix);
459 }
460 self
461 }
462
463 pub(crate) fn with_edit(mut self, prefix: String) -> Self {
465 if self.addressable {
466 self.edit_prefix = Some(prefix);
467 }
468 self
469 }
470
471 pub(crate) fn with_view(mut self, prefix: String) -> Self {
473 if self.addressable {
474 self.view_prefix = Some(prefix);
475 }
476 self
477 }
478
479 pub(crate) fn with_bulk_delete(mut self, enabled: bool) -> Self {
481 self.bulk_delete = enabled;
482 self
483 }
484
485 pub(crate) fn returning_to(mut self, url: String) -> Self {
487 self.return_to = Some(url);
488 self
489 }
490
491 fn action_url(&self, url: String) -> String {
492 match &self.return_to {
493 Some(target) => with_return(&url, target),
494 None => url,
495 }
496 }
497
498 pub(crate) fn with_custom_actions(
500 mut self,
501 prefix: String,
502 actions: Vec<TableAction<M>>,
503 ) -> Self {
504 if self.addressable {
505 self.actions_prefix = Some(prefix);
506 self.custom_actions = actions;
507 }
508 self
509 }
510
511 fn row_custom_actions(&self) -> impl Iterator<Item = &TableAction<M>> {
512 self.custom_actions
513 .iter()
514 .filter(|a| a.row && self.actions_prefix.is_some())
515 }
516
517 fn bulk_custom_actions(&self) -> impl Iterator<Item = &TableAction<M>> {
518 self.custom_actions
519 .iter()
520 .filter(|a| a.bulk && self.actions_prefix.is_some())
521 }
522
523 fn bulk_delete_enabled(&self) -> bool {
524 self.bulk_delete && self.delete_prefix.is_some()
525 }
526
527 fn bulk_enabled(&self) -> bool {
528 self.bulk_delete_enabled() || self.bulk_custom_actions().next().is_some()
529 }
530
531 pub(crate) fn search_expr(&self, term: &str) -> Option<Expr<bool>>
533 where
534 M: toasty::schema::Model,
535 {
536 let t = term.trim();
537 if t.is_empty() {
538 return None;
539 }
540 let mut exprs = self.columns.iter().filter_map(|c| c.search_expr(t));
541 let first = exprs.next()?;
542 Some(exprs.fold(first, |acc, e| acc.or(e)))
543 }
544
545 pub(crate) fn order_by(&self, descending: bool) -> Option<OrderByExpr>
547 where
548 M: toasty::schema::Model,
549 {
550 self.columns.iter().find_map(|c| c.order_by(descending))
551 }
552
553 fn pk_order_bys() -> Vec<OrderByExpr>
555 where
556 M: toasty::schema::Model,
557 {
558 let app_model = M::schema();
559 let Some(root) = app_model.as_root() else {
560 debug_assert!(
561 false,
562 "pk_order_bys: {} is not a root model; deterministic pagination needs its primary key",
563 std::any::type_name::<M>()
564 );
565 return Vec::new();
566 };
567 root.primary_key
568 .fields
569 .iter()
570 .map(|fid| M::path_field::<toasty::stmt::Value>(fid.index).asc())
571 .collect()
572 }
573
574 pub(crate) fn order_bys_for(&self, state: &TableState) -> Vec<OrderByExpr>
576 where
577 M: toasty::schema::Model,
578 {
579 if let Some(sort) = &state.sort
580 && let Some(col) = self
581 .columns
582 .iter()
583 .find(|c| c.is_sortable() && c.name() == sort.column)
584 && let Some(ord) = col.order_by(sort.descending)
585 {
586 return vec![ord];
587 }
588 let out: Vec<OrderByExpr> = self.order_by(false).into_iter().collect();
589 if out.is_empty() {
590 return Self::pk_order_bys();
591 }
592 out
593 }
594
595 pub(crate) fn apply_declaration(
597 &self,
598 mut query: toasty::stmt::Query<List<M>>,
599 state: &TableState,
600 ) -> toasty::stmt::Query<List<M>>
601 where
602 M: toasty::schema::Model,
603 {
604 if let Some(term) = &state.search
605 && let Some(expr) = self.search_expr(term)
606 {
607 query = query.filter(expr);
608 }
609 if let Some(expr) = self.filter_expr(state) {
610 query = query.filter(expr);
611 }
612 for ord in self.order_bys_for(state) {
613 query = query.order_by(ord);
614 }
615 query
616 }
617
618 pub(crate) fn search_enabled(&self) -> bool
620 where
621 M: toasty::schema::Model,
622 {
623 !self.hide_search && self.columns.iter().any(|c| c.is_searchable())
624 }
625
626 pub(crate) fn is_live_search(&self) -> bool {
628 self.live_search
629 }
630
631 pub(crate) fn filter_bar_enabled(&self) -> bool {
633 !self.hide_filter_bar && !self.filters.is_empty()
634 }
635}
636
637#[cfg(test)]
638mod tests;