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