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