1use std::{borrow::Cow, sync::Arc};
6
7use derive_where::derive_where;
8use toasty::stmt::{Expr, List, OrderByExpr, Path, Query};
9use topcoat::{context::Cx, icon::icon, view::*};
10
11use crate::{
12 Detail, IntoDetail, Lens,
13 form::FormScalar,
14 schema::{Binding, FieldResolver},
15};
16
17macro_rules! base_builders {
19 () => {
20 pub fn label(mut self, label: impl Into<String>) -> Self {
22 self.base.label = Some(label.into());
23 self
24 }
25
26 pub fn width(mut self, width: $crate::table::ColumnWidth) -> Self {
28 self.base.width = width;
29 self
30 }
31 };
32 (sortable) => {
33 base_builders!();
34
35 pub fn sortable(mut self) -> Self {
37 self.sortable = true;
38 self
39 }
40 };
41}
42
43macro_rules! base_column_methods {
46 () => {
47 fn name(&self) -> &str {
48 self.base.name()
49 }
50
51 fn label(&self) -> &str {
52 self.base.label()
53 }
54
55 fn column_width(&self) -> $crate::table::ColumnWidth {
56 self.base.width
57 }
58 };
59 (bind) => {
60 base_column_methods!();
61
62 fn misdeclared(&self) -> Option<$crate::DeclarationErrorKind> {
63 self.base.binding.misdeclared()
64 }
65
66 fn bind(&self, resolver: &$crate::schema::FieldResolver) {
67 self.base.binding.bind(resolver);
68 }
69 };
70}
71
72fn sort_order<M, T>(path: &Path<M, T>, descending: bool) -> OrderByExpr {
73 if descending {
74 path.clone().desc()
75 } else {
76 path.clone().asc()
77 }
78}
79
80mod embedded;
81pub(crate) mod model;
82mod relation;
83mod repeater;
84
85pub use embedded::EmbeddedColumn;
86pub use model::ModelColumns;
87pub use relation::{CountColumn, RelationColumn, RelationLens, ToOneRelation};
88pub use repeater::RepeaterColumn;
89
90pub trait Column<M>: Send + Sync {
119 fn name(&self) -> &str;
121
122 fn label(&self) -> &str;
124
125 fn text(&self, cx: &Cx, row: &M) -> String;
127
128 fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
130 crate::schema::value_cell(cx, &self.text(cx, row))
131 }
132
133 fn entry<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
136 crate::schema::read_only(cx, self.label(), self.cell(cx, row))
137 }
138
139 fn column_width(&self) -> ColumnWidth {
141 ColumnWidth::Narrow
142 }
143
144 fn is_searchable(&self) -> bool {
146 false
147 }
148
149 fn search_expr(&self, _term: &str) -> Option<Expr<bool>> {
151 None
152 }
153
154 fn is_sortable(&self) -> bool {
156 false
157 }
158
159 fn order_by(&self, _descending: bool) -> Option<OrderByExpr> {
161 None
162 }
163
164 fn includes(&self) -> Includes<M> {
166 Includes::new()
167 }
168
169 #[doc(hidden)]
171 fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
172 None
173 }
174
175 #[doc(hidden)]
177 fn unavailable_source(&self, _cx: &Cx) -> Option<&'static str> {
178 None
179 }
180
181 #[doc(hidden)]
183 fn bind(&self, _resolver: &FieldResolver) {}
184}
185
186#[derive_where(Clone, Debug, Default)]
188pub struct Includes<M>(
189 Vec<crate::toasty_compat::UntypedInclude>,
190 #[derive_where(skip(Debug))] std::marker::PhantomData<fn() -> M>,
191);
192
193impl<M> Includes<M> {
194 pub fn new() -> Self {
196 Self::default()
197 }
198
199 pub fn with<T>(mut self, relation: impl Into<toasty::stmt::Include<M, T>>) -> Self {
201 let include: crate::toasty_compat::UntypedInclude = relation.into().into();
202 if !self.0.contains(&include) {
203 self.0.push(include);
204 }
205 self
206 }
207
208 pub(crate) fn into_vec(self) -> Vec<crate::toasty_compat::UntypedInclude> {
210 self.0
211 }
212
213 pub fn len(&self) -> usize {
214 self.0.len()
215 }
216
217 pub fn is_empty(&self) -> bool {
218 self.0.is_empty()
219 }
220}
221
222pub(crate) fn unavailable_sources<'c, M: 'c>(
226 cx: &Cx,
227 columns: impl IntoIterator<Item = &'c dyn Column<M>>,
228) -> Vec<crate::DeclarationErrorKind> {
229 columns
230 .into_iter()
231 .filter_map(|column| {
232 column.unavailable_source(cx).map(|source| {
233 crate::DeclarationErrorKind::UnregisteredLabelSource {
234 column: column.name().to_string(),
235 source,
236 }
237 })
238 })
239 .collect()
240}
241
242pub(crate) fn include_relations<'c, M>(
243 mut query: Query<List<M>>,
244 columns: impl IntoIterator<Item = &'c BoxColumn<M>>,
245) -> Query<List<M>>
246where
247 M: toasty::schema::Model + 'c,
248{
249 let mut seen: Vec<crate::toasty_compat::UntypedInclude> = Vec::new();
250 for include in columns.into_iter().flat_map(|c| c.includes().into_vec()) {
251 if !seen.contains(&include) {
252 query = query.include(include.clone());
253 seen.push(include);
254 }
255 }
256 query
257}
258
259#[derive(Clone, Debug)]
262struct ColumnBase {
263 binding: Binding,
264 label: Option<String>,
266 width: ColumnWidth,
267}
268
269impl ColumnBase {
270 fn new<M, T>(path: &Path<M, T>, width: ColumnWidth) -> Self
272 where
273 M: toasty::schema::Model,
274 {
275 Self {
276 binding: Binding::of(path),
277 label: None,
278 width,
279 }
280 }
281
282 fn name(&self) -> &str {
283 self.binding.name()
284 }
285
286 fn label(&self) -> &str {
287 self.label.as_deref().unwrap_or(self.binding.label())
288 }
289}
290
291pub(crate) const NARROW_DEFAULT_PERCENT: u8 = 10;
294
295#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
297pub enum ColumnWidth {
298 #[default]
300 Wide,
301 Narrow,
303 Rem(u8),
305 Percent(u8),
307}
308
309impl ColumnWidth {
310 pub(crate) fn default_percent(self) -> Option<u8> {
312 match self {
313 Self::Narrow => Some(NARROW_DEFAULT_PERCENT),
314 Self::Wide | Self::Rem(_) | Self::Percent(_) => None,
315 }
316 }
317
318 pub(crate) fn explicit_css(self) -> Option<Cow<'static, str>> {
320 match self {
321 Self::Rem(rem) => Some(Cow::Owned(format!("width: {rem}rem"))),
322 Self::Percent(percent) => Some(Cow::Owned(format!("width: {percent}%"))),
323 Self::Wide | Self::Narrow => None,
324 }
325 }
326}
327
328#[derive_where(Clone, Debug)]
350pub struct TextColumn<M, T> {
351 lens: Lens<M, T>,
352 base: ColumnBase,
353 #[derive_where(skip(Debug))]
354 format: Arc<dyn Fn(&T) -> String + Send + Sync>,
355 #[derive_where(skip(Debug))]
357 search: Option<SearchFn>,
358 sortable: bool,
359}
360
361type SearchFn = Arc<dyn Fn(String) -> Expr<bool> + Send + Sync>;
363
364pub(crate) const LIKE_ESCAPE: char = '\\';
366
367pub(crate) fn escape_like_pattern(term: &str) -> String {
369 let mut pattern = String::with_capacity(term.len() + 2);
370 pattern.push('%');
371 for c in term.chars() {
372 if c == LIKE_ESCAPE || c == '%' || c == '_' {
373 pattern.push(LIKE_ESCAPE);
374 }
375 pattern.push(c);
376 }
377 pattern.push('%');
378 pattern
379}
380
381pub fn contains_expr<M, T>(lens: &Lens<M, T>, term: &str) -> Option<Expr<bool>>
383where
384 M: toasty::schema::Model,
385 T: toasty::schema::Field<Inner = String>,
386{
387 let trimmed = term.trim();
388 (!trimmed.is_empty()).then(|| {
389 lens.path()
390 .clone()
391 .like_with_escape(escape_like_pattern(trimmed), LIKE_ESCAPE)
392 })
393}
394
395impl<M, T> TextColumn<M, T>
396where
397 M: toasty::schema::Model,
398{
399 pub fn new(lens: Lens<M, T>) -> Self
401 where
402 T: FormScalar + Send + Sync + 'static,
403 {
404 Self {
405 base: ColumnBase::new(lens.path(), ColumnWidth::Wide),
406 lens,
407 format: Arc::new(T::to_label),
408 search: None,
409 sortable: false,
410 }
411 }
412
413 pub fn format(mut self, format: impl Fn(&T) -> String + Send + Sync + 'static) -> Self {
415 self.format = Arc::new(format);
416 self
417 }
418
419 base_builders!(sortable);
420}
421
422impl<M, T> TextColumn<M, T>
423where
424 M: toasty::schema::Model + Send + Sync + 'static,
425 T: toasty::schema::Field<Inner = String> + Send + Sync + 'static,
426{
427 pub fn searchable(mut self) -> Self {
429 let path = self.lens.path().clone();
430 self.search = Some(Arc::new(move |pattern| {
431 path.clone().like_with_escape(pattern, LIKE_ESCAPE)
432 }));
433 self
434 }
435}
436
437impl<M, T> Column<M> for TextColumn<M, T>
438where
439 M: toasty::schema::Model + Send + Sync + 'static,
440 T: Send + Sync + 'static,
441{
442 base_column_methods!(bind);
443
444 fn text(&self, _cx: &Cx, row: &M) -> String {
445 (self.format)(self.lens.read(row))
446 }
447
448 fn is_searchable(&self) -> bool {
449 self.search.is_some()
450 }
451
452 fn search_expr(&self, term: &str) -> Option<Expr<bool>> {
454 let t = term.trim();
455 let search = self.search.as_ref()?;
456 (!t.is_empty()).then(|| search(escape_like_pattern(t)))
457 }
458
459 fn is_sortable(&self) -> bool {
460 self.sortable
461 }
462
463 fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
464 self.sortable
465 .then(|| sort_order(self.lens.path(), descending))
466 }
467}
468
469#[derive_where(Clone, Debug)]
481pub struct DateColumn<M, T> {
482 lens: Lens<M, T>,
483 base: ColumnBase,
484 sortable: bool,
485}
486
487pub trait DateValue: Sized + Send + Sync + 'static {
491 fn calendar_date(&self) -> Option<jiff::civil::Date>;
493}
494
495impl DateValue for jiff::civil::Date {
496 fn calendar_date(&self) -> Option<jiff::civil::Date> {
497 Some(*self)
498 }
499}
500
501impl DateValue for jiff::civil::DateTime {
502 fn calendar_date(&self) -> Option<jiff::civil::Date> {
503 Some(self.date())
504 }
505}
506
507impl DateValue for jiff::Timestamp {
508 fn calendar_date(&self) -> Option<jiff::civil::Date> {
509 Some(self.to_zoned(jiff::tz::TimeZone::UTC).date())
510 }
511}
512
513impl<T: DateValue> DateValue for Option<T> {
514 fn calendar_date(&self) -> Option<jiff::civil::Date> {
515 self.as_ref().and_then(DateValue::calendar_date)
516 }
517}
518
519impl<M, T> DateColumn<M, T>
520where
521 M: toasty::schema::Model,
522{
523 pub fn new(lens: Lens<M, T>) -> Self
525 where
526 T: DateValue,
527 {
528 Self {
529 base: ColumnBase::new(lens.path(), ColumnWidth::Narrow),
530 lens,
531 sortable: false,
532 }
533 }
534
535 base_builders!(sortable);
536}
537
538impl<M, T> Column<M> for DateColumn<M, T>
539where
540 M: toasty::schema::Model + Send + Sync + 'static,
541 T: DateValue,
542{
543 base_column_methods!(bind);
544
545 fn text(&self, _cx: &Cx, row: &M) -> String {
546 self.lens
547 .read(row)
548 .calendar_date()
549 .map(|day| day.strftime("%b %-d, %Y").to_string())
550 .unwrap_or_default()
551 }
552
553 fn is_sortable(&self) -> bool {
554 self.sortable
555 }
556
557 fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
558 self.sortable
559 .then(|| sort_order(self.lens.path(), descending))
560 }
561}
562
563#[derive_where(Clone, Debug)]
579pub struct ComputedColumn<M> {
580 name: String,
581 label: String,
582 #[derive_where(skip(Debug))]
583 project: Arc<dyn Fn(&M) -> String + Send + Sync>,
584 width: ColumnWidth,
585 includes: Includes<M>,
587}
588
589impl<M> ComputedColumn<M>
590where
591 M: toasty::schema::Model,
592{
593 pub fn new(
595 label: impl Into<String>,
596 project: impl Fn(&M) -> String + Send + Sync + 'static,
597 ) -> Self {
598 let label = label.into();
599 Self {
600 name: label.to_lowercase(),
601 label,
602 project: Arc::new(project),
603 width: ColumnWidth::Narrow,
604 includes: Includes::new(),
605 }
606 }
607
608 pub fn include<T>(mut self, relation: impl Into<toasty::stmt::Include<M, T>>) -> Self {
610 self.includes = self.includes.with(relation);
611 self
612 }
613
614 pub fn width(mut self, width: ColumnWidth) -> Self {
616 self.width = width;
617 self
618 }
619}
620
621impl<M> Column<M> for ComputedColumn<M>
622where
623 M: toasty::schema::Model + Send + Sync + 'static,
624{
625 fn name(&self) -> &str {
626 &self.name
627 }
628
629 fn label(&self) -> &str {
630 &self.label
631 }
632
633 fn text(&self, _cx: &Cx, row: &M) -> String {
634 (self.project)(row)
635 }
636
637 fn column_width(&self) -> ColumnWidth {
638 self.width
639 }
640
641 fn includes(&self) -> Includes<M> {
642 self.includes.clone()
643 }
644}
645
646#[derive_where(Clone, Debug)]
654pub struct BooleanColumn<M> {
655 lens: Lens<M, bool>,
656 base: ColumnBase,
657 sortable: bool,
658 labels: (String, String),
659}
660
661impl<M> BooleanColumn<M>
662where
663 M: toasty::schema::Model,
664{
665 pub fn new(lens: Lens<M, bool>) -> Self {
667 Self {
668 base: ColumnBase::new(lens.path(), ColumnWidth::Narrow),
669 lens,
670 sortable: false,
671 labels: ("Yes".to_string(), "No".to_string()),
672 }
673 }
674
675 base_builders!(sortable);
676
677 pub fn labels(mut self, yes: impl Into<String>, no: impl Into<String>) -> Self {
679 self.labels = (yes.into(), no.into());
680 self
681 }
682}
683
684impl<M> Column<M> for BooleanColumn<M>
685where
686 M: toasty::schema::Model + Send + Sync + 'static,
687{
688 base_column_methods!(bind);
689
690 fn text(&self, _cx: &Cx, row: &M) -> String {
691 if *self.lens.read(row) {
692 self.labels.0.clone()
693 } else {
694 self.labels.1.clone()
695 }
696 }
697
698 fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
699 let value = *self.lens.read(row);
700 let text = self.text(cx, row);
701 let (data, class) = if value {
702 (tablo_ui::icons::CIRCLE_CHECK, "size-4 text-primary")
703 } else {
704 (tablo_ui::icons::CIRCLE_X, "size-4 text-destructive")
705 };
706 view! {
707 cx =>
708 <span class="inline-flex items-center" data-boolean=(value.to_string())>
709 icon(
710 data: data,
711 attrs: attributes! { class=(class) aria-hidden="true" }
712 )
713 <span class="sr-only">(text)</span>
714 </span>
715 }
716 .boxed()
717 }
718
719 fn is_sortable(&self) -> bool {
720 self.sortable
721 }
722
723 fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
724 self.sortable
725 .then(|| sort_order(self.lens.path(), descending))
726 }
727}
728
729#[derive_where(Clone, Debug)]
740pub struct FileColumn<M> {
741 lens: Lens<M, String>,
742 base: ColumnBase,
743}
744
745impl<M> FileColumn<M>
746where
747 M: toasty::schema::Model,
748{
749 pub fn new(lens: Lens<M, String>) -> Self {
751 Self {
752 base: ColumnBase::new(lens.path(), ColumnWidth::Wide),
753 lens,
754 }
755 }
756
757 base_builders!();
758}
759
760impl<M> Column<M> for FileColumn<M>
761where
762 M: toasty::schema::Model + Send + Sync + 'static,
763{
764 base_column_methods!(bind);
765
766 fn text(&self, _cx: &Cx, row: &M) -> String {
767 self.lens.read(row).clone()
768 }
769
770 fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
771 let path = self.lens.read(row);
772 if path.trim().is_empty() {
773 return crate::schema::value_cell(cx, path);
774 }
775 crate::schema::stored_upload(cx, path)
776 }
777}
778
779pub(crate) type BoxColumn<M> = Arc<dyn Column<M>>;
781
782pub trait IntoColumns<M> {
787 #[doc(hidden)]
788 fn into_columns(self) -> Vec<BoxColumn<M>>;
789}
790
791macro_rules! column_conversions {
793 ($($ty:ident<$($g:ident),+>),+ $(,)?) => {
794 $(
795 impl<$($g),+> IntoColumns<M> for $ty<$($g),+>
796 where
797 Self: Column<M> + 'static,
798 {
799 fn into_columns(self) -> Vec<BoxColumn<M>> {
800 vec![Arc::new(self)]
801 }
802 }
803
804 impl<$($g),+> IntoDetail<M> for $ty<$($g),+>
805 where
806 Self: Column<M> + 'static,
807 {
808 fn into_detail(self) -> Detail<M> {
809 Detail::empty().column(self)
810 }
811 }
812 )+
813 };
814}
815
816column_conversions!(
817 TextColumn<M, T>,
818 DateColumn<M, T>,
819 ComputedColumn<M>,
820 RelationColumn<M>,
821 CountColumn<M>,
822 BooleanColumn<M>,
823 FileColumn<M>,
824 EmbeddedColumn<M, T>,
825 RepeaterColumn<M, T>,
826);
827
828impl<M> IntoColumns<M> for () {
829 fn into_columns(self) -> Vec<BoxColumn<M>> {
830 Vec::new()
831 }
832}
833
834impl<M> IntoColumns<M> for Vec<BoxColumn<M>> {
835 fn into_columns(self) -> Vec<BoxColumn<M>> {
836 self
837 }
838}
839
840impl<M> IntoColumns<M> for &[BoxColumn<M>] {
841 fn into_columns(self) -> Vec<BoxColumn<M>> {
842 self.to_vec()
843 }
844}
845
846macro_rules! into_columns_tuples {
848 ($($T:ident => $v:ident),+ $(,)?) => {
849 impl<M, $($T),+> IntoColumns<M> for ($($T,)+)
850 where
851 $($T: Column<M> + 'static,)+
852 {
853 fn into_columns(self) -> Vec<BoxColumn<M>> {
854 let ($($v,)+) = self;
855 vec![$(Arc::new($v) as BoxColumn<M>,)+]
856 }
857 }
858 };
859}
860
861into_columns_tuples!(A => a);
862into_columns_tuples!(A => a, B => b);
863into_columns_tuples!(A => a, B => b, C => c);
864into_columns_tuples!(A => a, B => b, C => c, D => d);
865into_columns_tuples!(A => a, B => b, C => c, D => d, E => e);
866into_columns_tuples!(A => a, B => b, C => c, D => d, E => e, F => f);
867into_columns_tuples!(A => a, B => b, C => c, D => d, E => e, F => f, G => g);
868into_columns_tuples!(
869 A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h
870);
871into_columns_tuples!(
872 A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h, I => i
873);
874into_columns_tuples!(
875 A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h, I => i, J => j
876);
877into_columns_tuples!(
878 A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h, I => i, J => j, K => k
879);
880into_columns_tuples!(
881 A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h, I => i, J => j, K => k, L => l
882);
883
884#[cfg(test)]
885mod tests;