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}