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::Resource;
9
10/// A mutation beyond create, update and delete: "publish", "archive",
11/// "resend the invite".
12///
13/// An action runs on one record, from a button in its row, or on the
14/// selection, from the bulk bar, or both ([`ROW`](Self::ROW),
15/// [`BULK`](Self::BULK)). [`ResourceDef::action`](super::ResourceDef::action) declares it:
16///
17/// ```rust
18/// # #[derive(Debug, Clone, toasty::Model)]
19/// # struct Post {
20/// #     #[key] #[auto] id: uuid::Uuid,
21/// #     title: String,
22/// #     status: String,
23/// # }
24/// # use tablo_core::{Action, NoForm, Resource};
25/// # use toasty::Executor;
26/// # use topcoat::{Result, context::Cx};
27/// # struct PostResource;
28/// #
29/// # impl Resource for PostResource {
30/// #     type Model = Post;
31/// #     type Form = NoForm<Post>;
32/// # }
33/// struct Publish;
34///
35/// impl Action<PostResource> for Publish {
36///     const NAME: &'static str = "publish";
37///
38///     fn label(_cx: &Cx) -> String {
39///         "Publish".to_string()
40///     }
41///
42///     fn can_run(_cx: &Cx, post: &Post) -> bool {
43///         post.status != "published"
44///     }
45///
46///     async fn run(_cx: &Cx, posts: &[Post], ex: &mut dyn Executor) -> Result<()> {
47///         for post in posts {
48///             Post::filter(Post::fields().id().eq(post.id))
49///                 .update()
50///                 .status("published".to_string())
51///                 .exec(&mut *ex)
52///                 .await?;
53///         }
54///         Ok(())
55///     }
56/// }
57/// ```
58///
59/// The framework owns everything around [`run`](Self::run), as it does for
60/// a delete:
61///
62/// - the route, `{list}/{key}/-/actions/{NAME}` for a row and `{list}/-/actions/{NAME}` for the
63///   selection, and its CSRF check;
64/// - the transaction: the records are loaded through [`scoped_query`](super::scoped_query) inside
65///   it, `run` writes through the same executor, and an error rolls everything back;
66/// - the policy: every record must pass [`Ability::View`](crate::policy::Ability::View) and
67///   [`can_run`](Self::can_run), checked on the loaded rows before `run`;
68/// - [`Resource::after_commit`] with [`Mutation::Action`](super::Mutation::Action) once the
69///   transaction commits, and the success notification.
70///
71/// A row whose record fails `can_run` renders no button for the action, and
72/// a row that no bulk action and no delete allows renders no checkbox.
73pub trait Action<R: Resource>: 'static {
74    /// The action's URL segment, distinct among the resource's actions.
75    ///
76    /// [`ResourceDef::action`](super::ResourceDef::action) refuses to compile a name that is not
77    /// a single path segment: empty, `.` or `..`, or holding whitespace, a control character, a
78    /// quote, a backslash or one of `/ ? # % & = { } ( )`.
79    /// [`RouterBuilderPanelExt::panel`](crate::RouterBuilderPanelExt::panel) refuses a name that
80    /// another action of the resource shares.
81    ///
82    /// ```rust,compile_fail
83    /// # use tablo_core::{Action, NoForm, Resource, ResourceDef};
84    /// # use topcoat::{Result, context::Cx};
85    /// # #[derive(Debug, Clone, toasty::Model)]
86    /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
87    /// # struct PostResource;
88    /// # impl Resource for PostResource {
89    /// #     type Model = Post;
90    /// #     type Form = NoForm<Post>;
91    /// # }
92    /// struct Archive;
93    ///
94    /// impl Action<PostResource> for Archive {
95    ///     const NAME: &'static str = "archive/all";
96    /// #   fn label(_cx: &Cx) -> String { String::new() }
97    /// #   fn can_run(_: &Cx, _: &Post) -> bool { true }
98    /// #   async fn run(_: &Cx, _: &[Post], _: &mut dyn toasty::Executor) -> Result<()> { Ok(()) }
99    /// }
100    ///
101    /// let def = ResourceDef::<PostResource>::new().action::<Archive>();
102    /// ```
103    const NAME: &'static str;
104
105    /// Whether a row renders the action's button. Defaults to `true`.
106    const ROW: bool = true;
107
108    /// Whether the bulk bar renders the action for the selection. Defaults
109    /// to `true`.
110    const BULK: bool = true;
111
112    /// The button text.
113    fn label(cx: &Cx) -> String;
114
115    /// Whether the action may run on `record`.
116    fn can_run(_cx: &Cx, _record: &R::Model) -> bool {
117        true
118    }
119
120    /// Perform the action on `records`, all of which passed
121    /// [`can_run`](Self::can_run), through the framework's transaction `ex`.
122    fn run(
123        cx: &Cx,
124        records: &[R::Model],
125        ex: &mut dyn toasty::Executor,
126    ) -> impl Future<Output = Result<()>> + Send;
127
128    /// The success notification after a commit. Defaults to the label and
129    /// the record count: `"Publish: 3 records"`.
130    fn success(cx: &Cx, count: usize) -> String {
131        let noun = if count == 1 { "record" } else { "records" };
132        format!("{}: {count} {noun}", Self::label(cx))
133    }
134}
135
136/// What an erased action's `run` returns.
137pub(crate) type ActionFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
138
139/// The actions a [`Resource`] declares, in button order.
140pub(crate) struct Actions<R: Resource> {
141    entries: Vec<ActionEntry<R>>,
142}
143
144impl<R: Resource> Default for Actions<R> {
145    fn default() -> Self {
146        Self {
147            entries: Vec::new(),
148        }
149    }
150}
151
152impl<R: Resource> std::fmt::Debug for Actions<R> {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.debug_list()
155            .entries(self.entries.iter().map(|e| e.name))
156            .finish()
157    }
158}
159
160impl<R: Resource> Actions<R> {
161    /// Appends the action `A`, failing to compile when `A::NAME` is not a single path segment.
162    pub(crate) fn add<A: Action<R>>(mut self) -> Self {
163        const {
164            assert!(
165                crate::declaration::segment_fault(A::NAME).is_none(),
166                "`Action::NAME` must be a single path segment"
167            );
168        }
169        self.entries.push(ActionEntry {
170            name: A::NAME,
171            label: A::label,
172            row: A::ROW,
173            bulk: A::BULK,
174            can_run: A::can_run,
175            run: run_erased::<R, A>,
176            success: A::success,
177        });
178        self
179    }
180
181    /// The declared actions, in order.
182    pub(crate) fn entries(&self) -> &[ActionEntry<R>] {
183        &self.entries
184    }
185
186    /// The action named `name`.
187    pub(crate) fn find(&self, name: &str) -> Option<&ActionEntry<R>> {
188        self.entries.iter().find(|e| e.name == name)
189    }
190}
191
192/// One declared action with its type erased, so a resource's actions sit
193/// in one list.
194pub(crate) struct ActionEntry<R: Resource> {
195    pub(crate) name: &'static str,
196    pub(crate) label: fn(&Cx) -> String,
197    pub(crate) row: bool,
198    pub(crate) bulk: bool,
199    pub(crate) can_run: fn(&Cx, &R::Model) -> bool,
200    pub(crate) run:
201        for<'a> fn(&'a Cx, &'a [R::Model], &'a mut dyn toasty::Executor) -> ActionFuture<'a>,
202    pub(crate) success: fn(&Cx, usize) -> String,
203}
204
205/// [`Action::run`] behind a function pointer.
206fn run_erased<'a, R: Resource, A: Action<R>>(
207    cx: &'a Cx,
208    records: &'a [R::Model],
209    ex: &'a mut dyn toasty::Executor,
210) -> ActionFuture<'a> {
211    Box::pin(A::run(cx, records, ex))
212}