use std::{marker::PhantomData, num::NonZeroUsize, sync::Arc};
use toasty::stmt::{Expr, List, OrderByExpr};
use self::{column::BoxColumn, filter::BoxFilter};
use crate::{DeclarationErrorKind, Lens, form::FormScalar, schema::ResolvedLens};
mod column;
mod export;
mod filter;
mod page;
mod render;
mod state;
pub use self::{
column::{
BooleanColumn, Column, ColumnWidth, ComputedColumn, Includes, IntoColumns, TextColumn,
contains_expr,
},
filter::{
DateFilter, Filter, FilterInput, IntoFilters, QueryFilter, SelectFilter, TernaryFilter,
},
page::TablePage,
state::{Cursor, Sort, TableState},
};
pub(crate) use self::{
page::{Past, row_exists_past},
render::TABLE_CARD_CLASS,
state::{
ACTION_ROUTE_PARAM, ACTIONS_ROUTE_SEGMENT, BULK_DELETE_ROUTE_SEGMENT, CREATE_ROUTE_SEGMENT,
DASH_ROUTE_SEGMENT, DELETE_ROUTE_SEGMENT, EDIT_ROUTE_SEGMENT, RECORD_ROUTE_PARAM,
RETURN_PARAM, TableSignals, create_page_url, query_of, request_query, with_return,
},
};
pub(crate) type GroupKey<M> = Arc<dyn Fn(&M) -> String + Send + Sync>;
pub(crate) type RowPolicy<M> = Arc<dyn Fn(&M) -> RowActions + Send + Sync>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct RowActions {
pub view: bool,
pub edit: bool,
pub delete: bool,
}
impl RowActions {
pub const ALL: Self = Self {
view: true,
edit: true,
delete: true,
};
}
pub(crate) struct GroupDef<M> {
name: String,
key: GroupKey<M>,
}
impl<M> Clone for GroupDef<M> {
fn clone(&self) -> Self {
Self {
name: self.name.clone(),
key: Arc::clone(&self.key),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct TableChrome {
pub(crate) delete: bool,
pub(crate) edit: bool,
pub(crate) view: bool,
pub(crate) actions: bool,
}
pub(crate) struct TableAction<M> {
pub(crate) name: &'static str,
pub(crate) label: String,
pub(crate) row: bool,
pub(crate) bulk: bool,
pub(crate) allowed: Arc<dyn Fn(&M) -> bool + Send + Sync>,
}
impl<M> Clone for TableAction<M> {
fn clone(&self) -> Self {
Self {
name: self.name,
label: self.label.clone(),
row: self.row,
bulk: self.bulk,
allowed: Arc::clone(&self.allowed),
}
}
}
pub const DEFAULT_PAGE_SIZE: NonZeroUsize = NonZeroUsize::new(25).unwrap();
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,
row_policy: Option<RowPolicy<M>>,
page_size: NonZeroUsize,
hide_search: bool,
hide_filter_bar: bool,
delete_prefix: Option<String>,
edit_prefix: Option<String>,
view_prefix: Option<String>,
bulk_delete: bool,
custom_actions: Vec<TableAction<M>>,
actions_prefix: Option<String>,
live_search: bool,
framed: bool,
return_to: Option<String>,
_marker: PhantomData<M>,
}
impl<M> Clone for Table<M> {
fn clone(&self) -> Self {
Self {
columns: self.columns.clone(),
misdeclared: self.misdeclared.clone(),
filters: self.filters.clone(),
group_by: self.group_by.clone(),
key: self.key,
addressable: self.addressable,
row_policy: self.row_policy.clone(),
page_size: self.page_size,
hide_search: self.hide_search,
hide_filter_bar: self.hide_filter_bar,
delete_prefix: self.delete_prefix.clone(),
edit_prefix: self.edit_prefix.clone(),
view_prefix: self.view_prefix.clone(),
bulk_delete: self.bulk_delete,
custom_actions: self.custom_actions.clone(),
actions_prefix: self.actions_prefix.clone(),
live_search: self.live_search,
framed: self.framed,
return_to: self.return_to.clone(),
_marker: PhantomData,
}
}
}
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("row_policy", &self.row_policy.is_some())
.field("page_size", &self.page_size)
.field("hide_search", &self.hide_search)
.field("hide_filter_bar", &self.hide_filter_bar)
.field("delete_prefix", &self.delete_prefix)
.field("edit_prefix", &self.edit_prefix)
.field("view_prefix", &self.view_prefix)
.field("bulk_delete", &self.bulk_delete)
.field(
"custom_actions",
&self
.custom_actions
.iter()
.map(|a| a.name)
.collect::<Vec<_>>(),
)
.field("live_search", &self.live_search)
.field("framed", &self.framed)
.field("return_to", &self.return_to)
.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>(),
row_policy: None,
page_size: DEFAULT_PAGE_SIZE,
hide_search: false,
hide_filter_bar: false,
delete_prefix: None,
edit_prefix: None,
view_prefix: None,
bulk_delete: false,
custom_actions: Vec::new(),
actions_prefix: None,
live_search: false,
framed: true,
return_to: None,
_marker: PhantomData,
}
}
pub fn column(mut self, column: impl Column<M> + 'static) -> Self {
self.columns.push(Arc::new(column));
self
}
pub(crate) fn row_actions(
mut self,
policy: impl Fn(&M) -> RowActions + Send + Sync + 'static,
) -> Self {
self.row_policy = Some(Arc::new(policy));
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 include_relations(
&self,
mut query: toasty::stmt::Query<List<M>>,
) -> toasty::stmt::Query<List<M>>
where
M: toasty::schema::Model,
{
let mut seen: Vec<toasty_core::stmt::Include> = Vec::new();
for include in self.columns.iter().flat_map(|c| c.includes().into_vec()) {
if !seen.contains(&include) {
query = query.include(include.clone());
seen.push(include);
}
}
query
}
pub(crate) fn filter_expr(&self, state: &TableState) -> 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())
&& let Some(e) = f.to_expr(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, state: &TableState) -> Vec<(String, String)>
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((format!("{key}:{value}"), "unknown filter".to_string())),
Some(f) if f.to_expr(value).is_none() && !f.is_noop_value(value) => {
out.push((format!("{key}:{value}"), "invalid value".to_string()))
}
Some(_) => {}
}
}
if state.filters_dropped {
out.push((
"dropped filters".to_string(),
format!(
"more than {}, over {} bytes, or the retired filters= form",
state::MAX_FILTERS,
state::MAX_FILTER_LEN
),
));
}
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,
{
let binding = ResolvedLens::of(lens.path().clone());
self.misdeclared.extend(binding.misdeclared);
self.group_by = Some(GroupDef {
name: binding.name,
key: Arc::new(move |record| lens.read(record).to_form()),
});
self
}
fn effective_group_key(&self, state: &TableState) -> Option<GroupKey<M>> {
match (&self.group_by, &state.group_by) {
(Some(def), Some(want)) if def.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.name.as_str()) != out.group_by.as_deref() {
out.group_by = None;
}
out
}
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 fn declaration_errors(&self) -> Vec<DeclarationErrorKind> {
let mut errors = self.misdeclared.clone();
if self.columns.is_empty() {
errors.push(DeclarationErrorKind::NoColumns);
}
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 !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(crate) fn key_of(&self, record: &M) -> String {
(self.key)(record)
}
pub(crate) fn actions_for(&self, record: &M) -> RowActions {
self.row_policy
.as_ref()
.map_or(RowActions::ALL, |policy| policy(record))
}
pub fn hide_search(mut self) -> Self {
self.hide_search = true;
self
}
pub fn hide_filter_bar(mut self) -> Self {
self.hide_filter_bar = true;
self
}
pub(crate) fn unframed(mut self) -> Self {
self.framed = false;
self
}
pub fn live_search(mut self) -> Self {
self.live_search = true;
self
}
pub(crate) fn with_delete(mut self, prefix: String) -> Self {
if self.addressable {
self.delete_prefix = Some(prefix);
}
self
}
pub(crate) fn with_edit(mut self, prefix: String) -> Self {
if self.addressable {
self.edit_prefix = Some(prefix);
}
self
}
pub(crate) fn with_view(mut self, prefix: String) -> Self {
if self.addressable {
self.view_prefix = Some(prefix);
}
self
}
pub(crate) fn with_bulk_delete(mut self, enabled: bool) -> Self {
self.bulk_delete = enabled;
self
}
pub(crate) fn returning_to(mut self, url: String) -> Self {
self.return_to = Some(url);
self
}
fn action_url(&self, url: String) -> String {
match &self.return_to {
Some(target) => with_return(&url, target),
None => url,
}
}
pub(crate) fn with_custom_actions(
mut self,
prefix: String,
actions: Vec<TableAction<M>>,
) -> Self {
if self.addressable {
self.actions_prefix = Some(prefix);
self.custom_actions = actions;
}
self
}
fn row_custom_actions(&self) -> impl Iterator<Item = &TableAction<M>> {
self.custom_actions
.iter()
.filter(|a| a.row && self.actions_prefix.is_some())
}
fn bulk_custom_actions(&self) -> impl Iterator<Item = &TableAction<M>> {
self.custom_actions
.iter()
.filter(|a| a.bulk && self.actions_prefix.is_some())
}
fn bulk_delete_enabled(&self) -> bool {
self.bulk_delete && self.delete_prefix.is_some()
}
fn bulk_enabled(&self) -> bool {
self.bulk_delete_enabled() || self.bulk_custom_actions().next().is_some()
}
pub(crate) fn search_expr(&self, term: &str) -> Option<Expr<bool>>
where
M: toasty::schema::Model,
{
let t = term.trim();
if t.is_empty() {
return None;
}
let mut exprs = self.columns.iter().filter_map(|c| c.search_expr(t));
let first = exprs.next()?;
Some(exprs.fold(first, |acc, e| acc.or(e)))
}
pub(crate) fn order_by(&self, descending: bool) -> Option<OrderByExpr>
where
M: toasty::schema::Model,
{
self.columns.iter().find_map(|c| c.order_by(descending))
}
fn pk_order_bys() -> Vec<OrderByExpr>
where
M: toasty::schema::Model,
{
let app_model = M::schema();
let Some(root) = app_model.as_root() else {
debug_assert!(
false,
"pk_order_bys: {} is not a root model; deterministic pagination needs its primary key",
std::any::type_name::<M>()
);
return Vec::new();
};
root.primary_key
.fields
.iter()
.map(|fid| M::path_field::<toasty::stmt::Value>(fid.index).asc())
.collect()
}
pub(crate) fn order_bys_for(&self, state: &TableState) -> Vec<OrderByExpr>
where
M: toasty::schema::Model,
{
if let Some(sort) = &state.sort
&& let Some(col) = self
.columns
.iter()
.find(|c| c.is_sortable() && c.name() == sort.column)
&& let Some(ord) = col.order_by(sort.descending)
{
return vec![ord];
}
let out: Vec<OrderByExpr> = self.order_by(false).into_iter().collect();
if out.is_empty() {
return Self::pk_order_bys();
}
out
}
pub(crate) fn apply_declaration(
&self,
mut query: toasty::stmt::Query<List<M>>,
state: &TableState,
) -> 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(expr);
}
if let Some(expr) = self.filter_expr(state) {
query = query.filter(expr);
}
for ord in self.order_bys_for(state) {
query = query.order_by(ord);
}
query
}
pub(crate) fn search_enabled(&self) -> bool
where
M: toasty::schema::Model,
{
!self.hide_search && self.columns.iter().any(|c| c.is_searchable())
}
pub(crate) fn is_live_search(&self) -> bool {
self.live_search
}
pub(crate) fn filter_bar_enabled(&self) -> bool {
!self.hide_filter_bar && !self.filters.is_empty()
}
}
#[cfg(test)]
mod tests;