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