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}