tablo-core 0.8.0

The core toolkit types for Tablo, a server-rendered admin toolkit on Topcoat and Toasty.
Documentation
//! A declared [`Table`] together with what one request wires onto it.

use std::{ops::Deref, sync::Arc};

use topcoat::context::Cx;

use super::TableSignals;
use crate::{
    resource::Trashed,
    schema::Schema,
    table::{BoxColumn, ColumnChoice, Table, TableState},
};

/// Per-record action policy reads which row actions one model instance allows.
pub(crate) type RowPolicy<M> = Arc<dyn Fn(&M) -> RowActions + Send + Sync>;

/// Which row actions one record may use.
///
/// A denied action emits no link, and a row denied `delete` renders no bulk checkbox.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct RowActions {
    /// Whether the row renders its `View` link.
    pub view: bool,
    /// Whether the row renders its `Edit` link.
    pub edit: bool,
    /// Whether the row renders its `Delete` link and an enabled bulk checkbox.
    pub delete: bool,
}

impl RowActions {
    /// Every action allowed.
    pub const ALL: Self = Self {
        view: true,
        edit: true,
        delete: true,
    };
}

/// The action chrome a resource declares.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct TableChrome {
    /// Whether the row renders a Delete action (which also enables bulk).
    pub(crate) delete: bool,
    /// Whether the row renders an Edit action.
    pub(crate) edit: bool,
    /// Whether the row renders a View action.
    pub(crate) view: bool,
    /// Whether the table renders the resource's custom actions.
    pub(crate) actions: bool,
}

/// One custom [`Action`](crate::resource::Action) as a table renders it.
pub(crate) struct TableAction<M> {
    pub(crate) name: &'static str,
    pub(crate) label: String,
    pub(crate) row: bool,
    pub(crate) bulk: bool,
    /// Whether the action confirms: in the confirmation dialog, or in its input dialog.
    pub(crate) confirm: bool,
    /// The action's input form, when it asks for one: its buttons open the input's dialog.
    pub(crate) input: Option<fn() -> Schema>,
    /// The records it takes: the live ones, or a soft-deleting resource's trashed ones.
    pub(crate) trashed: Trashed,
    pub(crate) allowed: Arc<dyn Fn(&M) -> bool + Send + Sync>,
}

/// What a request wires onto a declared table: the action URLs its rows and bulk bar link to,
/// and which actions each row allows.
///
/// The panel builds it per request from the mounted resource and the request's policy, so the
/// declaration itself carries none of it.
pub(crate) struct Wiring<M> {
    row_policy: Option<RowPolicy<M>>,
    delete_prefix: Option<String>,
    edit_prefix: Option<String>,
    view_prefix: Option<String>,
    bulk_delete: bool,
    /// The custom actions and the list URL their routes hang off.
    custom_actions: Vec<TableAction<M>>,
    actions_prefix: Option<String>,
    /// Where a write this table's row and bulk actions start lands.
    return_to: Option<String>,
    /// The prefix the table's URL parameters carry.
    prefix: Option<String>,
    /// The reader's choice of toggleable columns.
    choice: ColumnChoice,
    /// Where the column picker posts a new choice; `None` renders no picker.
    columns_url: Option<String>,
}

impl<M> Default for Wiring<M> {
    fn default() -> Self {
        Self {
            row_policy: None,
            delete_prefix: None,
            edit_prefix: None,
            view_prefix: None,
            bulk_delete: false,
            custom_actions: Vec::new(),
            actions_prefix: None,
            return_to: None,
            prefix: None,
            choice: ColumnChoice::default(),
            columns_url: None,
        }
    }
}

/// A declared [`Table`] with one request's actions and policy wired on: what the list page
/// renders.
///
/// [`wired_table`](crate::panel::wired_table) returns the panel's for a resource. It reads as
/// its [`Table`] for loading a [`TablePage`](crate::TablePage).
pub struct WiredTable<M> {
    table: Arc<Table<M>>,
    pub(super) wiring: Wiring<M>,
}

impl<M> Deref for WiredTable<M> {
    type Target = Table<M>;

    fn deref(&self) -> &Table<M> {
        &self.table
    }
}

impl<M> std::fmt::Debug for WiredTable<M> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let wiring = &self.wiring;
        f.debug_struct("WiredTable")
            .field("table", &self.table)
            .field("row_policy", &wiring.row_policy.is_some())
            .field("delete_prefix", &wiring.delete_prefix)
            .field("edit_prefix", &wiring.edit_prefix)
            .field("view_prefix", &wiring.view_prefix)
            .field("bulk_delete", &wiring.bulk_delete)
            .field(
                "custom_actions",
                &wiring
                    .custom_actions
                    .iter()
                    .map(|a| a.name)
                    .collect::<Vec<_>>(),
            )
            .field("return_to", &wiring.return_to)
            .field("prefix", &wiring.prefix)
            .field("choice", &wiring.choice)
            .field("columns_url", &wiring.columns_url)
            .finish()
    }
}

impl<M> WiredTable<M> {
    /// `table` with nothing wired: no actions.
    pub(crate) fn new(table: Arc<Table<M>>) -> Self {
        Self {
            table,
            wiring: Wiring::default(),
        }
    }

    /// Declare the per-record action policy.
    pub(crate) fn row_actions(
        mut self,
        policy: impl Fn(&M) -> RowActions + Send + Sync + 'static,
    ) -> Self {
        self.wiring.row_policy = Some(Arc::new(policy));
        self
    }

