use std::{borrow::Cow, sync::Arc};
use toasty::stmt::{Expr, OrderByExpr};
use topcoat::{context::Cx, icon::icon, view::*};
use crate::{
Lens,
form::FormScalar,
schema::{Binding, FieldResolver},
};
pub trait Column<M>: Send + Sync {
fn name(&self) -> &str;
fn label(&self) -> &str;
fn text(&self, row: &M) -> String;
fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
let text = self.text(row);
view! { cx => (text) }.boxed()
}
fn column_width(&self) -> ColumnWidth {
ColumnWidth::Narrow
}
fn is_searchable(&self) -> bool {
false
}
fn search_expr(&self, _term: &str) -> Option<Expr<bool>> {
None
}
fn is_sortable(&self) -> bool {
false
}
fn order_by(&self, _descending: bool) -> Option<OrderByExpr> {
None
}
fn includes(&self) -> Includes<M> {
Includes::new()
}
#[doc(hidden)]
fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
None
}
#[doc(hidden)]
fn bind(&self, _resolver: &FieldResolver) {}
}
pub struct Includes<M>(
Vec<crate::toasty_compat::UntypedInclude>,
std::marker::PhantomData<fn() -> M>,
);
impl<M> Default for Includes<M> {
fn default() -> Self {
Self(Vec::new(), std::marker::PhantomData)
}
}
impl<M> Clone for Includes<M> {
fn clone(&self) -> Self {
Self(self.0.clone(), std::marker::PhantomData)
}
}
impl<M> std::fmt::Debug for Includes<M> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("Includes").field(&self.0).finish()
}
}
impl<M> Includes<M> {
pub fn new() -> Self {
Self::default()
}
pub fn with<T>(mut self, relation: impl Into<toasty::stmt::Include<M, T>>) -> Self {
let include: crate::toasty_compat::UntypedInclude = relation.into().into();
if !self.0.contains(&include) {
self.0.push(include);
}
self
}
pub(crate) fn into_vec(self) -> Vec<crate::toasty_compat::UntypedInclude> {
self.0
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
pub(crate) const NARROW_DEFAULT_PERCENT: u8 = 10;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ColumnWidth {
#[default]
Wide,
Narrow,
Rem(u8),
Percent(u8),
}
impl ColumnWidth {
pub(crate) fn default_percent(self) -> Option<u8> {
match self {
Self::Narrow => Some(NARROW_DEFAULT_PERCENT),
Self::Wide | Self::Rem(_) | Self::Percent(_) => None,
}
}
pub(crate) fn explicit_css(self) -> Option<Cow<'static, str>> {
match self {
Self::Rem(rem) => Some(Cow::Owned(format!("width: {rem}rem"))),
Self::Percent(percent) => Some(Cow::Owned(format!("width: {percent}%"))),
Self::Wide | Self::Narrow => None,
}
}
}
pub struct TextColumn<M, T> {
lens: Lens<M, T>,
binding: Binding,
format: Arc<dyn Fn(&T) -> String + Send + Sync>,
search: Option<SearchFn>,
sortable: bool,
width: ColumnWidth,
}
type SearchFn = Arc<dyn Fn(String) -> Expr<bool> + Send + Sync>;
pub(crate) const LIKE_ESCAPE: char = '\\';
pub(crate) fn escape_like_pattern(term: &str) -> String {
let mut pattern = String::with_capacity(term.len() + 2);
pattern.push('%');
for c in term.chars() {
if c == LIKE_ESCAPE || c == '%' || c == '_' {
pattern.push(LIKE_ESCAPE);
}
pattern.push(c);
}
pattern.push('%');
pattern
}
pub fn contains_expr<M, T>(lens: &Lens<M, T>, term: &str) -> Option<Expr<bool>>
where
M: toasty::schema::Model,
T: toasty::schema::Field<Inner = String>,
{
let trimmed = term.trim();
(!trimmed.is_empty()).then(|| {
lens.path()
.clone()
.like_with_escape(escape_like_pattern(trimmed), LIKE_ESCAPE)
})
}
impl<M, T> TextColumn<M, T>
where
M: toasty::schema::Model,
{
pub fn new(lens: Lens<M, T>) -> Self
where
T: FormScalar + Send + Sync + 'static,
{
let binding = Binding::of(&lens.path().clone());
Self {
lens,
binding,
format: Arc::new(T::to_form),
search: None,
sortable: false,
width: ColumnWidth::Wide,
}
}
pub fn format(mut self, format: impl Fn(&T) -> String + Send + Sync + 'static) -> Self {
self.format = Arc::new(format);
self
}
pub fn sortable(mut self) -> Self {
self.sortable = true;
self
}
pub fn width(mut self, width: ColumnWidth) -> Self {
self.width = width;
self
}
}
impl<M, T> TextColumn<M, T>
where
M: toasty::schema::Model + Send + Sync + 'static,
T: toasty::schema::Field<Inner = String> + Send + Sync + 'static,
{
pub fn searchable(mut self) -> Self {
let path = self.lens.path().clone();
self.search = Some(Arc::new(move |pattern| {
path.clone().like_with_escape(pattern, LIKE_ESCAPE)
}));
self
}
}
impl<M, T> Column<M> for TextColumn<M, T>
where
M: toasty::schema::Model + Send + Sync + 'static,
T: Send + Sync + 'static,
{
fn name(&self) -> &str {
self.binding.name()
}
fn label(&self) -> &str {
self.binding.label()
}
fn text(&self, row: &M) -> String {
(self.format)(self.lens.read(row))
}
fn column_width(&self) -> ColumnWidth {
self.width
}
fn is_searchable(&self) -> bool {
self.search.is_some()
}
fn search_expr(&self, term: &str) -> Option<Expr<bool>> {
let t = term.trim();
let search = self.search.as_ref()?;
(!t.is_empty()).then(|| search(escape_like_pattern(t)))
}
fn is_sortable(&self) -> bool {
self.sortable
}
fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
self.sortable.then(|| {
let path = self.lens.path().clone();
if descending { path.desc() } else { path.asc() }
})
}
fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
self.binding.misdeclared()
}
fn bind(&self, resolver: &FieldResolver) {
self.binding.bind(resolver);
}
}
impl<M, T> Clone for TextColumn<M, T> {
fn clone(&self) -> Self {
Self {
lens: self.lens.clone(),
binding: self.binding.clone(),
format: Arc::clone(&self.format),
search: self.search.clone(),
sortable: self.sortable,
width: self.width,
}
}
}
impl<M, T> std::fmt::Debug for TextColumn<M, T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TextColumn")
.field("name", &self.binding.name())
.field("label", &self.binding.label())
.field("searchable", &self.search.is_some())
.field("sortable", &self.sortable)
.field("width", &self.width)
.finish_non_exhaustive()
}
}
pub struct ComputedColumn<M> {
name: String,
label: String,
project: Arc<dyn Fn(&M) -> String + Send + Sync>,
width: ColumnWidth,
includes: Includes<M>,
}
impl<M> ComputedColumn<M>
where
M: toasty::schema::Model,
{
pub fn new(
label: impl Into<String>,
project: impl Fn(&M) -> String + Send + Sync + 'static,
) -> Self {
let label = label.into();
Self {
name: label.to_lowercase(),
label,
project: Arc::new(project),
width: ColumnWidth::Narrow,
includes: Includes::new(),
}
}
pub fn include<T>(mut self, relation: impl Into<toasty::stmt::Include<M, T>>) -> Self {
self.includes = self.includes.with(relation);
self
}
pub fn width(mut self, width: ColumnWidth) -> Self {
self.width = width;
self
}
}
impl<M> Column<M> for ComputedColumn<M>
where
M: toasty::schema::Model + Send + Sync + 'static,
{
fn name(&self) -> &str {
&self.name
}
fn label(&self) -> &str {
&self.label
}
fn text(&self, row: &M) -> String {
(self.project)(row)
}
fn column_width(&self) -> ColumnWidth {
self.width
}
fn includes(&self) -> Includes<M> {
self.includes.clone()
}
}
impl<M> Clone for ComputedColumn<M> {
fn clone(&self) -> Self {
Self {
name: self.name.clone(),
label: self.label.clone(),
project: Arc::clone(&self.project),
width: self.width,
includes: self.includes.clone(),
}
}
}
impl<M> std::fmt::Debug for ComputedColumn<M> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ComputedColumn")
.field("name", &self.name)
.field("label", &self.label)
.field("width", &self.width)
.field("includes", &self.includes.len())
.finish_non_exhaustive()
}
}
pub struct BooleanColumn<M> {
lens: Lens<M, bool>,
binding: Binding,
sortable: bool,
labels: (String, String),
width: ColumnWidth,
}
impl<M> BooleanColumn<M>
where
M: toasty::schema::Model,
{
pub fn new(lens: Lens<M, bool>) -> Self {
let binding = Binding::of(&lens.path().clone());
Self {
lens,
binding,
sortable: false,
labels: ("Yes".to_string(), "No".to_string()),
width: ColumnWidth::Narrow,
}
}
pub fn sortable(mut self) -> Self {
self.sortable = true;
self
}
pub fn width(mut self, width: ColumnWidth) -> Self {
self.width = width;
self
}
pub fn labels(mut self, yes: impl Into<String>, no: impl Into<String>) -> Self {
self.labels = (yes.into(), no.into());
self
}
}
impl<M> Column<M> for BooleanColumn<M>
where
M: toasty::schema::Model + Send + Sync + 'static,
{
fn name(&self) -> &str {
self.binding.name()
}
fn label(&self) -> &str {
self.binding.label()
}
fn text(&self, row: &M) -> String {
if *self.lens.read(row) {
self.labels.0.clone()
} else {
self.labels.1.clone()
}
}
fn column_width(&self) -> ColumnWidth {
self.width
}
fn cell<'a>(&self, cx: &'a Cx, row: &M) -> BoxView<'a> {
let value = *self.lens.read(row);
let text = self.text(row);
let (data, class) = if value {
(tablo_ui::icons::CIRCLE_CHECK, "size-4 text-primary")
} else {
(tablo_ui::icons::X, "size-4 text-muted-foreground")
};
view! {
cx =>
<span class="inline-flex items-center" data-boolean=(value.to_string())>
icon(
data: data,
attrs: attributes! { class=(class) aria-hidden="true" }
)
<span class="sr-only">(text)</span>
</span>
}
.boxed()
}
fn is_sortable(&self) -> bool {
self.sortable
}
fn order_by(&self, descending: bool) -> Option<OrderByExpr> {
self.sortable.then(|| {
let path = self.lens.path().clone();
if descending { path.desc() } else { path.asc() }
})
}
fn misdeclared(&self) -> Option<crate::DeclarationErrorKind> {
self.binding.misdeclared()
}
fn bind(&self, resolver: &FieldResolver) {
self.binding.bind(resolver);
}
}
impl<M> Clone for BooleanColumn<M> {
fn clone(&self) -> Self {
Self {
lens: self.lens.clone(),
binding: self.binding.clone(),
sortable: self.sortable,
labels: self.labels.clone(),
width: self.width,
}
}
}
impl<M> std::fmt::Debug for BooleanColumn<M> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BooleanColumn")
.field("name", &self.binding.name())
.field("label", &self.binding.label())
.field("sortable", &self.sortable)
.field("width", &self.width)
.finish_non_exhaustive()
}
}
/// A table's columns, as the table stores them.
pub(crate) type BoxColumn<M> = Arc<dyn Column<M>>;
/// Convert a single built-in column, a tuple of any [`Column`]s, a `Vec` or slice
/// of boxed columns, or `()` for none yet, into a table's column list.
///
/// [`Table::column`](super::Table::column) appends past it.
pub trait IntoColumns<M> {
#[doc(hidden)]
fn into_columns(self) -> Vec<BoxColumn<M>>;
}
impl<M, T> IntoColumns<M> for TextColumn<M, T>
where
M: toasty::schema::Model + Send + Sync + 'static,
T: Send + Sync + 'static,
{
fn into_columns(self) -> Vec<BoxColumn<M>> {
vec![Arc::new(self)]
}
}
impl<M> IntoColumns<M> for ComputedColumn<M>
where
M: toasty::schema::Model + Send + Sync + 'static,
{
fn into_columns(self) -> Vec<BoxColumn<M>> {
vec![Arc::new(self)]
}
}
impl<M> IntoColumns<M> for BooleanColumn<M>
where
M: toasty::schema::Model + Send + Sync + 'static,
{
fn into_columns(self) -> Vec<BoxColumn<M>> {
vec![Arc::new(self)]
}
}
impl<M> IntoColumns<M> for () {
fn into_columns(self) -> Vec<BoxColumn<M>> {
Vec::new()
}
}
impl<M> IntoColumns<M> for Vec<BoxColumn<M>> {
fn into_columns(self) -> Vec<BoxColumn<M>> {
self
}
}
impl<M> IntoColumns<M> for &[BoxColumn<M>] {
fn into_columns(self) -> Vec<BoxColumn<M>> {
self.to_vec()
}
}
/// The tuple impls of [`IntoColumns`], one arity per invocation.
macro_rules! into_columns_tuples {
($($T:ident => $v:ident),+ $(,)?) => {
impl<M, $($T),+> IntoColumns<M> for ($($T,)+)
where
$($T: Column<M> + 'static,)+
{
fn into_columns(self) -> Vec<BoxColumn<M>> {
let ($($v,)+) = self;
vec![$(Arc::new($v) as BoxColumn<M>,)+]
}
}
};
}
into_columns_tuples!(A => a);
into_columns_tuples!(A => a, B => b);
into_columns_tuples!(A => a, B => b, C => c);
into_columns_tuples!(A => a, B => b, C => c, D => d);
into_columns_tuples!(A => a, B => b, C => c, D => d, E => e);
into_columns_tuples!(A => a, B => b, C => c, D => d, E => e, F => f);
into_columns_tuples!(A => a, B => b, C => c, D => d, E => e, F => f, G => g);
into_columns_tuples!(
A => a, B => b, C => c, D => d, E => e, F => f, G => g, H => h
);
#[cfg(test)]
mod tests;