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