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