1use std::{borrow::Cow, sync::Arc};
5
6use toasty::stmt::{Expr, OrderByExpr};
7use topcoat::{context::Cx, icon::icon, view::*};
8
9use crate::{
10 Lens,
11 form::FormScalar,
12 schema::{Binding, FieldResolver},
13};
14
15pub trait Column<M>: Send + Sync {
42 fn name(&self) -> &str;
44
45 fn label(&self) -> &str;
47
48 fn text(&self, row: &M) -> String;
50
51 fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
53 let text = self.text(row);
54 view! { cx => (text) }.boxed()
55 }
56
57 fn column_width(&self) -> ColumnWidth {
59 ColumnWidth::Narrow
60 }
61
62 fn is_searchable(&self) -> bool {
64 false
65 }
66
67 fn search_expr(&self, _term: &str) -> Option<Expr<bool>> {
69 None
70 }
71
72 fn is_sortable(&self) -> bool {
74 false
75 }
76
77 fn order_by(&self, _descending: bool) -> Option<OrderByExpr> {
79 None
80 }
81
82 fn includes(&self) -> Includes<M> {
84 Includes::new()
85 }
86
87 #[doc(hidden)]
89 fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
90 None
91 }
92
93 #[doc(hidden)]
95 fn bind(&self, _resolver: &FieldResolver) {}
96}
97
98pub struct Includes<M>(
100 Vec<toasty_core::stmt::Include>,
101 std::marker::PhantomData<fn() -> M>,
102);
103
104impl<M> Default for Includes<M> {
105 fn default() -> Self {
106 Self(Vec::new(), std::marker::PhantomData)
107 }
108}
109
110impl<M> Clone for Includes<M> {
111 fn clone(&self) -> Self {
112 Self(self.0.clone(), std::marker::PhantomData)
113 }
114}
115
116impl<M> std::fmt::Debug for Includes<M> {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 f.debug_tuple("Includes").field(&self.0).finish()
119 }
120}
121
122impl<M> Includes<M> {
123 pub fn new() -> Self {
125 Self::default()
126 }
127
128 pub fn with<T>(mut self, relation: impl Into<toasty::stmt::Include<M, T>>) -> Self {
130 let include: toasty_core::stmt::Include = relation.into().into();
131 if !self.0.contains(&include) {
132 self.0.push(include);
133 }
134 self
135 }
136
137 pub(crate) fn into_vec(self) -> Vec<toasty_core::stmt::Include> {
139 self.0
140 }
141
142 pub fn len(&self) -> usize {
143 self.0.len()
144 }
145
146 pub fn is_empty(&self) -> bool {
147 self.0.is_empty()
148 }
149}
150
151pub(crate) const NARROW_DEFAULT_PERCENT: u8 = 10;
154
155#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
157pub enum ColumnWidth {
158 #[default]
160 Wide,
161 Narrow,
163 Rem(u8),
165 Percent(u8),
167}
168
169impl ColumnWidth {
170 pub(crate) fn default_percent(self) -> Option<u8> {
172 match self {
173 Self::Narrow => Some(NARROW_DEFAULT_PERCENT),
174 Self::Wide | Self::Rem(_) | Self::Percent(_) => None,
175 }
176 }
177
178 pub(crate) fn explicit_css(self) -> Option<Cow<'static, str>> {
180 match self {
181 Self::Rem(rem) => Some(Cow::Owned(format!("width: {rem}rem"))),
182 Self::Percent(percent) => Some(Cow::Owned(format!("width: {percent}%"))),
183 Self::Wide | Self::Narrow => None,
184 }
185 }
186}
187
188pub struct TextColumn<M, T> {
224 lens: Lens<M, T>,
225 binding: Binding,
226 format: Arc<dyn Fn(&T) -> String + Send + Sync>,
227 search: Option<SearchFn>,
229 sortable: bool,
230 width: ColumnWidth,
232}
233
234type SearchFn = Arc<dyn Fn(String) -> Expr<bool> + Send + Sync>;
236
237pub(crate) const LIKE_ESCAPE: char = '\\';
239
240pub(crate) fn escape_like_pattern(term: &str) -> String {
242 let mut pattern = String::with_capacity(term.len() + 2);
243 pattern.push('%');
244 for c in term.chars() {
245 if c == LIKE_ESCAPE || c == '%' || c == '_' {
246 pattern.push(LIKE_ESCAPE);
247 }
248 pattern.push(c);
249 }
250 pattern.push('%');
251 pattern
252}
253
254pub fn contains_expr<M, T>(lens: &Lens<M, T>, term: &str) -> Option<Expr<bool>>
256where
257 M: toasty::schema::Model,
258 T: toasty::schema::Field<Inner = String>,
259{
260 let trimmed = term.trim();
261 (!trimmed.is_empty()).then(|| {
262 lens.path()
263 .clone()
264 .like_with_escape(escape_like_pattern(trimmed), LIKE_ESCAPE)
265 })
266}
267
268impl<M, T> TextColumn<M, T>
269where
270 M: toasty::schema::Model,
271{
272 pub fn new(lens: Lens<M, T>) -> Self
274 where
275 T: FormScalar + Send + Sync + 'static,
276 {
277 let binding = Binding::of(&lens.path().clone());
278 Self {
279 lens,
280 binding,
281 format: Arc::new(T::to_form),
282 search: None,
283 sortable: false,
284 width: ColumnWidth::Wide,
285 }
286 }
287
288 pub fn format(mut self, format: impl Fn(&T) -> String + Send + Sync + 'static) -> Self {
290 self.format = Arc::new(format);
291 self
292 }
293
294 pub fn sortable(mut self) -> Self {
296 self.sortable = true;
297 self
298 }
299
300 pub fn width(mut self, width: ColumnWidth) -> Self {
302 self.width = width;
303 self
304 }
305}
306
307impl<M, T> TextColumn<M, T>
308where
309 M: toasty::schema::Model + Send + Sync + 'static,
310 T: toasty::schema::Field<Inner = String> + Send + Sync + 'static,
311{
312 pub fn searchable(mut self) -> Self {
314 let path = self.lens.path().clone();
315 self.search = Some(Arc::new(move |pattern| {
316 path.clone().like_with_escape(pattern, LIKE_ESCAPE)
317 }));
318 self
319 }
320}
321
322impl<M, T> Column<M> for TextColumn<M, T>
323where
324 M: toasty::schema::Model + Send + Sync + 'static,
325 T: Send + Sync + 'static,
326{
327 fn name(&self) -> &str {
328 self.binding.name()
329 }
330
331 fn label(&self) -> &str {
332 self.binding.label()
333 }
334
335 fn text(&self, row: &M) -> String {
336 (self.format)(self.lens.read(row))
337 }
338
339 fn column_width(&self) -> ColumnWidth {
340 self.width
341 }
342
343 fn is_searchable(&self) -> bool {
344 self.search.is_some()
345 }
346
347 fn search_expr(&self, term: &str) -> Option<Expr<bool>> {
349 let t = term.trim();
350 let search = self.search.as_ref()?;
351 (!t.is_empty()).then(|| search(escape_like_pattern(t)))
352 }
353
354 fn is_sortable(&self) -> bool {
355 self.sortable
356 }
357
358 fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
359 self.sortable.then(|| {
360 let path = self.lens.path().clone();
361 if descending { path.desc() } else { path.asc() }
362 })
363 }
364
365 fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
366 self.binding.misdeclared()
367 }
368
369 fn bind(&self, resolver: &FieldResolver) {
370 self.binding.bind(resolver);
371 }
372}
373
374impl<M, T> Clone for TextColumn<M, T> {
375 fn clone(&self) -> Self {
376 Self {
377 lens: self.lens.clone(),
378 binding: self.binding.clone(),
379 format: Arc::clone(&self.format),
380 search: self.search.clone(),
381 sortable: self.sortable,
382 width: self.width,
383 }
384 }
385}
386
387impl<M, T> std::fmt::Debug for TextColumn<M, T> {
388 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
389 f.debug_struct("TextColumn")
390 .field("name", &self.binding.name())
391 .field("label", &self.binding.label())
392 .field("searchable", &self.search.is_some())
393 .field("sortable", &self.sortable)
394 .field("width", &self.width)
395 .finish_non_exhaustive()
396 }
397}
398
399pub struct ComputedColumn<M> {
425 name: String,
426 label: String,
427 project: Arc<dyn Fn(&M) -> String + Send + Sync>,
428 width: ColumnWidth,
429 includes: Includes<M>,
431}
432
433impl<M> ComputedColumn<M>
434where
435 M: toasty::schema::Model,
436{
437 pub fn new(
439 label: impl Into<String>,
440 project: impl Fn(&M) -> String + Send + Sync + 'static,
441 ) -> Self {
442 let label = label.into();
443 Self {
444 name: label.to_lowercase(),
445 label,
446 project: Arc::new(project),
447 width: ColumnWidth::Narrow,
448 includes: Includes::new(),
449 }
450 }
451
452 pub fn include<T>(mut self, relation: impl Into<toasty::stmt::Include<M, T>>) -> Self {
454 self.includes = self.includes.with(relation);
455 self
456 }
457
458 pub fn width(mut self, width: ColumnWidth) -> Self {
460 self.width = width;
461 self
462 }
463}
464
465impl<M> Column<M> for ComputedColumn<M>
466where
467 M: toasty::schema::Model + Send + Sync + 'static,
468{
469 fn name(&self) -> &str {
470 &self.name
471 }
472
473 fn label(&self) -> &str {
474 &self.label
475 }
476
477 fn text(&self, row: &M) -> String {
478 (self.project)(row)
479 }
480
481 fn column_width(&self) -> ColumnWidth {
482 self.width
483 }
484
485 fn includes(&self) -> Includes<M> {
486 self.includes.clone()
487 }
488}
489
490impl<M> Clone for ComputedColumn<M> {
491 fn clone(&self) -> Self {
492 Self {
493 name: self.name.clone(),
494 label: self.label.clone(),
495 project: Arc::clone(&self.project),
496 width: self.width,
497 includes: self.includes.clone(),
498 }
499 }
500}
501
502impl<M> std::fmt::Debug for ComputedColumn<M> {
503 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504 f.debug_struct("ComputedColumn")
505 .field("name", &self.name)
506 .field("label", &self.label)
507 .field("width", &self.width)
508 .field("includes", &self.includes.len())
509 .finish_non_exhaustive()
510 }
511}
512
513pub struct BooleanColumn<M> {
521 lens: Lens<M, bool>,
522 binding: Binding,
523 sortable: bool,
524 labels: (String, String),
525 width: ColumnWidth,
526}
527
528impl<M> BooleanColumn<M>
529where
530 M: toasty::schema::Model,
531{
532 pub fn new(lens: Lens<M, bool>) -> Self {
534 let binding = Binding::of(&lens.path().clone());
535 Self {
536 lens,
537 binding,
538 sortable: false,
539 labels: ("Yes".to_string(), "No".to_string()),
540 width: ColumnWidth::Narrow,
541 }
542 }
543
544 pub fn sortable(mut self) -> Self {
546 self.sortable = true;
547 self
548 }
549
550 pub fn width(mut self, width: ColumnWidth) -> Self {
552 self.width = width;
553 self
554 }
555
556 pub fn labels(mut self, yes: impl Into<String>, no: impl Into<String>) -> Self {
558 self.labels = (yes.into(), no.into());
559 self
560 }
561}
562
563impl<M> Column<M> for BooleanColumn<M>
564where
565 M: toasty::schema::Model + Send + Sync + 'static,
566{
567 fn name(&self) -> &str {
568 self.binding.name()
569 }
570
571 fn label(&self) -> &str {
572 self.binding.label()
573 }
574
575 fn text(&self, row: &M) -> String {
576 if *self.lens.read(row) {
577 self.labels.0.clone()
578 } else {
579 self.labels.1.clone()
580 }
581 }
582
583 fn column_width(&self) -> ColumnWidth {
584 self.width
585 }
586
587 fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
588 let value = *self.lens.read(row);
589 let text = self.text(row);
590 let (data, class) = if value {
591 (tablo_ui::icons::CIRCLE_CHECK, "size-4 text-primary")
592 } else {
593 (tablo_ui::icons::X, "size-4 text-muted-foreground")
594 };
595 view! {
596 cx =>
597 <span class="inline-flex items-center" data-boolean=(value.to_string())>
598 icon(
599 data: data,
600 attrs: attributes! { class=(class) aria-hidden="true" }
601 )
602 <span class="sr-only">(text)</span>
603 </span>
604 }
605 .boxed()
606 }
607
608 fn is_sortable(&self) -> bool {
609 self.sortable
610 }
611
612 fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
613 self.sortable.then(|| {
614 let path = self.lens.path().clone();
615 if descending { path.desc() } else { path.asc() }
616 })
617 }
618
619 fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
620 self.binding.misdeclared()
621 }
622
623 fn bind(&self, resolver: &FieldResolver) {
624 self.binding.bind(resolver);
625 }
626}
627
628impl<M> Clone for BooleanColumn<M> {
629 fn clone(&self) -> Self {
630 Self {
631 lens: self.lens.clone(),
632 binding: self.binding.clone(),
633 sortable: self.sortable,
634 labels: self.labels.clone(),
635 width: self.width,
636 }
637 }
638}
639
640impl<M> std::fmt::Debug for BooleanColumn<M> {
641 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
642 f.debug_struct("BooleanColumn")
643 .field("name", &self.binding.name())
644 .field("label", &self.binding.label())
645 .field("sortable", &self.sortable)
646 .field("width", &self.width)
647 .finish_non_exhaustive()
648 }
649}
650
651pub(crate) type BoxColumn<M> = Arc<dyn Column<M>>;
653
654pub trait IntoColumns<M> {
659 #[doc(hidden)]
660 fn into_columns(self) -> Vec<BoxColumn<M>>;
661}
662
663impl<M, T> IntoColumns<M> for TextColumn<M, T>
664where
665 M: toasty::schema::Model + Send + Sync + 'static,
666 T: Send + Sync + 'static,
667{
668 fn into_columns(self) -> Vec<BoxColumn<M>> {
669 vec![Arc::new(self)]
670 }
671}
672
673impl<M> IntoColumns<M> for ComputedColumn<M>
674where
675 M: toasty::schema::Model + Send + Sync + 'static,
676{
677 fn into_columns(self) -> Vec<BoxColumn<M>> {
678 vec![Arc::new(self)]
679 }
680}
681
682impl<M> IntoColumns<M> for BooleanColumn<M>
683where
684 M: toasty::schema::Model + Send + Sync + 'static,
685{
686 fn into_columns(self) -> Vec<BoxColumn<M>> {
687 vec![Arc::new(self)]
688 }
689}
690
691impl<M> IntoColumns<M> for () {
692 fn into_columns(self) -> Vec<BoxColumn<M>> {
693 Vec::new()
694 }
695}
696
697impl<M> IntoColumns<M> for Vec<BoxColumn<M>> {
698 fn into_columns(self) -> Vec<BoxColumn<M>> {
699 self
700 }
701}
702
703impl<M> IntoColumns<M> for &[BoxColumn<M>] {
704 fn into_columns(self) -> Vec<BoxColumn<M>> {
705 self.to_vec()
706 }
707}
708
709macro_rules! into_columns_tuples {
711 ($($T:ident => $v:ident),+ $(,)?) => {
712 impl<M, $($T),+> IntoColumns<M> for ($($T,)+)
713 where
714 $($T: Column<M> + 'static,)+
715 {
716 fn into_columns(self) -> Vec<BoxColumn<M>> {
717 let ($($v,)+) = self;
718 vec![$(Arc::new($v) as BoxColumn<M>,)+]
719 }
720 }
721 };
722}
723
724into_columns_tuples!(A => a);
725into_columns_tuples!(A => a, B => b);
726into_columns_tuples!(A => a, B => b, C => c);
727into_columns_tuples!(A => a, B => b, C => c, D => d);
728into_columns_tuples!(A => a, B => b, C => c, D => d, E => e);
729into_columns_tuples!(A => a, B => b, C => c, D => d, E => e, F => f);
730into_columns_tuples!(A => a, B => b, C => c, D => d, E => e, F => f, G => g);
731into_columns_tuples!(
732 A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h
733);
734
735#[cfg(test)]
736mod tests;