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