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
4mod input;
5
6use std::{any::Any, collections::HashMap, future::Future, pin::Pin};
7
8pub use input::ActionInput;
9pub(crate) use input::RESERVED_KEYS;
10#[doc(hidden)]
11pub use input::required_input;
12use topcoat::{Result, context::Cx};
13
14use super::{Mounted, Resource};
15use crate::{
16    form::{FieldError, FieldErrors},
17    naming::sentence_case,
18    policy::Ability,
19    schema::Schema,
20};
21
22/// A mutation beyond create, update and delete: "publish", "archive",
23/// "resend the invite".
24///
25/// An action runs on one record, from a button in its table row or in the header of its detail or
26/// edit page, or on the selection, from the bulk bar: [`PLACES`](Self::PLACES) says which.
27/// [`ResourceDef::action`](super::ResourceDef::action) declares it:
28///
29/// ```rust
30/// # #[derive(Debug, Clone, toasty::Model)]
31/// # struct Post {
32/// #     #[key] #[auto] id: uuid::Uuid,
33/// #     title: String,
34/// #     status: String,
35/// # }
36/// # use tablo_core::{Action, NoForm, Resource};
37/// # use toasty::Executor;
38/// # use topcoat::{Result, context::Cx};
39/// # struct PostResource;
40/// #
41/// # impl Resource for PostResource {
42/// #     type Model = Post;
43/// #     type Form = NoForm<Post>;
44/// # }
45/// struct Publish;
46///
47/// impl Action<PostResource> for Publish {
48///     type Input = ();
49///     const NAME: &'static str = "publish";
50///
51///     fn can_run(_cx: &Cx, post: &Post) -> bool {
52///         post.status != "published"
53///     }
54///
55///     async fn run(_cx: &Cx, posts: &[Post], _: (), ex: &mut dyn Executor) -> Result<()> {
56///         for post in posts {
57///             Post::filter(Post::fields().id().eq(post.id))
58///                 .update()
59///                 .status("published".to_string())
60///                 .exec(&mut *ex)
61///                 .await?;
62///         }
63///         Ok(())
64///     }
65/// }
66/// ```
67///
68/// The framework owns everything around [`run`](Self::run), as it does for
69/// a delete:
70///
71/// - the route, `{list}/{key}/-/actions/{NAME}` for one record and `{list}/-/actions/{NAME}` for
72///   the selection, and its CSRF check;
73/// - the transaction: the records are loaded through [`scoped_query`](super::scoped_query) inside
74///   it, `run` writes through the same executor, and an error rolls everything back;
75/// - the policy: [`Ability::RunAny`] with the action's `NAME`, asked before the body is read, then
76///   [`Ability::View`] and [`Ability::Run`] on every loaded record before `run`. A record `View`
77///   refuses fails the whole POST with 403;
78/// - the refusal: a row that `Run` or [`can_run`](Self::can_run) refuses answers 403, and a bulk
79///   selection runs the records that pass both and reports the refused count as skipped. A
80///   selection that passes on none writes nothing and answers with an error notification;
81/// - [`Resource::after_commit`] with [`Mutation::Action`](super::Mutation::Action) once the
82///   transaction commits, and the success notification.
83///
84/// An action that asks for an [`Input`](Self::Input) asks for it first: its button opens the
85/// input's form in a dialog over the page, whose submit runs the action with the parsed value. The
86/// submit parses the input, asks [`validate_input`](Self::validate_input) and checks its choices
87/// before the transaction opens, then re-checks a relationship choice inside it. A refused value
88/// renders the input as a page with its errors and writes nothing.
89///
90/// A run from a detail or edit page lands back on that page; one from a row or the bulk bar lands
91/// on the list.
92///
93/// No place renders a button for an action the policy refuses `RunAny`, and no row or page renders
94/// one for a record that fails `View`, `Run` or `can_run`. A row that no bulk action and no delete
95/// allows renders no checkbox.
96pub trait Action<R: Resource>: 'static {
97    /// What the action asks for before it runs: `()` for nothing, or an
98    /// [`ActionInput`](derive@crate::ActionInput) struct, such as a rejection's reason.
99    type Input: ActionInput;
100
101    /// The action's URL segment, distinct among the resource's actions.
102    ///
103    /// [`ResourceDef::action`](super::ResourceDef::action) refuses to compile a name that is not
104    /// a single path segment: empty, `.` or `..`, or holding whitespace, a control character, a
105    /// quote, a backslash or one of `/ ? # % & = { } ( )`.
106    /// [`RouterBuilderPanelExt::panel`](crate::RouterBuilderPanelExt::panel) refuses a name that
107    /// another action of the resource shares.
108    ///
109    /// ```rust,compile_fail
110    /// # use tablo_core::{Action, NoForm, Resource, ResourceDef};
111    /// # use topcoat::{Result, context::Cx};
112    /// # #[derive(Debug, Clone, toasty::Model)]
113    /// # struct Post { #[key] #[auto] id: uuid::Uuid, title: String }
114    /// # struct PostResource;
115    /// # impl Resource for PostResource {
116    /// #     type Model = Post;
117    /// #     type Form = NoForm<Post>;
118    /// # }
119    /// struct Archive;
120    ///
121    /// impl Action<PostResource> for Archive {
122    ///     type Input = ();
123    ///     const NAME: &'static str = "archive/all";
124    /// #   fn can_run(_: &Cx, _: &Post) -> bool { true }
125    /// #   async fn run(_: &Cx, _: &[Post], _: (), _: &mut dyn toasty::Executor) -> Result<()> { Ok(()) }
126    /// }
127    ///
128    /// let def = ResourceDef::<PostResource>::new().action::<Archive>();
129    /// ```
130    const NAME: &'static str;
131
132    /// Where the action's button renders. Defaults to [`Places::ALL`]: each row, the detail and
133    /// edit pages' headers, and the bulk bar.
134    ///
135    /// It decides where the button shows, not who may run it: the record route serves an action
136    /// placed on any of [`Places::RECORD`], and the bulk route one placed on
137    /// [`Places::BULK`].
138    const PLACES: Places = Places::ALL;
139
140    /// Whether the action asks first through a confirmation dialog sharing the
141    /// delete dialog's mechanism and destructive wording. Defaults to `false`.
142    ///
143    /// An action with input confirms in its input dialog, or on its input page, instead, which
144    /// say the action cannot be undone and whose submit renders destructive. An unconfirmed POST
145    /// that would write answers 400.
146    const CONFIRM: bool = false;
147
148    /// The button text. Defaults to [`NAME`](Self::NAME) in sentence case: `"publish"` reads
149    /// "Publish", and `"send-invite"` reads "Send invite".
150    fn label(_cx: &Cx) -> String {
151        sentence_case(Self::NAME)
152    }
153
154    /// Whether `record`'s state lets the action run on it: a published post refuses "publish".
155    /// Defaults to `true`.
156    ///
157    /// It sees no policy, so it decides no authorization: the resource's policy answers
158    /// [`Ability::Run`] for that, and a panel that mounts the resource with another policy
159    /// changes who may run the action.
160    fn can_run(_cx: &Cx, _record: &R::Model) -> bool {
161        true
162    }
163
164    /// Refuse an input [`run`](Self::run) should not receive, such as a reason too short to
165    /// act on: each error names an input field's key and renders under its control. Defaults to
166    /// none.
167    ///
168    /// It runs after the input parses and before the transaction opens, so it sees no record.
169    fn validate_input(_cx: &Cx, _input: &Self::Input) -> FieldErrors {
170        FieldErrors::new()
171    }
172
173    /// Perform the action on `records`, the records of the row or selection that
174    /// passed [`Ability::Run`] and [`can_run`](Self::can_run), with the parsed `input`, through
175    /// the framework's transaction `ex`.
176    fn run(
177        cx: &Cx,
178        records: &[R::Model],
179        input: Self::Input,
180        ex: &mut dyn toasty::Executor,
181    ) -> impl Future<Output = Result<()>> + Send;
182
183    /// The success notification after a commit. Defaults to the label and
184    /// the record count: `"Publish: 3 records"`. A bulk run the action refused
185    /// on some records appends their count out of the selection:
186    /// `"Publish: 3 records (2 of 5 skipped)"`.
187    fn success(cx: &Cx, count: usize) -> String {
188        let noun = if count == 1 { "record" } else { "records" };
189        format!("{}: {count} {noun}", Self::label(cx))
190    }
191}
192
193/// Where an [`Action`]'s button renders: [`Action::PLACES`].
194///
195/// ```rust
196/// # use tablo_core::Places;
197/// // On the detail page and the bulk bar, not on each row or the edit page.
198/// const PLACES: Places = Places::DETAIL.with(Places::BULK);
199/// assert!(PLACES.contains(Places::BULK));
200/// assert!(!PLACES.contains(Places::ROW));
201/// ```
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub struct Places(u8);
204
205impl Places {
206    /// A button in each table row, the related tables' included.
207    pub const ROW: Self = Self(1);
208    /// A button in the detail page's header.
209    pub const DETAIL: Self = Self(1 << 1);
210    /// A button in the edit page's header.
211    pub const EDIT: Self = Self(1 << 2);
212    /// An entry in the bulk bar, run on the selection.
213    pub const BULK: Self = Self(1 << 3);
214    /// Every place that shows one record: [`ROW`](Self::ROW), [`DETAIL`](Self::DETAIL) and
215    /// [`EDIT`](Self::EDIT).
216    pub const RECORD: Self = Self(Self::ROW.0 | Self::DETAIL.0 | Self::EDIT.0);
217    /// Every place.
218    pub const ALL: Self = Self(Self::RECORD.0 | Self::BULK.0);
219
220    /// These places and `other`'s.
221    #[must_use]
222    pub const fn with(self, other: Self) -> Self {
223        Self(self.0 | other.0)
224    }
225
226    /// Whether every place of `other` is one of these.
227    #[must_use]
228    pub const fn contains(self, other: Self) -> bool {
229        self.0 & other.0 == other.0
230    }
231
232    /// Whether any place of `other` is one of these.
233    #[must_use]
234    pub const fn intersects(self, other: Self) -> bool {
235        self.0 & other.0 != 0
236    }
237}
238
239impl<M> Ability<'_, M> {
240    /// Whether the ability is [`RunAny`](Ability::RunAny) or [`Run`](Ability::Run) for the action
241    /// `A`.
242    ///
243    /// It compares against `A::NAME`, so renaming the action keeps the policy matching it, and a
244    /// type that is not an action of the resource does not compile. `R` is inferred when `A` is an
245    /// action of one resource.
246    ///
247    /// ```rust
248    /// # use tablo_core::{Ability, Action, NoForm, Resource};
249    /// # use topcoat::{Result, context::Cx};
250    /// # #[derive(Debug, Clone, toasty::Model)]
251    /// # struct Post { #[key] #[auto] id: uuid::Uuid, published: bool }
252    /// # struct PostResource;
253    /// # impl Resource for PostResource {
254    /// #     type Model = Post;
255    /// #     type Form = NoForm<Post>;
256    /// # }
257    /// # struct Publish;
258    /// # impl Action<PostResource> for Publish {
259    /// #     type Input = ();
260    /// #     const NAME: &'static str = "publish";
261    /// #     async fn run(_: &Cx, _: &[Post], _: (), _: &mut dyn toasty::Executor) -> Result<()> { Ok(()) }
262    /// # }
263    /// # fn is_editor(_cx: &Cx) -> bool { true }
264    /// fn post_policy(cx: &Cx, ability: Ability<'_, Post>) -> bool {
265    ///     match ability {
266    ///         Ability::Run { record, .. } if ability.is_action::<Publish, _>() => !record.published,
267    ///         Ability::ViewAny | Ability::View(_) => true,
268    ///         _ => is_editor(cx),
269    ///     }
270    /// }
271    /// ```
272    pub fn is_action<A, R>(self) -> bool
273    where
274        A: Action<R>,
275        R: Resource<Model = M>,
276    {
277        matches!(self, Ability::RunAny { action } | Ability::Run { action, .. } if action == A::NAME)
278    }
279}
280
281/// What an erased action's `run` returns.
282pub(crate) type ActionFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
283
284/// A parsed [`Action::Input`] with its type erased; `run` downcasts it back.
285pub(crate) type ErasedInput = Box<dyn Any + Send>;
286
287/// What an erased input parse returns.
288pub(crate) type InputResult = std::result::Result<ErasedInput, Vec<FieldError>>;
289
290/// What an action asks for before it runs, its type erased: the input's schema and its parse.
291#[derive(Clone, Copy)]
292pub(crate) struct InputSpec {
293    /// The input form's schema: empty for a delete and an action asking for nothing.
294    pub(crate) schema: fn() -> Schema,
295    /// Whether [`schema`](Self::schema) declares a field, so the action asks for it on a page.
296    pub(crate) takes_input: bool,
297    /// Parses and validates the input from a submission, as the value `run` takes.
298    pub(crate) parse: fn(&Cx, &HashMap<String, String>) -> InputResult,
299}
300
301impl InputSpec {
302    /// The input of type `I`, parsed and validated by `parse`.
303    pub(crate) fn of<I: ActionInput>(
304        parse: fn(&Cx, &HashMap<String, String>) -> InputResult,
305    ) -> Self {
306        Self {
307            schema: I::schema,
308            takes_input: !I::schema().is_empty(),
309            parse,
310        }
311    }
312
313    /// No input: a delete's.
314    pub(crate) fn none() -> Self {
315        Self {
316            schema: Schema::empty,
317            takes_input: false,
318            parse: parse_nothing,
319        }
320    }
321}
322
323/// The actions a [`Resource`] declares, in button order.
324pub(crate) struct Actions<R: Resource> {
325    entries: Vec<ActionEntry<R>>,
326}
327
328impl<R: Resource> Default for Actions<R> {
329    fn default() -> Self {
330        Self {
331            entries: Vec::new(),
332        }
333    }
334}
335
336impl<R: Resource> std::fmt::Debug for Actions<R> {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        f.debug_list()
339            .entries(self.entries.iter().map(|e| e.name))
340            .finish()
341    }
342}
343
344impl<R: Resource> Actions<R> {
345    /// Appends the action `A`, failing to compile when `A::NAME` is not a single path segment.
346    pub(crate) fn add<A: Action<R>>(mut self) -> Self {
347        const {
348            assert!(
349                crate::declaration::segment_fault(A::NAME).is_none(),
350                "`Action::NAME` must be a single path segment"
351            );
352        }
353        self.entries.push(ActionEntry {
354            name: A::NAME,
355            label: A::label,
356            places: A::PLACES,
357            resource_wide: Ability::RunAny { action: A::NAME },
358            can_run: can_run_erased::<R, A>,
359            input: InputSpec::of::<A::Input>(parse_input_erased::<R, A>),
360            run: run_erased::<R, A>,
361            success: A::success,
362            acted: super::Committed::acted::<R, A>,
363            failure: "run the action",
364            confirm: A::CONFIRM,
365        });
366        self
367    }
368
369    /// The declared actions, in order.
370    pub(crate) fn entries(&self) -> &[ActionEntry<R>] {
371        &self.entries
372    }
373
374    /// The action named `name`.
375    pub(crate) fn find(&self, name: &str) -> Option<&ActionEntry<R>> {
376        self.entries.iter().find(|e| e.name == name)
377    }
378}
379
380/// One mutation of a record or a selection with its type erased: a declared action, or the
381/// built-in delete. The panel runs every one through the same pipeline.
382pub(crate) struct ActionEntry<R: Resource> {
383    pub(crate) name: &'static str,
384    pub(crate) label: fn(&Cx) -> String,
385    pub(crate) places: Places,
386    /// The resource-wide ability checked before the body is read.
387    pub(crate) resource_wide: Ability<'static, R::Model>,
388    /// Whether the mutation may write `record`, which already passed `View`.
389    pub(crate) can_run: fn(&Mounted<R>, &Cx, &R::Model) -> bool,
390    pub(crate) input: InputSpec,
391    pub(crate) run: for<'a> fn(
392        &'a Cx,
393        &'a [R::Model],
394        ErasedInput,
395        &'a mut dyn toasty::Executor,
396    ) -> ActionFuture<'a>,
397    pub(crate) success: fn(&Cx, usize) -> String,
398    pub(crate) acted: fn(Vec<R::Model>) -> super::Committed<R::Model>,
399    /// The failure toast's wording: "Couldn't {failure}".
400    pub(crate) failure: &'static str,
401    pub(crate) confirm: bool,
402}
403
404impl<R: Resource> Clone for ActionEntry<R> {
405    fn clone(&self) -> Self {
406        *self
407    }
408}
409
410impl<R: Resource> Copy for ActionEntry<R> {}
411
412impl<R: Resource> ActionEntry<R> {
413    /// One record's Delete, from its row or its detail or edit page: [`Resource::delete_record`],
414    /// on a record [`Ability::Delete`] allows.
415    pub(crate) fn delete() -> Self {
416        Self::deleting(false)
417    }
418
419    /// The bulk bar's Delete: [`Resource::bulk_delete_records`], on the selected records
420    /// [`Ability::Delete`] allows.
421    pub(crate) fn bulk_delete() -> Self {
422        Self::deleting(true)
423    }
424
425    fn deleting(bulk: bool) -> Self {
426        Self {
427            name: "delete",
428            label: |_| "Delete".to_string(),
429            places: if bulk { Places::BULK } else { Places::RECORD },
430            resource_wide: Ability::DeleteAny,
431            can_run: |resource, cx, record| resource.can(cx, Ability::Delete(record)),
432            input: InputSpec::none(),
433            run: if bulk {
434                bulk_delete_erased::<R>
435            } else {
436                delete_erased::<R>
437            },
438            success: if bulk {
439                |_, _| "Bulk deleted".to_string()
440            } else {
441                |_, _| "Deleted".to_string()
442            },
443            acted: super::Committed::deleted,
444            failure: if bulk {
445                "delete the selected rows"
446            } else {
447                "delete the record"
448            },
449            confirm: true,
450        }
451    }
452}
453
454/// The mounted policy's [`Ability::Run`] and [`Action::can_run`] behind a function pointer.
455fn can_run_erased<R: Resource, A: Action<R>>(
456    resource: &Mounted<R>,
457    cx: &Cx,
458    record: &R::Model,
459) -> bool {
460    resource.can(
461        cx,
462        Ability::Run {
463            action: A::NAME,
464            record,
465        },
466    ) && A::can_run(cx, record)
467}
468
469/// [`ActionInput::parse`], then [`Action::validate_input`], behind a function pointer.
470fn parse_input_erased<R: Resource, A: Action<R>>(
471    cx: &Cx,
472    values: &HashMap<String, String>,
473) -> InputResult {
474    parse_validated(cx, values, A::validate_input)
475}
476
477/// `I`'s [`ActionInput::parse`], then `validate`.
478pub(crate) fn parse_validated<I: ActionInput>(
479    cx: &Cx,
480    values: &HashMap<String, String>,
481    validate: fn(&Cx, &I) -> FieldErrors,
482) -> InputResult {
483    let input = I::parse(cx, values)?;
484    let refused: Vec<FieldError> = validate(cx, &input).into_iter().collect();
485    if !refused.is_empty() {
486        return Err(refused);
487    }
488    Ok(Box::new(input))
489}
490
491/// A delete's input: nothing.
492fn parse_nothing(_cx: &Cx, _values: &HashMap<String, String>) -> InputResult {
493    Ok(Box::new(()))
494}
495
496/// [`Action::run`] behind a function pointer.
497fn run_erased<'a, R: Resource, A: Action<R>>(
498    cx: &'a Cx,
499    records: &'a [R::Model],
500    input: ErasedInput,
501    ex: &'a mut dyn toasty::Executor,
502) -> ActionFuture<'a> {
503    let input = *input
504        .downcast::<A::Input>()
505        .expect("the pipeline parses the input `run` takes");
506    Box::pin(A::run(cx, records, input, ex))
507}
508
509/// [`Resource::delete_record`] on each of `records`, behind a function pointer.
510fn delete_erased<'a, R: Resource>(
511    cx: &'a Cx,
512    records: &'a [R::Model],
513    _input: ErasedInput,
514    ex: &'a mut dyn toasty::Executor,
515) -> ActionFuture<'a> {
516    Box::pin(async move {
517        for record in records {
518            R::delete_record(cx, record, &mut *ex).await?;
519        }
520        Ok(())
521    })
522}
523
524/// [`Resource::bulk_delete_records`] behind a function pointer.
525fn bulk_delete_erased<'a, R: Resource>(
526    cx: &'a Cx,
527    records: &'a [R::Model],
528    _input: ErasedInput,
529    ex: &'a mut dyn toasty::Executor,
530) -> ActionFuture<'a> {
531    Box::pin(R::bulk_delete_records(cx, records, ex))
532}