1use std::{borrow::Cow, sync::Arc};
6
7use toasty::stmt::{Expr, List, OrderByExpr, Query};
8use topcoat::{context::Cx, icon::icon, view::*};
9
10use crate::{
11 Lens,
12 form::FormScalar,
13 schema::{Binding, FieldResolver},
14};
15
16mod embedded;
17mod relation;
18mod repeater;
19
20pub use embedded::EmbeddedColumn;
21pub use relation::{CountColumn, RelationColumn, RelationLens, ToOneRelation};
22pub use repeater::RepeaterColumn;
23
24pub trait Column<M>: Send + Sync {
53 fn name(&self) -> &str;
55
56 fn label(&self) -> &str;
58
59 fn text(&self, cx: &Cx, row: &M) -> String;
61
62 fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
64 crate::schema::value_cell(cx, &self.text(cx, row))
65 }
66
67 fn entry<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
70 crate::schema::read_only(cx, self.label(), self.cell(cx, row))
71 }
72
73 fn column_width(&self) -> ColumnWidth {
75 ColumnWidth::Narrow
76 }
77
78 fn is_searchable(&self) -> bool {
80 false
81 }
82
83 fn search_expr(&self, _term: &str) -> Option<Expr<bool>> {
85 None
86 }
87
88 fn is_sortable(&self) -> bool {
90 false
91 }
92
93 fn order_by(&self, _descending: bool) -> Option<OrderByExpr> {
95 None
96 }
97
98 fn includes(&self) -> Includes<M> {
100 Includes::new()
101 }
102
103 #[doc(hidden)]
105 fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
106 None
107 }
108
109 #[doc(hidden)]
111 fn unavailable_source(&self, _cx: &Cx) -> Option<&'static str> {
112 None
113 }
114
115 #[doc(hidden)]
117 fn bind(&self, _resolver: &FieldResolver) {}
118}
119
120pub struct Includes<M>(
122 Vec<crate::toasty_compat::UntypedInclude>,
123 std::marker::PhantomData<fn() -> M>,
124);
125
126impl<M> Default for Includes<M> {
127 fn default() -> Self {
128 Self(Vec::new(), std::marker::PhantomData)
129 }
130}
131
132impl<M> Clone for Includes<M> {
133 fn clone(&self) -> Self {
134 Self(self.0.clone(), std::marker::PhantomData)
135 }
136}
137
138impl<M> std::fmt::Debug for Includes<M> {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 f.debug_tuple("Includes").field(&self.0).finish()
141 }
142}
143
144impl<M> Includes<M> {
145 pub fn new() -> Self {
147 Self::default()
148 }
149
150 pub fn with<T>(mut self, relation: impl Into<toasty::stmt::Include<M, T>>) -> Self {
152 let include: crate::toasty_compat::UntypedInclude = relation.into().into();
153 if !self.0.contains(&include) {
154 self.0.push(include);
155 }
156 self
157 }
158
159 pub(crate) fn into_vec(self) -> Vec<crate::toasty_compat::UntypedInclude> {
161 self.0
162 }
163
164 pub fn len(&self) -> usize {
165 self.0.len()
166 }
167
168 pub fn is_empty(&self) -> bool {
169 self.0.is_empty()
170 }
171}
172
173pub(crate) fn unavailable_sources<'c, M: 'c>(
177 cx: &Cx,
178 columns: impl IntoIterator<Item = &'c dyn Column<M>>,
179) -> Vec<crate::DeclarationErrorKind> {
180 columns
181 .into_iter()
182 .filter_map(|column| {
183 column.unavailable_source(cx).map(|source| {
184 crate::DeclarationErrorKind::UnregisteredLabelSource {
185 column: column.name().to_string(),
186 source,
187 }
188 })
189 })
190 .collect()
191}
192
193pub(crate) fn include_relations<'c, M>(
194 mut query: Query<List<M>>,
195 columns: impl IntoIterator<Item = &'c BoxColumn<M>>,
196) -> Query<List<M>>
197where
198 M: toasty::schema::Model + 'c,
199{
200 let mut seen: Vec<crate::toasty_compat::UntypedInclude> = Vec::new();
201 for include in columns.into_iter().flat_map(|c| c.includes().into_vec()) {
202 if !seen.contains(&include) {
203 query = query.include(include.clone());
204 seen.push(include);
205 }
206 }
207 query
208}
209
210pub(crate) const NARROW_DEFAULT_PERCENT: u8 = 10;
213
214#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
216pub enum ColumnWidth {
217 #[default]
219 Wide,
220 Narrow,
222 Rem(u8),
224 Percent(u8),
226}
227
228impl ColumnWidth {
229 pub(crate) fn default_percent(self) -> Option<u8> {
231 match self {
232 Self::Narrow => Some(NARROW_DEFAULT_PERCENT),
233 Self::Wide | Self::Rem(_) | Self::Percent(_) => None,
234 }
235 }
236
237 pub(crate) fn explicit_css(self) -> Option<Cow<'static, str>> {
239 match self {
240 Self::Rem(rem) => Some(Cow::Owned(format!("width: {rem}rem"))),
241 Self::Percent(percent) => Some(Cow::Owned(format!("width: {percent}%"))),
242 Self::Wide | Self::Narrow => None,
243 }
244 }
245}
246
247pub struct TextColumn<M, T> {
277 lens: Lens<M, T>,
278 binding: Binding,
279 label: Option<String>,
281 format: Arc<dyn Fn(&T) -> String + Send + Sync>,
282 search: Option<SearchFn>,
284 sortable: bool,
285 width: ColumnWidth,
287}
288
289type SearchFn = Arc<dyn Fn(String) -> Expr<bool> + Send + Sync>;
291
292pub(crate) const LIKE_ESCAPE: char = '\\';
294
295pub(crate) fn escape_like_pattern(term: &str) -> String {
297 let mut pattern = String::with_capacity(term.len() + 2);
298 pattern.push('%');
299 for c in term.chars() {
300 if c == LIKE_ESCAPE || c == '%' || c == '_' {
301 pattern.push(LIKE_ESCAPE);
302 }
303 pattern.push(c);
304 }
305 pattern.push('%');
306 pattern
307}
308
309pub fn contains_expr<M, T>(lens: &Lens<M, T>, term: &str) -> Option<Expr<bool>>
311where
312 M: toasty::schema::Model,
313 T: toasty::schema::Field<Inner = String>,
314{
315 let trimmed = term.trim();
316 (!trimmed.is_empty()).then(|| {
317 lens.path()
318 .clone()
319 .like_with_escape(escape_like_pattern(trimmed), LIKE_ESCAPE)
320 })
321}
322
323impl<M, T> TextColumn<M, T>
324where
325 M: toasty::schema::Model,
326{
327 pub fn new(lens: Lens<M, T>) -> Self
329 where
330 T: FormScalar + Send + Sync + 'static,
331 {
332 let binding = Binding::of(&lens.path().clone());
333 Self {
334 lens,
335 binding,
336 label: None,
337 format: Arc::new(T::to_label),
338 search: None,
339 sortable: false,
340 width: ColumnWidth::Wide,
341 }
342 }
343
344 pub fn format(mut self, format: impl Fn(&T) -> String + Send + Sync + 'static) -> Self {
346 self.format = Arc::new(format);
347 self
348 }
349
350 pub fn sortable(mut self) -> Self {
352 self.sortable = true;
353 self
354 }
355
356 pub fn label(mut self, label: impl Into<String>) -> Self {
358 self.label = Some(label.into());
359 self
360 }
361
362 pub fn width(mut self, width: ColumnWidth) -> Self {
364 self.width = width;
365 self
366 }
367}
368
369impl<M, T> TextColumn<M, T>
370where
371 M: toasty::schema::Model + Send + Sync + 'static,
372 T: toasty::schema::Field<Inner = String> + Send + Sync + 'static,
373{
374 pub fn searchable(mut self) -> Self {
376 let path = self.lens.path().clone();
377 self.search = Some(Arc::new(move |pattern| {
378 path.clone().like_with_escape(pattern, LIKE_ESCAPE)
379 }));
380 self
381 }
382}
383
384impl<M, T> Column<M> for TextColumn<M, T>
385where
386 M: toasty::schema::Model + Send + Sync + 'static,
387 T: Send + Sync + 'static,
388{
389 fn name(&self) -> &str {
390 self.binding.name()
391 }
392
393 fn label(&self) -> &str {
394 self.label.as_deref().unwrap_or(self.binding.label())
395 }
396
397 fn text(&self, _cx: &Cx, row: &M) -> String {
398 (self.format)(self.lens.read(row))
399 }
400
401 fn column_width(&self) -> ColumnWidth {
402 self.width
403 }
404
405 fn is_searchable(&self) -> bool {
406 self.search.is_some()
407 }
408
409 fn search_expr(&self, term: &str) -> Option<Expr<bool>> {
411 let t = term.trim();
412 let search = self.search.as_ref()?;
413 (!t.is_empty()).then(|| search(escape_like_pattern(t)))
414 }
415
416 fn is_sortable(&self) -> bool {
417 self.sortable
418 }
419
420 fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
421 self.sortable.then(|| {
422 let path = self.lens.path().clone();
423 if descending { path.desc() } else { path.asc() }
424 })
425 }
426
427 fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
428 self.binding.misdeclared()
429 }
430
431 fn bind(&self, resolver: &FieldResolver) {
432 self.binding.bind(resolver);
433 }
434}
435
436impl<M, T> Clone for TextColumn<M, T> {
437 fn clone(&self) -> Self {
438 Self {
439 lens: self.lens.clone(),
440 binding: self.binding.clone(),
441 label: self.label.clone(),
442 format: Arc::clone(&self.format),
443 search: self.search.clone(),
444 sortable: self.sortable,
445 width: self.width,
446 }
447 }
448}
449
450impl<M, T> std::fmt::Debug for TextColumn<M, T> {
451 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
452 f.debug_struct("TextColumn")
453 .field("name", &self.binding.name())
454 .field(
455 "label",
456 &self.label.as_deref().unwrap_or(self.binding.label()),
457 )
458 .field("searchable", &self.search.is_some())
459 .field("sortable", &self.sortable)
460 .field("width", &self.width)
461 .finish_non_exhaustive()
462 }
463}
464
465pub struct ComputedColumn<M> {
489 name: String,
490 label: String,
491 project: Arc<dyn Fn(&M) -> String + Send + Sync>,
492 width: ColumnWidth,
493 includes: Includes<M>,
495}
496
497impl<M> ComputedColumn<M>
498where
499 M: toasty::schema::Model,
500{
501 pub fn new(
503 label: impl Into<String>,
504 project: impl Fn(&M) -> String + Send + Sync + 'static,
505 ) -> Self {
506 let label = label.into();
507 Self {
508 name: label.to_lowercase(),
509 label,
510 project: Arc::new(project),
511 width: ColumnWidth::Narrow,
512 includes: Includes::new(),
513 }
514 }
515
516 pub fn include<T>(mut self, relation: impl Into<toasty::stmt::Include<M, T>>) -> Self {
518 self.includes = self.includes.with(relation);
519 self
520 }
521
522 pub fn width(mut self, width: ColumnWidth) -> Self {
524 self.width = width;
525 self
526 }
527}
528
529impl<M> Column<M> for ComputedColumn<M>
530where
531 M: toasty::schema::Model + Send + Sync + 'static,
532{
533 fn name(&self) -> &str {
534 &self.name
535 }
536
537 fn label(&self) -> &str {
538 &self.label
539 }
540
541 fn text(&self, _cx: &Cx, row: &M) -> String {
542 (self.project)(row)
543 }
544
545 fn column_width(&self) -> ColumnWidth {
546 self.width
547 }
548
549 fn includes(&self) -> Includes<M> {
550 self.includes.clone()
551 }
552}
553
554impl<M> Clone for ComputedColumn<M> {
555 fn clone(&self) -> Self {
556 Self {
557 name: self.name.clone(),
558 label: self.label.clone(),
559 project: Arc::clone(&self.project),
560 width: self.width,
561 includes: self.includes.clone(),
562 }
563 }
564}
565
566impl<M> std::fmt::Debug for ComputedColumn<M> {
567 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
568 f.debug_struct("ComputedColumn")
569 .field("name", &self.name)
570 .field("label", &self.label)
571 .field("width", &self.width)
572 .field("includes", &self.includes.len())
573 .finish_non_exhaustive()
574 }
575}
576
577pub struct BooleanColumn<M> {
585 lens: Lens<M, bool>,
586 binding: Binding,
587 label: Option<String>,
589 sortable: bool,
590 labels: (String, String),
591 width: ColumnWidth,
592}
593
594impl<M> BooleanColumn<M>
595where
596 M: toasty::schema::Model,
597{
598 pub fn new(lens: Lens<M, bool>) -> Self {
600 let binding = Binding::of(&lens.path().clone());
601 Self {
602 lens,
603 binding,
604 label: None,
605 sortable: false,
606 labels: ("Yes".to_string(), "No".to_string()),
607 width: ColumnWidth::Narrow,
608 }
609 }
610
611 pub fn sortable(mut self) -> Self {
613 self.sortable = true;
614 self
615 }
616
617 pub fn label(mut self, label: impl Into<String>) -> Self {
619 self.label = Some(label.into());
620 self
621 }
622
623 pub fn width(mut self, width: ColumnWidth) -> Self {
625 self.width = width;
626 self
627 }
628
629 pub fn labels(mut self, yes: impl Into<String>, no: impl Into<String>) -> Self {
631 self.labels = (yes.into(), no.into());
632 self
633 }
634}
635
636impl<M> Column<M> for BooleanColumn<M>
637where
638 M: toasty::schema::Model + Send + Sync + 'static,
639{
640 fn name(&self) -> &str {
641 self.binding.name()
642 }
643
644 fn label(&self) -> &str {
645 self.label.as_deref().unwrap_or(self.binding.label())
646 }
647
648 fn text(&self, _cx: &Cx, row: &M) -> String {
649 if *self.lens.read(row) {
650 self.labels.0.clone()
651 } else {
652 self.labels.1.clone()
653 }
654 }
655
656 fn column_width(&self) -> ColumnWidth {
657 self.width
658 }
659
660 fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
661 let value = *self.lens.read(row);
662 let text = self.text(cx, row);
663 let (data, class) = if value {
664 (tablo_ui::icons::CIRCLE_CHECK, "size-4 text-primary")
665 } else {
666 (tablo_ui::icons::X, "size-4 text-muted-foreground")
667 };
668 view! {
669 cx =>
670 <span class="inline-flex items-center" data-boolean=(value.to_string())>
671 icon(
672 data: data,
673 attrs: attributes! { class=(class) aria-hidden="true" }
674 )
675 <span class="sr-only">(text)</span>
676 </span>
677 }
678 .boxed()
679 }
680
681 fn is_sortable(&self) -> bool {
682 self.sortable
683 }
684
685 fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
686 self.sortable.then(|| {
687 let path = self.lens.path().clone();
688 if descending { path.desc() } else { path.asc() }
689 })
690 }
691
692 fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
693 self.binding.misdeclared()
694 }
695
696 fn bind(&self, resolver: &FieldResolver) {
697 self.binding.bind(resolver);
698 }
699}
700
701impl<M> Clone for BooleanColumn<M> {
702 fn clone(&self) -> Self {
703 Self {
704 lens: self.lens.clone(),
705 binding: self.binding.clone(),
706 label: self.label.clone(),
707 sortable: self.sortable,
708 labels: self.labels.clone(),
709 width: self.width,
710 }
711 }
712}
713
714impl<M> std::fmt::Debug for BooleanColumn<M> {
715 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
716 f.debug_struct("BooleanColumn")
717 .field("name", &self.binding.name())
718 .field(
719 "label",
720 &self.label.as_deref().unwrap_or(self.binding.label()),
721 )
722 .field("sortable", &self.sortable)
723 .field("width", &self.width)
724 .finish_non_exhaustive()
725 }
726}
727
728pub struct FileColumn<M> {
739 lens: Lens<M, String>,
740 binding: Binding,
741 label: Option<String>,
743 width: ColumnWidth,
744}
745
746impl<M> FileColumn<M>
747where
748 M: toasty::schema::Model,
749{
750 pub fn new(lens: Lens<M, String>) -> Self {
752 let binding = Binding::of(&lens.path().clone());
753 Self {
754 lens,
755 binding,
756 label: None,
757 width: ColumnWidth::Wide,
758 }
759 }
760
761 pub fn label(mut self, label: impl Into<String>) -> Self {
763 self.label = Some(label.into());
764 self
765 }
766
767 pub fn width(mut self, width: ColumnWidth) -> Self {
769 self.width = width;
770 self
771 }
772}
773
774impl<M> Column<M> for FileColumn<M>
775where
776 M: toasty::schema::Model + Send + Sync + 'static,
777{
778 fn name(&self) -> &str {
779 self.binding.name()
780 }
781
782 fn label(&self) -> &str {
783 self.label.as_deref().unwrap_or(self.binding.label())
784 }
785
786 fn text(&self, _cx: &Cx, row: &M) -> String {
787 self.lens.read(row).clone()
788 }
789
790 fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
791 let path = self.lens.read(row);
792 if path.trim().is_empty() {
793 return crate::schema::value_cell(cx, path);
794 }
795 crate::schema::stored_upload(cx, path)
796 }
797
798 fn column_width(&self) -> ColumnWidth {
799 self.width
800 }
801
802 fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
803 self.binding.misdeclared()
804 }
805
806 fn bind(&self, resolver: &FieldResolver) {
807 self.binding.bind(resolver);
808 }
809}
810
811impl<M> Clone for FileColumn<M> {
812 fn clone(&self) -> Self {
813 Self {
814 lens: self.lens.clone(),
815 binding: self.binding.clone(),
816 label: self.label.clone(),
817 width: self.width,
818 }
819 }
820}
821
822impl<M> std::fmt::Debug for FileColumn<M> {
823 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
824 f.debug_struct("FileColumn")
825 .field("name", &self.binding.name())
826 .field(
827 "label",
828 &self.label.as_deref().unwrap_or(self.binding.label()),
829 )
830 .field("width", &self.width)
831 .finish_non_exhaustive()
832 }
833}
834
835pub(crate) type BoxColumn<M> = Arc<dyn Column<M>>;
837
838pub trait IntoColumns<M> {
843 #[doc(hidden)]
844 fn into_columns(self) -> Vec<BoxColumn<M>>;
845}
846
847impl<M, T> IntoColumns<M> for TextColumn<M, T>
848where
849 M: toasty::schema::Model + Send + Sync + 'static,
850 T: Send + Sync + 'static,
851{
852 fn into_columns(self) -> Vec<BoxColumn<M>> {
853 vec![Arc::new(self)]
854 }
855}
856
857impl<M> IntoColumns<M> for ComputedColumn<M>
858where
859 M: toasty::schema::Model + Send + Sync + 'static,
860{
861 fn into_columns(self) -> Vec<BoxColumn<M>> {
862 vec![Arc::new(self)]
863 }
864}
865
866impl<M> IntoColumns<M> for RelationColumn<M>
867where
868 M: toasty::schema::Model + Send + Sync + 'static,
869{
870 fn into_columns(self) -> Vec<BoxColumn<M>> {
871 vec![Arc::new(self)]
872 }
873}
874
875impl<M> IntoColumns<M> for CountColumn<M>
876where
877 M: toasty::schema::Model + Send + Sync + 'static,
878{
879 fn into_columns(self) -> Vec<BoxColumn<M>> {
880 vec![Arc::new(self)]
881 }
882}
883
884impl<M> IntoColumns<M> for BooleanColumn<M>
885where
886 M: toasty::schema::Model + Send + Sync + 'static,
887{
888 fn into_columns(self) -> Vec<BoxColumn<M>> {
889 vec![Arc::new(self)]
890 }
891}
892
893impl<M> IntoColumns<M> for FileColumn<M>
894where
895 M: toasty::schema::Model + Send + Sync + 'static,
896{
897 fn into_columns(self) -> Vec<BoxColumn<M>> {
898 vec![Arc::new(self)]
899 }
900}
901
902impl<M, T> IntoColumns<M> for EmbeddedColumn<M, T>
903where
904 M: toasty::schema::Model + Send + Sync + 'static,
905 T: crate::EmbeddedForm + Send + Sync + 'static,
906{
907 fn into_columns(self) -> Vec<BoxColumn<M>> {
908 vec![Arc::new(self)]
909 }
910}
911
912impl<M, T> IntoColumns<M> for RepeaterColumn<M, T>
913where
914 M: toasty::schema::Model + Send + Sync + 'static,
915 T: crate::RepeaterItem,
916{
917 fn into_columns(self) -> Vec<BoxColumn<M>> {
918 vec![Arc::new(self)]
919 }
920}
921
922impl<M> IntoColumns<M> for () {
923 fn into_columns(self) -> Vec<BoxColumn<M>> {
924 Vec::new()
925 }
926}
927
928impl<M> IntoColumns<M> for Vec<BoxColumn<M>> {
929 fn into_columns(self) -> Vec<BoxColumn<M>> {
930 self
931 }
932}
933
934impl<M> IntoColumns<M> for &[BoxColumn<M>] {
935 fn into_columns(self) -> Vec<BoxColumn<M>> {
936 self.to_vec()
937 }
938}
939
940macro_rules! into_columns_tuples {
942 ($($T:ident => $v:ident),+ $(,)?) => {
943 impl<M, $($T),+> IntoColumns<M> for ($($T,)+)
944 where
945 $($T: Column<M> + 'static,)+
946 {
947 fn into_columns(self) -> Vec<BoxColumn<M>> {
948 let ($($v,)+) = self;
949 vec![$(Arc::new($v) as BoxColumn<M>,)+]
950 }
951 }
952 };
953}
954
955into_columns_tuples!(A => a);
956into_columns_tuples!(A => a, B => b);
957into_columns_tuples!(A => a, B => b, C => c);
958into_columns_tuples!(A => a, B => b, C => c, D => d);
959into_columns_tuples!(A => a, B => b, C => c, D => d, E => e);
960into_columns_tuples!(A => a, B => b, C => c, D => d, E => e, F => f);
961into_columns_tuples!(A => a, B => b, C => c, D => d, E => e, F => f, G => g);
962into_columns_tuples!(
963 A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h
964);
965into_columns_tuples!(
966 A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h, I => i
967);
968into_columns_tuples!(
969 A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h, I => i, J => j
970);
971into_columns_tuples!(
972 A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h, I => i, J => j, K => k
973);
974into_columns_tuples!(
975 A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h, I => i, J => j, K => k, L => l
976);
977
978#[cfg(test)]
979mod tests;