    /// Enable the row-level `Delete` action.
    pub(crate) fn with_delete(mut self, prefix: String) -> Self {
        if self.is_addressable() {
            self.wiring.delete_prefix = Some(prefix);
        }
        self
    }

    /// Enable the row-level `Edit` action.
    pub(crate) fn with_edit(mut self, prefix: String) -> Self {
        if self.is_addressable() {
            self.wiring.edit_prefix = Some(prefix);
        }
        self
    }

    /// Enable the row-level `View` action.
    pub(crate) fn with_view(mut self, prefix: String) -> Self {
        if self.is_addressable() {
            self.wiring.view_prefix = Some(prefix);
        }
        self
    }

    /// Enable bulk selection with the `BulkDelete` action.
    pub(crate) fn with_bulk_delete(mut self, enabled: bool) -> Self {
        self.wiring.bulk_delete = enabled;
        self
    }

    /// Wire the resource's custom actions.
    pub(crate) fn with_custom_actions(
        mut self,
        prefix: String,
        actions: Vec<TableAction<M>>,
    ) -> Self {
        if self.is_addressable() {
            self.wiring.actions_prefix = Some(prefix);
            self.wiring.custom_actions = actions;
        }
        self
    }

    /// Show the columns `choice` shows, and let the reader post a new choice to `url`. A choice
    /// hiding every column would leave no table to read, so the defaults show instead.
    pub(crate) fn with_column_choice(mut self, choice: ColumnChoice, url: Option<String>) -> Self {
        let shows_any = self
            .declared_columns()
            .iter()
            .any(|col| choice.shows(col.name(), col.toggle()));
        if shows_any {
            self.wiring.choice = choice;
        }
        self.wiring.columns_url = url;
        self
    }

    /// Send the writes this table starts back to `url`.
    pub(crate) fn returning_to(mut self, url: String) -> Self {
        self.wiring.return_to = Some(url);
        self
    }

    /// Spell the table's URL parameters `{prefix}.{name}`, so a page renders several tables and
    /// each keeps its own state and browser controls.
    #[must_use]
    pub fn prefixed(mut self, prefix: impl Into<String>) -> Self {
        self.wiring.prefix = Some(prefix.into());
        self
    }

    /// Keeps the delete and the custom actions that take some of the records `shown` admits, so
    /// the bulk bar of a list showing only trashed records offers what a trashed record takes.
    pub(crate) fn taking(mut self, shown: Trashed) -> Self {
        // Some record, live or trashed, is both shown and taken.
        let overlaps = |taken: Trashed| {
            [false, true]
                .into_iter()
                .any(|trashed| shown.admits(trashed) && taken.admits(trashed))
        };
        self.wiring
            .custom_actions
            .retain(|action| overlaps(action.trashed));
        if !overlaps(Trashed::Without) {
            self.wiring.bulk_delete = false;
        }
        self
    }

    /// The table's signals and the list state they hold. Creates the signals, so it carries
    /// [`topcoat::runtime::signal`]'s contract: call it while a page body runs.
    pub(crate) fn browser_state(&self, cx: &Cx) -> (TableSignals, TableState) {
        let prefix = self.wiring.prefix.as_deref();
        let signals = TableSignals::new(cx, prefix);
        let state = self.normalize_state(&signals.state(prefix));
        (signals, state)
    }

    /// Which row actions `record` allows.
    pub(crate) fn actions_for(&self, record: &M) -> RowActions {
        self.wiring
            .row_policy
            .as_ref()
            .map_or(RowActions::ALL, |policy| policy(record))
    }

    /// Whether the reader's choice shows `col`.
    pub(super) fn shows(&self, col: &BoxColumn<M>) -> bool {
        self.wiring.choice.shows(col.name(), col.toggle())
    }

    /// The declared columns the reader's choice shows, in declaration order.
    pub(super) fn visible_columns(&self) -> impl Iterator<Item = &BoxColumn<M>> {
        self.declared_columns().iter().filter(|col| self.shows(col))
    }

    /// Where the column picker posts, when the table renders one.
    pub(super) fn columns_url(&self) -> Option<&str> {
        self.wiring.columns_url.as_deref()
    }

    pub(super) fn return_to(&self) -> Option<&str> {
        self.wiring.return_to.as_deref()
    }

    pub(super) fn delete_prefix(&self) -> Option<&str> {
        self.wiring.delete_prefix.as_deref()
    }

    pub(super) fn edit_prefix(&self) -> Option<&str> {
        self.wiring.edit_prefix.as_deref()
    }

    pub(super) fn view_prefix(&self) -> Option<&str> {
        self.wiring.view_prefix.as_deref()
    }

    pub(super) fn actions_prefix(&self) -> Option<&str> {
        self.wiring.actions_prefix.as_deref()
    }

    pub(super) fn row_custom_actions(&self) -> impl Iterator<Item = &TableAction<M>> {
        self.wiring
            .custom_actions
            .iter()
            .filter(|a| a.row && self.wiring.actions_prefix.is_some())
    }

    pub(super) fn bulk_custom_actions(&self) -> impl Iterator<Item = &TableAction<M>> {
        self.wiring
            .custom_actions
            .iter()
            .filter(|a| a.bulk && self.wiring.actions_prefix.is_some())
    }

    pub(super) fn bulk_delete_enabled(&self) -> bool {
        self.wiring.bulk_delete && self.wiring.delete_prefix.is_some()
    }
}

#[cfg(test)]
impl<M> Table<M> {
    /// This table with nothing wired yet, for a test that wires it by hand.
    pub(crate) fn wired(self) -> WiredTable<M> {
        WiredTable::new(Arc::new(self))
    }
}