Skip to main content

tablo_core/resource/
action.rs

1//! Custom actions: the [`Action`] trait, and the [`Actions`] list a
2//! [`ResourceDef`](super::ResourceDef) declares them in.
3
4use std::{future::Future, pin::Pin};
5
6use topcoat::{Result, context::Cx};
7
8use super::{Mounted, Resource};
9use crate::policy::Ability;
10
11/// A mutation beyond create, update and delete: "publish", "archive",
12/// "resend the invite".
13///
14/// An action runs on one record, from a button in its row, or on the
15/// selection, from the bulk bar, or both ([`ROW`](Self::ROW),
16/// [`BULK`](Self::BULK)). [`ResourceDef::action`](super::ResourceDef::action) declares it:
17///
18/// ```rust
19/// # #[derive(Debug, Clone, toasty::Model)]
20/// # struct Post {
21/// #     #[key] #[auto] id: uuid::Uuid,
22/// #     title: String,
23/// #     status: String,
24/// # }
25/// # use tablo_core::{Action, NoForm, Resource};
26/// # use toasty::Executor;
27/// # use topcoat::{Result, context::Cx};
28/// # struct PostResource;
29/// #
30/// # impl Resource for PostResource {
31/// #     type Model = Post;
32/// #     type Form = NoForm<Post>;
33/// # }
34/// struct Publish;
35///
36/// impl Action<PostResource> for Publish {
37///     const NAME: &'static str = "publish";
38///
39///     fn label(_cx: &Cx) -> String {
40///         "Publish".to_string()
41///     }
42///
43///     fn can_run(_cx: &Cx, post: &Post) -> bool {
44///         post.status != "published"
45///     }
46///
47///     async fn run(_cx: &Cx, posts: &[Post], ex: &mut dyn Executor) -> Result<()> {
48///         for post in posts {
49///             Post::filter(Post::fields().id().eq(post.id))
50///                 .update()
51///                 .status("published".to_string())
52///                 .exec(&mut *ex)
53///                 .await?;
54///         }
55///         Ok(())
56///     }
57/// }
58/// ```
59///
60/// The framework owns everything around [`run`](Self::run), as it does for
61/// a delete:
62///
63/// - the route, `{list}/{key}/-/actions/{NAME}` for a row and `{list}/-/actions/{NAME}` for the
64///   selection, and its CSRF check;
65/// - the transaction: the records are loaded through [`scoped_query`](super::scoped_query) inside
66///   it, `run` writes through the same executor, and an error rolls everything back;
67/// - the policy: every record must pass [`Ability::View`](crate::policy::Ability::View), checked on
68///   the loaded rows before `run`;
69/// - the refusal: a row [`can_run`](Self::can_run) refuses answers 403, and a bulk selection runs
70///   the records that pass it and reports the refused count as skipped. A selection that passes on
71///   none writes nothing and answers with an error notification;
72/// - [`Resource::after_commit`] with [`Mutation::Action`](super::Mutation::Action) once the
73///   transaction commits, and the success notification.
74///
75/// A row whose record fails `can_run` renders no button for the action, and
76/// a row that no bulk action and no delete allows renders no checkbox.
77pub trait Action<R: Resource>: 'static {
78    /// The action's URL segment, distinct among the resource's actions.
79    ///
80    /// [`ResourceDef::action`](super::ResourceDef::action) refuses to compile a name that is not
81    /// a single path segment: empty, `.` or `..`, or holding whitespace, a control character, a
82    /// quote, a backslash or one of `/ ? # % & = { } ( )`.
83    /// [`RouterBuilderPanelExt::panel`](crate::RouterBuilderPanelExt::panel) refuses a name that
84    /// another action of the resource shares.
85    ///
86    /// ```rust,compile_fail
87    /// # use tablo_core::{Action, NoForm, Resource, ResourceDef};
88    /// # use topcoat::{Result, context::Cx};
89    /// # #[derive(Debug, Clone, toasty::Model)]
90    /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
91    /// # struct PostResource;
92    /// # impl Resource for PostResource {
93    /// #     type Model = Post;
94    /// #     type Form = NoForm<Post>;
95    /// # }
96    /// struct Archive;
97    ///
98    /// impl Action<PostResource> for Archive {
99    ///     const NAME: &'static str = "archive/all";
100    /// #   fn label(_cx: &Cx) -> String { String::new() }
101    /// #   fn can_run(_: &Cx, _: &Post) -> bool { true }
102    /// #   async fn run(_: &Cx, _: &[Post], _: &mut dyn toasty::Executor) -> Result<()> { Ok(()) }
103    /// }
104    ///
105    /// let def = ResourceDef::<PostResource>::new().action::<Archive>();
106    /// ```
107    const NAME: &'static str;
108
109    /// Whether a row renders the action's button. Defaults to `true`.
110    const ROW: bool = true;
111
112    /// Whether the bulk bar renders the action for the selection. Defaults
113    /// to `true`.
114    const BULK: bool = true;
115
116    /// Whether the action asks first through a confirmation dialog sharing the
117    /// delete dialog's mechanism and destructive wording. Defaults to `false`.
118    ///
119    /// An unconfirmed POST answers 400.
120    const CONFIRM: bool = false;
121
122    /// The button text.
123    fn label(cx: &Cx) -> String;
124
125    /// Whether the action may run on `record`.
126    fn can_run(_cx: &Cx, _record: &R::Model) -> bool {
127        true
128    }
129
130    /// Perform the action on `records`, the records of the row or selection that
131    /// passed [`can_run`](Self::can_run), through the framework's transaction `ex`.
132    fn run(
133        cx: &Cx,
134        records: &[R::Model],
135        ex: &mut dyn toasty::Executor,
136    ) -> impl Future<Output = Result<()>> + Send;
137
138    /// The success notification after a commit. Defaults to the label and
139    /// the record count: `"Publish: 3 records"`. A bulk run the action refused
140    /// on some records appends their count out of the selection:
141    /// `"Publish: 3 records (2 of 5 skipped)"`.
142    fn success(cx: &Cx, count: usize) -> String {
143        let noun = if count == 1 { "record" } else { "records" };
144        format!("{}: {count} {noun}", Self::label(cx))
145    }
146}
147
148/// What an erased action's `run` returns.
149pub(crate) type ActionFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
150
151/// The actions a [`Resource`] declares, in button order.
152pub(crate) struct Actions<R: Resource> {
153    entries: Vec<ActionEntry<R>>,
154}
155
156impl<R: Resource> Default for Actions<R> {
157    fn default() -> Self {
158        Self {
159            entries: Vec::new(),
160        }
161    }
162}
163
164impl<R: Resource> std::fmt::Debug for Actions<R> {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.debug_list()
167            .entries(self.entries.iter().map(|e| e.name))
168            .finish()
169    }
170}
171
172impl<R: Resource> Actions<R> {
173    /// Appends the action `A`, failing to compile when `A::NAME` is not a single path segment.
174    pub(crate) fn add<A: Action<R>>(mut self) -> Self {
175        const {
176            assert!(
177                crate::declaration::segment_fault(A::NAME).is_none(),
178                "`Action::NAME` must be a single path segment"
179            );
180        }
181        self.entries.push(ActionEntry {
182            name: A::NAME,
183            label: A::label,
184            row: A::ROW,
185            bulk: A::BULK,
186            resource_wide: Ability::ViewAny,
187            can_run: can_run_erased::<R, A>,
188            run: run_erased::<R, A>,
189            success: A::success,
190            acted: super::Committed::acted::<R, A>,
191            failure: "run the action",
192            confirm: A::CONFIRM,
193        });
194        self
195    }
196
197    /// The declared actions, in order.
198    pub(crate) fn entries(&self) -> &[ActionEntry<R>] {
199        &self.entries
200    }
201
202    /// The action named `name`.
203    pub(crate) fn find(&self, name: &str) -> Option<&ActionEntry<R>> {
204        self.entries.iter().find(|e| e.name == name)
205    }
206}
207
208/// One mutation of a record or a selection with its type erased: a declared action, or the
209/// built-in delete. The panel runs every one through the same pipeline.
210pub(crate) struct ActionEntry<R: Resource> {
211    pub(crate) name: &'static str,
212    pub(crate) label: fn(&Cx) -> String,
213    pub(crate) row: bool,
214    pub(crate) bulk: bool,
215    /// The resource-wide ability checked before the body is read.
216    pub(crate) resource_wide: Ability<'static, R::Model>,
217    /// Whether the mutation may write `record`, which already passed `View`.
218    pub(crate) can_run: fn(&Mounted<R>, &Cx, &R::Model) -> bool,
219    pub(crate) run:
220        for<'a> fn(&'a Cx, &'a [R::Model], &'a mut dyn toasty::Executor) -> ActionFuture<'a>,
221    pub(crate) success: fn(&Cx, usize) -> String,
222    pub(crate) acted: fn(Vec<R::Model>) -> super::Committed<R::Model>,
223    /// The failure toast's wording: "Couldn't {failure}".
224    pub(crate) failure: &'static str,
225    pub(crate) confirm: bool,
226}
227
228impl<R: Resource> Clone for ActionEntry<R> {
229    fn clone(&self) -> Self {
230        *self
231    }
232}
233
234impl<R: Resource> Copy for ActionEntry<R> {}
235
236impl<R: Resource> ActionEntry<R> {
237    /// The row's Delete: [`Resource::delete_record`], on a record [`Ability::Delete`] allows.
238    pub(crate) fn delete() -> Self {
239        Self::deleting(false)
240    }
241
242    /// The bulk bar's Delete: [`Resource::bulk_delete_records`], on the selected records
243    /// [`Ability::Delete`] allows.
244    pub(crate) fn bulk_delete() -> Self {
245        Self::deleting(true)
246    }
247
248    fn deleting(bulk: bool) -> Self {
249        Self {
250            name: "delete",
251            label: |_| "Delete".to_string(),
252            row: !bulk,
253            bulk,
254            resource_wide: Ability::DeleteAny,
255            can_run: |resource, cx, record| resource.can(cx, Ability::Delete(record)),
256            run: if bulk {
257                bulk_delete_erased::<R>
258            } else {
259                delete_erased::<R>
260            },
261            success: if bulk {
262                |_, _| "Bulk deleted".to_string()
263            } else {
264                |_, _| "Deleted".to_string()
265            },
266            acted: super::Committed::deleted,
267            failure: if bulk {
268                "delete the selected rows"
269            } else {
270                "delete the record"
271            },
272            confirm: true,
273        }
274    }
275}
276
277/// [`Action::can_run`] behind a function pointer.
278fn can_run_erased<R: Resource, A: Action<R>>(_: &Mounted<R>, cx: &Cx, record: &R::Model) -> bool {
279    A::can_run(cx, record)
280}
281
282/// [`Action::run`] behind a function pointer.
283fn run_erased<'a, R: Resource, A: Action<R>>(
284    cx: &'a Cx,
285    records: &'a [R::Model],
286    ex: &'a mut dyn toasty::Executor,
287) -> ActionFuture<'a> {
288    Box::pin(A::run(cx, records, ex))
289}
290
291/// [`Resource::delete_record`] on each of `records`, behind a function pointer.
292fn delete_erased<'a, R: Resource>(
293    cx: &'a Cx,
294    records: &'a [R::Model],
295    ex: &'a mut dyn toasty::Executor,
296) -> ActionFuture<'a> {
297    Box::pin(async move {
298        for record in records {
299            R::delete_record(cx, record, &mut *ex).await?;
300        }
301        Ok(())
302    })
303}
304
305/// [`Resource::bulk_delete_records`] behind a function pointer.
306fn bulk_delete_erased<'a, R: Resource>(
307    cx: &'a Cx,
308    records: &'a [R::Model],
309    ex: &'a mut dyn toasty::Executor,
310) -> ActionFuture<'a> {
311    Box::pin(R::bulk_delete_records(cx, records, ex))
312}