Skip to main content

tablo_core/panel/list/
wiring.rs

1//! A declared [`Table`] together with what one request wires onto it.
2
3use std::{ops::Deref, sync::Arc};
4
5use topcoat::context::Cx;
6
7use super::TableSignals;
8use crate::{
9    resource::Trashed,
10    schema::Schema,
11    table::{BoxColumn, ColumnChoice, Table, TableState},
12};
13
14/// Per-record action policy reads which row actions one model instance allows.
15pub(crate) type RowPolicy<M> = Arc<dyn Fn(&M) -> RowActions + Send + Sync>;
16
17/// Which row actions one record may use.
18///
19/// A denied action emits no link, and a row denied `delete` renders no bulk checkbox.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub(crate) struct RowActions {
22    /// Whether the row renders its `View` link.
23    pub view: bool,
24    /// Whether the row renders its `Edit` link.
25    pub edit: bool,
26    /// Whether the row renders its `Delete` link and an enabled bulk checkbox.
27    pub delete: bool,
28}
29
30impl RowActions {
31    /// Every action allowed.
32    pub const ALL: Self = Self {
33        view: true,
34        edit: true,
35        delete: true,
36    };
37}
38
39/// The action chrome a resource declares.
40#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
41pub(crate) struct TableChrome {
42    /// Whether the row renders a Delete action (which also enables bulk).
43    pub(crate) delete: bool,
44    /// Whether the row renders an Edit action.
45    pub(crate) edit: bool,
46    /// Whether the row renders a View action.
47    pub(crate) view: bool,
48    /// Whether the table renders the resource's custom actions.
49    pub(crate) actions: bool,
50}
51
52/// One custom [`Action`](crate::resource::Action) as a table renders it.
53pub(crate) struct TableAction<M> {
54    pub(crate) name: &'static str,
55    pub(crate) label: String,
56    pub(crate) row: bool,
57    pub(crate) bulk: bool,
58    /// Whether the action confirms: in the confirmation dialog, or in its input dialog.
59    pub(crate) confirm: bool,
60    /// The action's input form, when it asks for one: its buttons open the input's dialog.
61    pub(crate) input: Option<fn() -> Schema>,
62    /// The records it takes: the live ones, or a soft-deleting resource's trashed ones.
63    pub(crate) trashed: Trashed,
64    pub(crate) allowed: Arc<dyn Fn(&M) -> bool + Send + Sync>,
65}
66
67/// What a request wires onto a declared table: the action URLs its rows and bulk bar link to,
68/// and which actions each row allows.
69///
70/// The panel builds it per request from the mounted resource and the request's policy, so the
71/// declaration itself carries none of it.
72pub(crate) struct Wiring<M> {
73    row_policy: Option<RowPolicy<M>>,
74    delete_prefix: Option<String>,
75    edit_prefix: Option<String>,
76    view_prefix: Option<String>,
77    bulk_delete: bool,
78    /// The custom actions and the list URL their routes hang off.
79    custom_actions: Vec<TableAction<M>>,
80    actions_prefix: Option<String>,
81    /// Where a write this table's row and bulk actions start lands.
82    return_to: Option<String>,
83    /// The prefix the table's URL parameters carry.
84    prefix: Option<String>,
85    /// The reader's choice of toggleable columns.
86    choice: ColumnChoice,
87    /// Where the column picker posts a new choice; `None` renders no picker.
88    columns_url: Option<String>,
89}
90
91impl<M> Default for Wiring<M> {
92    fn default() -> Self {
93        Self {
94            row_policy: None,
95            delete_prefix: None,
96            edit_prefix: None,
97            view_prefix: None,
98            bulk_delete: false,
99            custom_actions: Vec::new(),
100            actions_prefix: None,
101            return_to: None,
102            prefix: None,
103            choice: ColumnChoice::default(),
104            columns_url: None,
105        }
106    }
107}
108
109/// A declared [`Table`] with one request's actions and policy wired on: what the list page
110/// renders.
111///
112/// [`wired_table`](crate::panel::wired_table) returns the panel's for a resource. It reads as
113/// its [`Table`] for loading a [`TablePage`](crate::TablePage).
114pub struct WiredTable<M> {
115    table: Arc<Table<M>>,
116    pub(super) wiring: Wiring<M>,
117}
118
119impl<M> Deref for WiredTable<M> {
120    type Target = Table<M>;
121
122    fn deref(&self) -> &Table<M> {
123        &self.table
124    }
125}
126
127impl<M> std::fmt::Debug for WiredTable<M> {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        let wiring = &self.wiring;
130        f.debug_struct("WiredTable")
131            .field("table", &self.table)
132            .field("row_policy", &wiring.row_policy.is_some())
133            .field("delete_prefix", &wiring.delete_prefix)
134            .field("edit_prefix", &wiring.edit_prefix)
135            .field("view_prefix", &wiring.view_prefix)
136            .field("bulk_delete", &wiring.bulk_delete)
137            .field(
138                "custom_actions",
139                &wiring
140                    .custom_actions
141                    .iter()
142                    .map(|a| a.name)
143                    .collect::<Vec<_>>(),
144            )
145            .field("return_to", &wiring.return_to)
146            .field("prefix", &wiring.prefix)
147            .field("choice", &wiring.choice)
148            .field("columns_url", &wiring.columns_url)
149            .finish()
150    }
151}
152
153impl<M> WiredTable<M> {
154    /// `table` with nothing wired: no actions.
155    pub(crate) fn new(table: Arc<Table<M>>) -> Self {
156        Self {
157            table,
158            wiring: Wiring::default(),
159        }
160    }
161
162    /// Declare the per-record action policy.
163    pub(crate) fn row_actions(
164        mut self,
165        policy: impl Fn(&M) -> RowActions + Send + Sync + 'static,
166    ) -> Self {
167        self.wiring.row_policy = Some(Arc::new(policy));
168        self
169    }
170
171    /// Enable the row-level `Delete` action.
172    pub(crate) fn with_delete(mut self, prefix: String) -> Self {
173        if self.is_addressable() {
174            self.wiring.delete_prefix = Some(prefix);
175        }
176        self
177    }
178
179    /// Enable the row-level `Edit` action.
180    pub(crate) fn with_edit(mut self, prefix: String) -> Self {
181        if self.is_addressable() {
182            self.wiring.edit_prefix = Some(prefix);
183        }
184        self
185    }
186
187    /// Enable the row-level `View` action.
188    pub(crate) fn with_view(mut self, prefix: String) -> Self {
189        if self.is_addressable() {
190            self.wiring.view_prefix = Some(prefix);
191        }
192        self
193    }
194
195    /// Enable bulk selection with the `BulkDelete` action.
196    pub(crate) fn with_bulk_delete(mut self, enabled: bool) -> Self {
197        self.wiring.bulk_delete = enabled;
198        self
199    }
200
201    /// Wire the resource's custom actions.
202    pub(crate) fn with_custom_actions(
203        mut self,
204        prefix: String,
205        actions: Vec<TableAction<M>>,
206    ) -> Self {
207        if self.is_addressable() {
208            self.wiring.actions_prefix = Some(prefix);
209            self.wiring.custom_actions = actions;
210        }
211        self
212    }
213
214    /// Show the columns `choice` shows, and let the reader post a new choice to `url`. A choice
215    /// hiding every column would leave no table to read, so the defaults show instead.
216    pub(crate) fn with_column_choice(mut self, choice: ColumnChoice, url: Option<String>) -> Self {
217        let shows_any = self
218            .declared_columns()
219            .iter()
220            .any(|col| choice.shows(col.name(), col.toggle()));
221        if shows_any {
222            self.wiring.choice = choice;
223        }
224        self.wiring.columns_url = url;
225        self
226    }
227
228    /// Send the writes this table starts back to `url`.
229    pub(crate) fn returning_to(mut self, url: String) -> Self {
230        self.wiring.return_to = Some(url);
231        self
232    }
233
234    /// Spell the table's URL parameters `{prefix}.{name}`, so a page renders several tables and
235    /// each keeps its own state and browser controls.
236    #[must_use]
237    pub fn prefixed(mut self, prefix: impl Into<String>) -> Self {
238        self.wiring.prefix = Some(prefix.into());
239        self
240    }
241
242    /// Keeps the delete and the custom actions that take some of the records `shown` admits, so
243    /// the bulk bar of a list showing only trashed records offers what a trashed record takes.
244    pub(crate) fn taking(mut self, shown: Trashed) -> Self {
245        // Some record, live or trashed, is both shown and taken.
246        let overlaps = |taken: Trashed| {
247            [false, true]
248                .into_iter()
249                .any(|trashed| shown.admits(trashed) && taken.admits(trashed))
250        };
251        self.wiring
252            .custom_actions
253            .retain(|action| overlaps(action.trashed));
254        if !overlaps(Trashed::Without) {
255            self.wiring.bulk_delete = false;
256        }
257        self
258    }
259
260    /// The table's signals and the list state they hold. Creates the signals, so it carries
261    /// [`topcoat::runtime::signal`]'s contract: call it while a page body runs.
262    pub(crate) fn browser_state(&self, cx: &Cx) -> (TableSignals, TableState) {
263        let prefix = self.wiring.prefix.as_deref();
264        let signals = TableSignals::new(cx, prefix);
265        let state = self.normalize_state(&signals.state(prefix));
266        (signals, state)
267    }
268
269    /// Which row actions `record` allows.
270    pub(crate) fn actions_for(&self, record: &M) -> RowActions {
271        self.wiring
272            .row_policy
273            .as_ref()
274            .map_or(RowActions::ALL, |policy| policy(record))
275    }
276
277    /// Whether the reader's choice shows `col`.
278    pub(super) fn shows(&self, col: &BoxColumn<M>) -> bool {
279        self.wiring.choice.shows(col.name(), col.toggle())
280    }
281
282    /// The declared columns the reader's choice shows, in declaration order.
283    pub(super) fn visible_columns(&self) -> impl Iterator<Item = &BoxColumn<M>> {
284        self.declared_columns().iter().filter(|col| self.shows(col))
285    }
286
287    /// Where the column picker posts, when the table renders one.
288    pub(super) fn columns_url(&self) -> Option<&str> {
289        self.wiring.columns_url.as_deref()
290    }
291
292    pub(super) fn return_to(&self) -> Option<&str> {
293        self.wiring.return_to.as_deref()
294    }
295
296    pub(super) fn delete_prefix(&self) -> Option<&str> {
297        self.wiring.delete_prefix.as_deref()
298    }
299
300    pub(super) fn edit_prefix(&self) -> Option<&str> {
301        self.wiring.edit_prefix.as_deref()
302    }
303
304    pub(super) fn view_prefix(&self) -> Option<&str> {
305        self.wiring.view_prefix.as_deref()
306    }
307
308    pub(super) fn actions_prefix(&self) -> Option<&str> {
309        self.wiring.actions_prefix.as_deref()
310    }
311
312    pub(super) fn row_custom_actions(&self) -> impl Iterator<Item = &TableAction<M>> {
313        self.wiring
314            .custom_actions
315            .iter()
316            .filter(|a| a.row && self.wiring.actions_prefix.is_some())
317    }
318
319    pub(super) fn bulk_custom_actions(&self) -> impl Iterator<Item = &TableAction<M>> {
320        self.wiring
321            .custom_actions
322            .iter()
323            .filter(|a| a.bulk && self.wiring.actions_prefix.is_some())
324    }
325
326    pub(super) fn bulk_delete_enabled(&self) -> bool {
327        self.wiring.bulk_delete && self.wiring.delete_prefix.is_some()
328    }
329}
330
331#[cfg(test)]
332impl<M> Table<M> {
333    /// This table with nothing wired yet, for a test that wires it by hand.
334    pub(crate) fn wired(self) -> WiredTable<M> {
335        WiredTable::new(Arc::new(self))
336    }
337}