Skip to main content

tablo_core/resource/
header_action.rs

1//! Actions on no record: the [`HeaderAction`] trait, and the [`HeaderActions`] a
2//! [`Page`](crate::Page) or a [`ResourceDef`](super::ResourceDef) declares them in.
3
4use std::future::Future;
5
6use topcoat::{Result, context::Cx};
7
8use super::{ErasedInput, InputResult, InputSpec, parse_validated};
9use crate::{
10    BoxFuture, FormInput,
11    form::{FieldErrors, FormValues},
12    naming::{derived_id, sentence_case},
13    policy::Ability,
14};
15
16/// A mutation that acts on no record, run from a button in a page's header: "import posts",
17/// "clear the cache", "recompute the totals".
18///
19/// A resource declares one with
20/// [`ResourceDef::header_action`](crate::ResourceDef::header_action), and its list page renders
21/// the button; a [`Page`](crate::Page) declares one with
22/// [`PageDef::header_action`](crate::PageDef::header_action), and renders the buttons with
23/// [`header_action_buttons`](crate::header_action_buttons):
24///
25/// ```rust
26/// # use tablo_core::HeaderAction;
27/// # use toasty::Executor;
28/// # use topcoat::{Result, context::Cx};
29/// struct RecountTags;
30///
31/// impl HeaderAction for RecountTags {
32///     type Input = ();
33///     const NAME: &'static str = "recount-tags";
34///
35///     async fn run(
36///         _cx: &Cx,
37///         _: (),
38///         _ex: &mut dyn Executor,
39///     ) -> Result<tablo_core::Outcome, tablo_core::WriteError> {
40///         // Recount through `ex`, the framework's transaction.
41///         Ok(tablo_core::Outcome::done())
42///     }
43/// }
44/// ```
45///
46/// The framework owns everything around [`run`](Self::run), as it does for a record's
47/// [`Action`](crate::Action):
48///
49/// - the route, `{url}/-/actions/{NAME}` under the list or page URL, and its CSRF check;
50/// - who may run it: on a resource, the policy's [`Ability::ViewAny`] and [`Ability::RunHeader`]
51///   with the action's `NAME`; on a page, [`PageDef::access`](crate::PageDef::access); on either,
52///   then [`can_run`](Self::can_run). A refusal answers 403 and renders no button;
53/// - the transaction: `run` writes through its executor, and an error rolls everything back;
54/// - on a resource, the hooks, with [`Change::HeaderAction`](crate::Change::HeaderAction):
55///   [`Resource::before_commit`](crate::Resource::before_commit) in the transaction after `run`,
56///   then [`Resource::after_commit`](crate::Resource::after_commit) once it commits;
57/// - the success notification, and the redirect back to the list as it was left, its search, sort
58///   and filters kept, or to the page.
59///
60/// No record is loaded, so nothing scopes [`run`](Self::run) to the request's tenant: an action
61/// over a tenant's records reads them through [`scoped_query`](crate::scoped_query).
62///
63/// An action that asks for an [`Input`](Self::Input) asks for it as a record's action does, in a
64/// dialog or a page titled with its label, and runs on its submit.
65pub trait HeaderAction: 'static {
66    /// What the action asks for before it runs: `()` for nothing, or an
67    /// [`FormInput`](derive@crate::FormInput) struct.
68    type Input: FormInput;
69
70    /// The action's URL segment, distinct among the actions of its resource or page.
71    ///
72    /// A name that is not a single path segment does not compile, as for
73    /// [`Action::NAME`](crate::Action::NAME).
74    const NAME: &'static str;
75
76    /// Whether the action asks first through a confirmation dialog with destructive wording.
77    /// Defaults to `false`.
78    ///
79    /// An action with input confirms in its input dialog, or on its input page, instead. An
80    /// unconfirmed POST that would write answers 400.
81    const CONFIRM: bool = false;
82
83    /// The button text. Defaults to the message `action-{NAME}` of the panel's catalogs, else
84    /// [`NAME`](Self::NAME) in sentence case: `"recount-tags"` reads "Recount tags".
85    fn label(cx: &Cx) -> String {
86        crate::Text::derived(derived_id("action", Self::NAME), sentence_case(Self::NAME))
87            .localize(cx)
88    }
89
90    /// Whether the request may run the action, beyond the policy or the page's access: a
91    /// feature flag, a role the policy does not model. Defaults to `true`.
92    fn can_run(_cx: &Cx) -> bool {
93        true
94    }
95
96    /// Refuse an input [`run`](Self::run) should not receive. Each error names an input field's
97    /// key and renders under its control. Defaults to none. A key no control renders is a
98    /// declaration error: the page would show no message.
99    fn validate_input(_cx: &Cx, _input: &Self::Input) -> FieldErrors {
100        FieldErrors::new()
101    }
102
103    /// Perform the action with the parsed `input`, through the framework's transaction `ex`,
104    /// and say what the response does once it commits: [`Outcome::done`](crate::Outcome::done)
105    /// for the [`success`](Self::success) notification, landing back on the list or page.
106    ///
107    /// Refuses the input with [`WriteError::Refused`](crate::WriteError::Refused), as
108    /// [`Action::run`](crate::Action::run) does.
109    fn run(
110        cx: &Cx,
111        input: Self::Input,
112        ex: &mut dyn toasty::Executor,
113    ) -> impl Future<Output = Result<crate::Outcome, crate::WriteError>> + Send;
114
115    /// The success notification after a commit. Defaults to the label: `"Recount tags: done"`.
116    fn success(cx: &Cx) -> String {
117        crate::t!(cx, "tablo-action-done", action = Self::label(cx))
118    }
119}
120
121impl<M> Ability<'_, M> {
122    /// Whether the ability is [`RunHeader`](Ability::RunHeader) for the header action `A`, which
123    /// a resource's policy answers to let the request run it.
124    ///
125    /// ```rust
126    /// # use tablo_core::{Ability, HeaderAction};
127    /// # use topcoat::{Result, context::Cx};
128    /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
129    /// # struct Post { #[key] #[auto] id: uuid::Uuid }
130    /// # struct RecountTags;
131    /// # impl HeaderAction for RecountTags {
132    /// #     type Input = ();
133    /// #     const NAME: &'static str = "recount-tags";
134    /// #     async fn run(_: &Cx, _: (), _: &mut dyn toasty::Executor) -> Result<tablo_core::Outcome, tablo_core::WriteError> { Ok(tablo_core::Outcome::done()) }
135    /// # }
136    /// # fn is_admin(_cx: &Cx) -> bool { true }
137    /// fn post_policy(cx: &Cx, ability: Ability<'_, Post>) -> bool {
138    ///     match ability {
139    ///         _ if ability.is_header_action::<RecountTags>() => is_admin(cx),
140    ///         Ability::ViewAny | Ability::View(_) => true,
141    ///         _ => false,
142    ///     }
143    /// }
144    /// ```
145    pub fn is_header_action<A: HeaderAction>(self) -> bool {
146        matches!(self, Ability::RunHeader { action } if action == A::NAME)
147    }
148}
149
150impl<M> crate::policy::Abilities<M> {
151    /// Who may run the header action `A` from the list's header:
152    /// [`RunHeader`](Ability::RunHeader) with `A`'s [`NAME`](HeaderAction::NAME). A header
153    /// action no `run_header` declares is refused.
154    #[must_use]
155    pub fn run_header<A: HeaderAction>(
156        mut self,
157        check: impl Fn(&Cx) -> bool + Send + Sync + 'static,
158    ) -> Self {
159        self.set_header_action(A::NAME, Box::new(check));
160        self
161    }
162}
163
164/// The [`HeaderAction`]s a page or a resource declares, in button order.
165///
166/// ```rust
167/// # use tablo_core::{HeaderAction, HeaderActions};
168/// # use topcoat::{Result, context::Cx};
169/// # struct ClearCache;
170/// # impl HeaderAction for ClearCache {
171/// #     type Input = ();
172/// #     const NAME: &'static str = "clear-cache";
173/// #     async fn run(_: &Cx, _: (), _: &mut dyn toasty::Executor) -> Result<tablo_core::Outcome, tablo_core::WriteError> { Ok(tablo_core::Outcome::done()) }
174/// # }
175/// let actions = HeaderActions::new().add::<ClearCache>();
176/// # let _ = actions;
177/// ```
178#[derive(Default)]
179pub struct HeaderActions {
180    entries: Vec<HeaderEntry>,
181}
182
183impl std::fmt::Debug for HeaderActions {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        f.debug_list()
186            .entries(self.entries.iter().map(|e| e.name))
187            .finish()
188    }
189}
190
191impl HeaderActions {
192    /// No action.
193    #[must_use]
194    pub fn new() -> Self {
195        Self::default()
196    }
197
198    /// Appends the action `A`, failing to compile when `A::NAME` is not a single path segment.
199    ///
200    /// The check is a constant evaluated when `A` is instantiated, so `cargo check` does not
201    /// report it; `cargo build` does. The doctest builds the program. Its `E0080` names the
202    /// error, but rustdoc checks the code on nightly only.
203    ///
204    /// ```rust,compile_fail,E0080
205    /// # use tablo_core::{HeaderAction, HeaderActions};
206    /// # use topcoat::{Result, context::Cx};
207    /// struct ClearCache;
208    ///
209    /// impl HeaderAction for ClearCache {
210    ///     type Input = ();
211    ///     const NAME: &'static str = "clear cache";
212    ///
213    ///     async fn run(_: &Cx, _: (), _: &mut dyn toasty::Executor) -> Result<tablo_core::Outcome, tablo_core::WriteError> {
214    ///         Ok(tablo_core::Outcome::done())
215    ///     }
216    /// }
217    ///
218    /// let _actions = HeaderActions::new().add::<ClearCache>();
219    /// ```
220    #[must_use]
221    pub fn add<A: HeaderAction>(mut self) -> Self {
222        const {
223            assert!(
224                crate::declaration::segment_fault(A::NAME).is_none(),
225                "`HeaderAction::NAME` must be a single path segment"
226            );
227        }
228        self.entries.push(HeaderEntry {
229            name: A::NAME,
230            label: A::label,
231            can_run: A::can_run,
232            input: InputSpec::of::<A::Input>(parse_header_input::<A>),
233            run: run_header_erased::<A>,
234            success: A::success,
235            confirm: A::CONFIRM,
236        });
237        self
238    }
239
240    /// Whether no action is declared.
241    #[must_use]
242    pub fn is_empty(&self) -> bool {
243        self.entries.is_empty()
244    }
245
246    /// The declared actions, in order.
247    pub(crate) fn entries(&self) -> &[HeaderEntry] {
248        &self.entries
249    }
250
251    /// The action named `name`.
252    pub(crate) fn find(&self, name: &str) -> Option<&HeaderEntry> {
253        self.entries.iter().find(|e| e.name == name)
254    }
255}
256
257/// An erased header action's body: it acts with the parsed input inside the transaction.
258pub(crate) type RunHeader = for<'a> fn(
259    &'a Cx,
260    ErasedInput,
261    &'a mut dyn toasty::Executor,
262) -> BoxFuture<'a, Result<crate::Outcome, crate::WriteError>>;
263
264/// A [`HeaderAction`] with its type erased.
265#[derive(Clone, Copy)]
266pub(crate) struct HeaderEntry {
267    pub(crate) name: &'static str,
268    pub(crate) label: fn(&Cx) -> String,
269    pub(crate) can_run: fn(&Cx) -> bool,
270    pub(crate) input: InputSpec,
271    pub(crate) run: RunHeader,
272    pub(crate) success: fn(&Cx) -> String,
273    pub(crate) confirm: bool,
274}
275
276/// [`FormInput::parse`], then [`HeaderAction::validate_input`], behind a function pointer.
277fn parse_header_input<A: HeaderAction>(cx: &Cx, values: &FormValues) -> InputResult {
278    parse_validated(cx, values, A::validate_input)
279}
280
281/// [`HeaderAction::run`] behind a function pointer.
282fn run_header_erased<'a, A: HeaderAction>(
283    cx: &'a Cx,
284    input: ErasedInput,
285    ex: &'a mut dyn toasty::Executor,
286) -> BoxFuture<'a, Result<crate::Outcome, crate::WriteError>> {
287    let input = *input
288        .downcast::<A::Input>()
289        .expect("the pipeline parses the input `run` takes");
290    Box::pin(A::run(cx, input, ex))
291}