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) allowed: Arc<dyn Fn(&M) -> bool + Send + Sync>,
104}
105
106impl<M> Clone for TableAction<M> {
107 fn clone(&self) -> Self {
108 Self {
109 name: self.name,
110 label: self.label.clone(),
111 row: self.row,
112 bulk: self.bulk,
113 allowed: Arc::clone(&self.allowed),
114 }
115 }
116}
117
118pub const DEFAULT_PAGE_SIZE: NonZeroUsize = NonZeroUsize::new(25).unwrap();
120
121pub struct Table<M> {
123 columns: Vec<BoxColumn<M>>,
124 misdeclared: Vec<DeclarationErrorKind>,
126 filters: Vec<BoxFilter<M>>,
127 group_by: Option<GroupDef<M>>,
128 key: fn(&M) -> String,
130 addressable: bool,
133 row_policy: Option<RowPolicy<M>>,
134 page_size: NonZeroUsize,
135 hide_search: bool,
136 hide_filter_bar: bool,
137 delete_prefix: Option<String>,
138 edit_prefix: Option<String>,
139 view_prefix: Option<String>,
140 bulk_delete: bool,
141 custom_actions: Vec<TableAction<M>>,
143 actions_prefix: Option<String>,
144 live_search: bool,
145 framed: bool,
147 return_to: Option<String>,
149 _marker: PhantomData<M>,
150}
151
152impl<M> Clone for Table<M> {
154 fn clone(&self) -> Self {
155 Self {
156 columns: self.columns.clone(),
157 misdeclared: self.misdeclared.clone(),
158 filters: self.filters.clone(),
159 group_by: self.group_by.clone(),
160 key: self.key,
161 addressable: self.addressable,
162 row_policy: self.row_policy.clone(),
163 page_size: self.page_size,
164 hide_search: self.hide_search,
165 hide_filter_bar: self.hide_filter_bar,
166 delete_prefix: self.delete_prefix.clone(),
167 edit_prefix: self.edit_prefix.clone(),
168 view_prefix: self.view_prefix.clone(),
169 bulk_delete: self.bulk_delete,
170 custom_actions: self.custom_actions.clone(),
171 actions_prefix: self.actions_prefix.clone(),
172 live_search: self.live_search,
173 framed: self.framed,
174 return_to: self.return_to.clone(),
175 _marker: PhantomData,
176 }
177 }
178}
179
180impl<M> std::fmt::Debug for Table<M> {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 f.debug_struct("Table")
183 .field(
184 "columns",
185 &self.columns.iter().map(|c| c.name()).collect::<Vec<_>>(),
186 )
187 .field("filters", &self.filters.len())
188 .field("group_by", &self.group_by.is_some())
189 .field("row_policy", &self.row_policy.is_some())
190 .field("page_size", &self.page_size)
191 .field("hide_search", &self.hide_search)
192 .field("hide_filter_bar", &self.hide_filter_bar)
193 .field("delete_prefix", &self.delete_prefix)
194 .field("edit_prefix", &self.edit_prefix)
195 .field("view_prefix", &self.view_prefix)
196 .field("bulk_delete", &self.bulk_delete)
197 .field(
198 "custom_actions",
199 &self
200 .custom_actions
201 .iter()
202 .map(|a| a.name)
203 .collect::<Vec<_>>(),
204 )
205 .field("live_search", &self.live_search)
206 .field("framed", &self.framed)
207 .field("return_to", &self.return_to)
208 .finish()
209 }
210}
211
212impl<M> Table<M> {
213 pub fn new(cols: impl IntoColumns<M>) -> Self
217 where
218 M: toasty::schema::Model + toasty::stmt::IntoExpr<M>,
219 {
220 Self {
221 columns: cols.into_columns(),
222 misdeclared: Vec::new(),
223 filters: Vec::new(),
224 group_by: None,
225 key: crate::toasty_compat::pk::pk_text::<M>,
226 addressable: !crate::toasty_compat::pk::pk_is_composite::<M>(),
227 row_policy: None,
228 page_size: DEFAULT_PAGE_SIZE,
229 hide_search: false,
230 hide_filter_bar: false,
231 delete_prefix: None,
232 edit_prefix: None,
233 view_prefix: None,
234 bulk_delete: false,
235 custom_actions: Vec::new(),
236 actions_prefix: None,
237 live_search: false,
238 framed: true,
239 return_to: None,
240 _marker: PhantomData,
241 }
242 }
243
244 pub fn column(mut self, column: impl Column<M> + 'static) -> Self {
246 self.columns.push(Arc::new(column));
247 self
248 }
249
250 pub(crate) fn row_actions(
252 mut self,
253 policy: impl Fn(&M) -> RowActions + Send + Sync + 'static,
254 ) -> Self {
255 self.row_policy = Some(Arc::new(policy));
256 self
257 }
258
259 pub fn filters(mut self, filters: impl IntoFilters<M>) -> Self
261 where
262 M: toasty::schema::Model,
263 {
264 let filters = filters.into_filters();
265 self.filters = filters;
266 self
267 }
268
269 pub(crate) fn include_relations(
271 &self,
272 mut query: toasty::stmt::Query<List<M>>,
273 ) -> toasty::stmt::Query<List<M>>
274 where
275 M: toasty::schema::Model,
276 {
277 let mut seen: Vec<toasty_core::stmt::Include> = Vec::new();
278 for include in self.columns.iter().flat_map(|c| c.includes().into_vec()) {
279 if !seen.contains(&include) {
280 query = query.include(include.clone());
281 seen.push(include);
282 }
283 }
284 query
285 }
286
287 pub(crate) fn filter_expr(&self, state: &TableState) -> Option<Expr<bool>>
289 where
290 M: toasty::schema::Model,
291 {
292 let mut exprs = Vec::new();
293 for f in &self.filters {
294 if let Some(v) = state.filters.get(f.name())
295 && let Some(e) = f.to_expr(v)
296 {
297 exprs.push(e);
298 }
299 }
300 if exprs.is_empty() {
301 None
302 } else {
303 let mut iter = exprs.into_iter();
304 let first = iter.next().unwrap();
305 Some(iter.fold(first, |acc, e| acc.and(e)))
306 }
307 }
308
309 pub(crate) fn unapplied_filters(&self, state: &TableState) -> Vec<(String, String)>
311 where
312 M: toasty::schema::Model,
313 {
314 let mut out = Vec::new();
315 for (key, value) in &state.filters {
316 match self.filters.iter().find(|f| f.name() == key) {
317 None => out.push((format!("{key}:{value}"), "unknown filter".to_string())),
318 Some(f) if f.to_expr(value).is_none() && !f.is_noop_value(value) => {
319 out.push((format!("{key}:{value}"), "invalid value".to_string()))
320 }
321 Some(_) => {}
322 }
323 }
324 if state.filters_dropped {
325 out.push((
326 "dropped filters".to_string(),
327 format!(
328 "more than {}, over {} bytes, or the retired filters= form",
329 state::MAX_FILTERS,
330 state::MAX_FILTER_LEN
331 ),
332 ));
333 }
334 out.sort();
335 out
336 }
337
338 pub fn group_by<T>(mut self, lens: Lens<M, T>) -> Self
340 where
341 M: toasty::schema::Model + Send + Sync + 'static,
342 T: FormScalar + Send + Sync + 'static,
343 {
344 let binding = ResolvedLens::of(lens.path().clone());
345 self.misdeclared.extend(binding.misdeclared);
346 self.group_by = Some(GroupDef {
347 name: binding.name,
348 key: Arc::new(move |record| lens.read(record).to_form()),
349 });
350 self
351 }
352
353 fn effective_group_key(&self, state: &TableState) -> Option<GroupKey<M>> {
355 match (&self.group_by, &state.group_by) {
356 (Some(def), Some(want)) if def.name == *want => Some(def.key.clone()),
357 _ => None,
358 }
359 }
360
361 pub(crate) fn normalize_state(&self, state: &TableState) -> TableState {
363 let mut out = state.clone();
364 if self.group_by.as_ref().map(|def| def.name.as_str()) != out.group_by.as_deref() {
365 out.group_by = None;
366 }
367 out
368 }
369
370 pub fn paginate(mut self, per_page: usize) -> Self {
372 match NonZeroUsize::new(per_page) {
373 Some(size) => self.page_size = size,
374 None => self.misdeclared.push(DeclarationErrorKind::ZeroPageSize),
375 }
376 self
377 }
378
379 pub fn declaration_errors(&self) -> Vec<DeclarationErrorKind> {
381 let mut errors = self.misdeclared.clone();
382 if self.columns.is_empty() {
383 errors.push(DeclarationErrorKind::NoColumns);
384 }
385 let mut seen = std::collections::HashSet::with_capacity(self.columns.len());
386 for column in &self.columns {
387 match column.misdeclared() {
388 Some(error) => errors.push(error),
389 None if !seen.insert(column.name()) => {
390 errors.push(DeclarationErrorKind::DuplicateColumn {
391 name: column.name().to_string(),
392 });
393 }
394 None => {}
395 }
396 }
397 let mut seen = std::collections::HashSet::with_capacity(self.filters.len());
398 for filter in &self.filters {
399 match filter.misdeclared() {
400 Some(error) => errors.push(error),
401 None if !seen.insert(filter.name()) => {
402 errors.push(DeclarationErrorKind::DuplicateFilter {
403 name: filter.name().to_string(),
404 });
405 }
406 None => {}
407 }
408 }
409 errors
410 }
411
412 pub fn page_size(&self) -> usize {
414 self.page_size.get()
415 }
416
417 pub(crate) fn key_of(&self, record: &M) -> String {
419 (self.key)(record)
420 }
421
422 pub(crate) fn actions_for(&self, record: &M) -> RowActions {
424 self.row_policy
425 .as_ref()
426 .map_or(RowActions::ALL, |policy| policy(record))
427 }
428
429 pub fn hide_search(mut self) -> Self {
431 self.hide_search = true;
432 self
433 }
434
435 pub fn hide_filter_bar(mut self) -> Self {
437 self.hide_filter_bar = true;
438 self
439 }
440
441 pub(crate) fn unframed(mut self) -> Self {
443 self.framed = false;
444 self
445 }
446
447 pub fn live_search(mut self) -> Self {
449 self.live_search = true;
450 self
451 }
452
453 pub(crate) fn with_delete(mut self, prefix: String) -> Self {
455 if self.addressable {
456 self.delete_prefix = Some(prefix);
457 }
458 self
459 }
460
461 pub(crate) fn with_edit(mut self, prefix: String) -> Self {
463 if self.addressable {
464 self.edit_prefix = Some(prefix);
465 }
466 self
467 }
468
469 pub(crate) fn with_view(mut self, prefix: String) -> Self {
471 if self.addressable {
472 self.view_prefix = Some(prefix);
473 }
474 self
475 }
476
477 pub(crate) fn with_bulk_delete(mut self, enabled: bool) -> Self {
479 self.bulk_delete = enabled;
480 self
481 }
482
483 pub(crate) fn returning_to(mut self, url: String) -> Self {
485 self.return_to = Some(url);
486 self
487 }
488
489 fn action_url(&self, url: String) -> String {
490 match &self.return_to {
491 Some(target) => with_return(&url, target),
492 None => url,
493 }
494 }
495
496 pub(crate) fn with_custom_actions(
498 mut self,
499 prefix: String,
500 actions: Vec<TableAction<M>>,
501 ) -> Self {
502 if self.addressable {
503 self.actions_prefix = Some(prefix);
504 self.custom_actions = actions;
505 }
506 self
507 }
508
509 fn row_custom_actions(&self) -> impl Iterator<Item = &TableAction<M>> {
510 self.custom_actions
511 .iter()
512 .filter(|a| a.row && self.actions_prefix.is_some())
513 }
514
515 fn bulk_custom_actions(&self) -> impl Iterator<Item = &TableAction<M>> {
516 self.custom_actions
517 .iter()
518 .filter(|a| a.bulk && self.actions_prefix.is_some())
519 }
520
521 fn bulk_delete_enabled(&self) -> bool {
522 self.bulk_delete && self.delete_prefix.is_some()
523 }
524
525 fn bulk_enabled(&self) -> bool {
526 self.bulk_delete_enabled() || self.bulk_custom_actions().next().is_some()
527 }
528
529 pub(crate) fn search_expr(&self, term: &str) -> Option<Expr<bool>>
531 where
532 M: toasty::schema::Model,
533 {
534 let t = term.trim();
535 if t.is_empty() {
536 return None;
537 }
538 let mut exprs = self.columns.iter().filter_map(|c| c.search_expr(t));
539 let first = exprs.next()?;
540 Some(exprs.fold(first, |acc, e| acc.or(e)))
541 }
542
543 pub(crate) fn order_by(&self, descending: bool) -> Option<OrderByExpr>
545 where
546 M: toasty::schema::Model,
547 {
548 self.columns.iter().find_map(|c| c.order_by(descending))
549 }
550
551 fn pk_order_bys() -> Vec<OrderByExpr>
553 where
554 M: toasty::schema::Model,
555 {
556 let app_model = M::schema();
557 let Some(root) = app_model.as_root() else {
558 debug_assert!(
559 false,
560 "pk_order_bys: {} is not a root model; deterministic pagination needs its primary key",
561 std::any::type_name::<M>()
562 );
563 return Vec::new();
564 };
565 root.primary_key
566 .fields
567 .iter()
568 .map(|fid| M::path_field::<toasty::stmt::Value>(fid.index).asc())
569 .collect()
570 }
571
572 pub(crate) fn order_bys_for(&self, state: &TableState) -> Vec<OrderByExpr>
574 where
575 M: toasty::schema::Model,
576 {
577 if let Some(sort) = &state.sort
578 && let Some(col) = self
579 .columns
580 .iter()
581 .find(|c| c.is_sortable() && c.name() == sort.column)
582 && let Some(ord) = col.order_by(sort.descending)
583 {
584 return vec![ord];
585 }
586 let out: Vec<OrderByExpr> = self.order_by(false).into_iter().collect();
587 if out.is_empty() {
588 return Self::pk_order_bys();
589 }
590 out
591 }
592
593 pub(crate) fn apply_declaration(
595 &self,
596 mut query: toasty::stmt::Query<List<M>>,
597 state: &TableState,
598 ) -> toasty::stmt::Query<List<M>>
599 where
600 M: toasty::schema::Model,
601 {
602 if let Some(term) = &state.search
603 && let Some(expr) = self.search_expr(term)
604 {
605 query = query.filter(expr);
606 }
607 if let Some(expr) = self.filter_expr(state) {
608 query = query.filter(expr);
609 }
610 for ord in self.order_bys_for(state) {
611 query = query.order_by(ord);
612 }
613 query
614 }
615
616 pub(crate) fn search_enabled(&self) -> bool
618 where
619 M: toasty::schema::Model,
620 {
621 !self.hide_search && self.columns.iter().any(|c| c.is_searchable())
622 }
623
624 pub(crate) fn is_live_search(&self) -> bool {
626 self.live_search
627 }
628
629 pub(crate) fn filter_bar_enabled(&self) -> bool {
631 !self.hide_filter_bar && !self.filters.is_empty()
632 }
633}
634
635#[cfg(test)]
636mod tests;