use std::{marker::PhantomData, num::NonZeroUsize, sync::Arc};
use derive_where::derive_where;
use toasty::stmt::{Expr, List};
use topcoat::context::Cx;
pub(crate) use self::choice::ColumnChoice;
use self::filter::BoxFilter;
pub(crate) use crate::column::{BoxColumn, Column, include_relations, restrict_relations};
use crate::{DeclarationErrorKind, Lens, dom::ignored, schema::Binding, values::FormScalar};
mod choice;
mod export;
pub(crate) mod filter;
mod mask;
pub(crate) mod model;
mod page;
mod search;
mod sort;
mod state;
pub use self::{
filter::{DateFilter, FilterValue, IntoFilters, QueryFilter, SelectFilter, TernaryFilter},
model::ModelColumns,
page::{DEFAULT_PAGE_SIZE, DEFAULT_PER_PAGE_OPTIONS, PagePosition, Pagination, TablePage},
state::{Cursor, Sort, TableState},
};
pub(crate) use self::{
page::{Past, row_exists_past},
state::{bulk_key, bulk_token, group_header_dom_id, query_of, request_query, row_dom_id},
};
pub(crate) use crate::{
column::{ColumnToggle, ColumnWidth, IntoColumns, contains_expr},
display::Alignment,
};
pub(crate) type GroupKey<M> = Arc<dyn Fn(&M) -> crate::Text + Send + Sync>;
#[derive_where(Clone)]
pub(crate) struct GroupDef<M> {
binding: Binding,
key: GroupKey<M>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ActionLayout {
#[default]
Inline,
Menu,
}
#[derive_where(Clone)]
pub struct Table<M> {
columns: Vec<BoxColumn<M>>,
misdeclared: Vec<DeclarationErrorKind>,
filters: Vec<BoxFilter<M>>,
group_by: Option<GroupDef<M>>,
key: fn(&M) -> String,
addressable: bool,
page_size: NonZeroUsize,
pagination: Pagination,
action_layout: ActionLayout,
per_page_options: Vec<usize>,
default_sort: Option<Sort>,
hide_search: bool,
hide_filter_menu: bool,
_marker: PhantomData<M>,
}
impl<M> std::fmt::Debug for Table<M> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Table")
.field(
"columns",
&self.columns.iter().map(|c| c.name()).collect::<Vec<_>>(),
)
.field("filters", &self.filters.len())
.field("group_by", &self.group_by.is_some())
.field("page_size", &self.page_size)
.field("pagination", &self.pagination)
.field("default_sort", &self.default_sort)
.field("hide_search", &self.hide_search)
.field("hide_filter_menu", &self.hide_filter_menu)
.finish()
}
}
impl<M> Table<M> {
pub fn new(cols: impl IntoColumns<M>) -> Self
where
M: toasty::schema::Model + toasty::stmt::IntoExpr<M>,
{
Self {
columns: cols.into_columns(),
misdeclared: Vec::new(),
filters: Vec::new(),
group_by: None,
key: crate::toasty_compat::pk::pk_text::<M>,
addressable: !crate::toasty_compat::pk::pk_is_composite::<M>(),
page_size: DEFAULT_PAGE_SIZE,
pagination: Pagination::default(),
action_layout: ActionLayout::default(),
per_page_options: DEFAULT_PER_PAGE_OPTIONS.to_vec(),
default_sort: None,
hide_search: false,
hide_filter_menu: false,
_marker: PhantomData,
}
}
pub fn column(mut self, column: impl Column<M> + 'static) -> Self {
self.columns.push(Arc::new(column));
self
}
pub fn filters(mut self, filters: impl IntoFilters<M>) -> Self
where
M: toasty::schema::Model,
{
let filters = filters.into_filters();
self.filters = filters;
self
}
pub(crate) fn has_toggleable_columns(&self) -> bool {
self.columns.iter().any(|c| c.toggle().is_toggleable())
}
pub(crate) fn declared_columns(&self) -> &[BoxColumn<M>] {
&self.columns
}
pub(crate) fn push_filter(&mut self, filter: BoxFilter<M>) {
self.filters.push(filter);
}
pub(crate) fn declared_filters(&self) -> &[BoxFilter<M>] {
&self.filters
}
pub(crate) fn include_relations(
&self,
query: toasty::stmt::Query<List<M>>,
) -> toasty::stmt::Query<List<M>>
where
M: toasty::schema::Model,
{
include_relations(query, &self.columns)
}
pub(crate) async fn restrict_relations(
&self,
cx: &topcoat::context::Cx,
rows: &[M],
ex: &mut dyn toasty::Executor,
) -> topcoat::Result<()> {
restrict_relations(cx, &self.columns, rows, ex).await
}
pub(crate) async fn refused_filters(
&self,
cx: &topcoat::context::Cx,
state: &TableState,
) -> topcoat::Result<Vec<String>> {
let mut refused = Vec::new();
for f in &self.filters {
if let Some(value) = state.filters.get(f.name())
&& !f.admits(cx, value).await?
{
refused.push(f.name().to_string());
}
}
Ok(refused)
}
pub(crate) fn filter_expr(
&self,
cx: &Cx,
state: &TableState,
refused: &[String],
) -> Option<Expr<bool>>
where
M: toasty::schema::Model,
{
let mut exprs = Vec::new();
for f in &self.filters {
if let Some(v) = state.filters.get(f.name())
&& !refused.iter().any(|name| name == f.name())
&& let Some(e) = f.to_expr(cx, v)
{
exprs.push(e);
}
}
if exprs.is_empty() {
None
} else {
let mut iter = exprs.into_iter();
let first = iter.next().unwrap();
Some(iter.fold(first, |acc, e| acc.and(e)))
}
}
pub(crate) fn unapplied_filters(
&self,
cx: &Cx,
state: &TableState,
refused: &[String],
) -> Vec<(String, &'static str)>
where
M: toasty::schema::Model,
{
let mut out = Vec::new();
for (key, value) in &state.filters {
match self.filters.iter().find(|f| f.name() == key) {
None => out.push((state.spell_filter(key, value), ignored::UNKNOWN)),
Some(f)
if refused.iter().any(|name| name == key)
|| (f.to_expr(cx, value).is_none() && !f.is_noop_value(value)) =>
{
out.push((state.spell_filter(key, value), ignored::INVALID))
}
Some(_) => {}
}
}
if state.filters_dropped {
out.push(("dropped filters".to_string(), ignored::DROPPED));
}
out.sort();
out
}
pub fn group_by<T>(mut self, lens: Lens<M, T>) -> Self
where
M: toasty::schema::Model + Send + Sync + 'static,
T: FormScalar + Send + Sync + 'static,
{
self.group_by = Some(GroupDef {
binding: Binding::of(lens.path()),
key: Arc::new(move |record| lens.read(record).to_label()),
});
self
}
pub(crate) fn effective_group_key(&self, state: &TableState) -> Option<GroupKey<M>> {
match (&self.group_by, &state.group_by) {
(Some(def), Some(want)) if def.binding.name() == want => Some(def.key.clone()),
_ => None,
}
}
pub(crate) fn normalize_state(&self, state: &TableState) -> TableState {
let mut out = state.clone();
if self.group_by.as_ref().map(|def| def.binding.name()) != out.group_by.as_deref() {
out.group_by = None;
}
out
}
pub fn pagination(mut self, pagination: Pagination) -> Self {
self.pagination = pagination;
self
}
pub fn action_layout(mut self, layout: ActionLayout) -> Self {
self.action_layout = layout;
self
}
pub fn per_page_options(mut self, options: impl IntoIterator<Item = usize>) -> Self {
self.per_page_options = options.into_iter().filter(|size| *size > 0).collect();
self
}
pub fn paginate(mut self, per_page: usize) -> Self {
match NonZeroUsize::new(per_page) {
Some(size) => self.page_size = size,
None => self.misdeclared.push(DeclarationErrorKind::ZeroPageSize),
}
self
}
pub(crate) fn unavailable_sources(
&self,
cx: &topcoat::context::Cx,
) -> Vec<DeclarationErrorKind> {
let mut errors = crate::column::unavailable_sources(cx, self.columns.iter().map(|c| &**c));
errors.extend(self.filters.iter().filter_map(|filter| {
filter.unavailable_source(cx).map(|source| {
DeclarationErrorKind::UnregisteredOptionSource {
field: filter.name().to_string(),
source,
}
})
}));
errors
}
pub fn declaration_errors(&self) -> Vec<DeclarationErrorKind> {
let mut errors = self.misdeclared.clone();
errors.extend(
self.group_by
.as_ref()
.and_then(|def| def.binding.misdeclared()),
);
if self.columns.is_empty() {
errors.push(DeclarationErrorKind::NoColumns);
}
if let Some(sort) = &self.default_sort
&& self.sortable_column(&sort.column).is_none()
{
errors.push(DeclarationErrorKind::UnsortableDefaultSort {
column: sort.column.clone(),
});
}
let mut seen = std::collections::HashSet::with_capacity(self.columns.len());
for column in &self.columns {
match column.misdeclared() {
Some(error) => errors.push(error),
None if !seen.insert(column.name()) => {
errors.push(DeclarationErrorKind::DuplicateColumn {
name: column.name().to_string(),
});
}
None => {}
}
}
let mut seen = std::collections::HashSet::with_capacity(self.filters.len());
for filter in &self.filters {
match filter.misdeclared() {
Some(error) => errors.push(error),
None if filter.name().contains('.') => {
errors.push(DeclarationErrorKind::DottedFilterName {
name: filter.name().to_string(),
});
}
None if !seen.insert(filter.name()) => {
errors.push(DeclarationErrorKind::DuplicateFilter {
name: filter.name().to_string(),
});
}
None => {}
}
}
errors
}
pub fn page_size(&self) -> usize {
self.page_size.get()
}
pub fn pagination_mode(&self) -> Pagination {
self.pagination
}
pub fn row_action_layout(&self) -> ActionLayout {
self.action_layout
}
pub(crate) fn page_sizes(&self) -> Vec<usize> {
if self.per_page_options.is_empty() || self.pagination == Pagination::Cursor {
return Vec::new();
}
let mut sizes = self.per_page_options.clone();
sizes.push(self.page_size());
sizes.sort_unstable();
sizes.dedup();
sizes
}
pub(crate) fn page_size_for(&self, state: &TableState) -> usize {
state
.per_page
.and_then(|asked| usize::try_from(asked).ok())
.filter(|asked| self.page_sizes().contains(asked))
.unwrap_or_else(|| self.page_size())
}
pub(crate) fn key_of(&self, record: &M) -> String {
(self.key)(record)
}
pub(crate) fn is_addressable(&self) -> bool {
self.addressable
}
pub fn hide_search(mut self) -> Self {
self.hide_search = true;
self
}
pub fn hide_filter_menu(mut self) -> Self {
self.hide_filter_menu = true;
self
}
pub(crate) fn apply_declaration(
&self,
cx: &Cx,
db: &toasty::Db,
query: toasty::stmt::Query<List<M>>,
state: &TableState,
refused: &[String],
) -> toasty::stmt::Query<List<M>>
where
M: toasty::schema::Model,
{
self.order(self.narrow(cx, db, query, state, refused), state)
}
pub(crate) fn narrow(
&self,
cx: &Cx,
db: &toasty::Db,
mut query: toasty::stmt::Query<List<M>>,
state: &TableState,
refused: &[String],
) -> toasty::stmt::Query<List<M>>
where
M: toasty::schema::Model,
{
if let Some(term) = &state.search
&& let Some(expr) = self.search_expr(term)
{
query = query.filter(crate::toasty_compat::case_insensitive_search(db, expr));
}
if let Some(expr) = self.filter_expr(cx, state, refused) {
query = query.filter(expr);
}
query
}
pub(crate) fn filter_menu_enabled(&self) -> bool {
!self.hide_filter_menu && !self.filters.is_empty()
}
}
pub(crate) fn ignored_reason(kind: &str) -> crate::Text {
match kind {
ignored::UNKNOWN => crate::text!("tablo-filter-ignored-unknown"),
ignored::DROPPED => crate::text!(
"tablo-filter-ignored-dropped",
filters = state::MAX_FILTERS,
values = state::MAX_FILTER_VALUES,
bytes = state::MAX_FILTER_LEN,
),
_ => crate::text!("tablo-filter-ignored-invalid"),
}
}
#[cfg(test)]
mod tests;