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, icon::IconData};
7
8use super::{ErasedInput, InputResult, InputSpec, parse_validated};
9use crate::{
10 BoxFuture, Color, 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<std::convert::Infallible>> {
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 icon before the button's label. Defaults to none.
84 const ICON: Option<IconData> = None;
85
86 /// The colour the button is tinted in, as Delete's is in [`Color::Danger`]. Defaults to
87 /// [`Color::Gray`], the untinted button.
88 ///
89 /// It changes how the button looks, not what it asks: [`CONFIRM`](Self::CONFIRM) asks first.
90 const COLOR: Color = Color::Gray;
91
92 /// The question the confirmation dialog asks when [`CONFIRM`](Self::CONFIRM) is set. Defaults
93 /// to the message `tablo-confirm-run`, "Run this action?". An action with input confirms in
94 /// its input dialog instead, which its label titles.
95 fn confirmation() -> crate::Text {
96 crate::text!("tablo-confirm-run")
97 }
98
99 /// The button text. Defaults to the message `action-{NAME}` of the panel's catalogs, else
100 /// [`NAME`](Self::NAME) in sentence case: `"recount-tags"` reads "Recount tags".
101 fn label() -> crate::Text {
102 crate::Text::derived(derived_id("action", Self::NAME), sentence_case(Self::NAME))
103 }
104
105 /// Whether the request may run the action, beyond the policy or the page's access: a
106 /// feature flag, a role the policy does not model. Defaults to `true`.
107 fn can_run(_cx: &Cx) -> bool {
108 true
109 }
110
111 /// Refuse an input [`run`](Self::run) should not receive. Each error names a field of the
112 /// input's [`Field`](FormInput::Field) enum and renders under its control, as
113 /// [`Action::validate_input`](crate::Action::validate_input) does. Defaults to none.
114 fn validate_input(
115 _cx: &Cx,
116 _input: &Self::Input,
117 ) -> FieldErrors<<Self::Input as FormInput>::Field> {
118 FieldErrors::new()
119 }
120
121 /// Perform the action with the parsed `input`, through the framework's transaction `ex`,
122 /// and say what the response does once it commits: [`Outcome::done`](crate::Outcome::done)
123 /// for the [`success`](Self::success) notification, landing back on the list or page.
124 ///
125 /// Refuses the input with [`WriteError::Refused`](crate::WriteError::Refused), or declines
126 /// with [`WriteError::declined`](crate::WriteError::declined), as
127 /// [`Action::run`](crate::Action::run) does.
128 fn run(
129 cx: &Cx,
130 input: Self::Input,
131 ex: &mut dyn toasty::Executor,
132 ) -> impl Future<
133 Output = Result<crate::Outcome, crate::WriteError<<Self::Input as FormInput>::Field>>,
134 > + Send;
135
136 /// The success notification after a commit. Defaults to the label: `"Recount tags: done"`.
137 fn success() -> crate::Text {
138 crate::text!("tablo-action-done", action = Self::label())
139 }
140}
141
142impl<M> Ability<'_, M> {
143 /// Whether the ability is [`RunHeader`](Ability::RunHeader) for the header action `A`, which
144 /// a resource's policy answers to let the request run it.
145 ///
146 /// ```rust
147 /// # use tablo_core::{Ability, HeaderAction};
148 /// # use topcoat::{Result, context::Cx};
149 /// # #[derive(Debug, Clone, toasty::Model, tablo_core::ModelColumns)]
150 /// # struct Post { #[key] #[auto] id: uuid::Uuid }
151 /// # struct RecountTags;
152 /// # impl HeaderAction for RecountTags {
153 /// # type Input = ();
154 /// # const NAME: &'static str = "recount-tags";
155 /// # async fn run(_: &Cx, _: (), _: &mut dyn toasty::Executor) -> Result<tablo_core::Outcome, tablo_core::WriteError<std::convert::Infallible>> { Ok(tablo_core::Outcome::done()) }
156 /// # }
157 /// # fn is_admin(_cx: &Cx) -> bool { true }
158 /// fn post_policy(cx: &Cx, ability: Ability<'_, Post>) -> bool {
159 /// match ability {
160 /// _ if ability.is_header_action::<RecountTags>() => is_admin(cx),
161 /// Ability::ViewAny | Ability::View(_) => true,
162 /// _ => false,
163 /// }
164 /// }
165 /// ```
166 pub fn is_header_action<A: HeaderAction>(self) -> bool {
167 matches!(self, Ability::RunHeader { action } if action == A::NAME)
168 }
169}
170
171impl<M> crate::policy::Abilities<M> {
172 /// Who may run the header action `A` from the list's header:
173 /// [`RunHeader`](Ability::RunHeader) with `A`'s [`NAME`](HeaderAction::NAME). A header
174 /// action no `run_header` declares is refused.
175 #[must_use]
176 pub fn run_header<A: HeaderAction>(
177 mut self,
178 check: impl Fn(&Cx) -> bool + Send + Sync + 'static,
179 ) -> Self {
180 self.set_header_action(A::NAME, Box::new(check));
181 self
182 }
183}
184
185/// The [`HeaderAction`]s a page or a resource declares, in button order.
186///
187/// ```rust
188/// # use tablo_core::{HeaderAction, HeaderActions};
189/// # use topcoat::{Result, context::Cx};
190/// # struct ClearCache;
191/// # impl HeaderAction for ClearCache {
192/// # type Input = ();
193/// # const NAME: &'static str = "clear-cache";
194/// # async fn run(_: &Cx, _: (), _: &mut dyn toasty::Executor) -> Result<tablo_core::Outcome, tablo_core::WriteError<std::convert::Infallible>> { Ok(tablo_core::Outcome::done()) }
195/// # }
196/// let actions = HeaderActions::new().add::<ClearCache>();
197/// # let _ = actions;
198/// ```
199#[derive(Default)]
200pub struct HeaderActions {
201 entries: Vec<HeaderEntry>,
202}
203
204impl std::fmt::Debug for HeaderActions {
205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206 f.debug_list()
207 .entries(self.entries.iter().map(|e| e.name))
208 .finish()
209 }
210}
211
212impl HeaderActions {
213 /// No action.
214 #[must_use]
215 pub fn new() -> Self {
216 Self::default()
217 }
218
219 /// Appends the action `A`, failing to compile when `A::NAME` is not a single path segment.
220 ///
221 /// The check is a constant evaluated when `A` is instantiated, so `cargo check` does not
222 /// report it; `cargo build` does. The doctest builds the program. Its `E0080` names the
223 /// error, but rustdoc checks the code on nightly only.
224 ///
225 /// ```rust,compile_fail,E0080
226 /// # use tablo_core::{HeaderAction, HeaderActions};
227 /// # use topcoat::{Result, context::Cx};
228 /// struct ClearCache;
229 ///
230 /// impl HeaderAction for ClearCache {
231 /// type Input = ();
232 /// const NAME: &'static str = "clear cache";
233 ///
234 /// async fn run(_: &Cx, _: (), _: &mut dyn toasty::Executor) -> Result<tablo_core::Outcome, tablo_core::WriteError<std::convert::Infallible>> {
235 /// Ok(tablo_core::Outcome::done())
236 /// }
237 /// }
238 ///
239 /// let _actions = HeaderActions::new().add::<ClearCache>();
240 /// ```
241 #[must_use]
242 pub fn add<A: HeaderAction>(mut self) -> Self {
243 const {
244 assert!(
245 crate::declaration::segment_fault(A::NAME).is_none(),
246 "`HeaderAction::NAME` must be a single path segment"
247 );
248 }
249 self.entries.push(HeaderEntry {
250 name: A::NAME,
251 label: A::label,
252 can_run: A::can_run,
253 input: InputSpec::of::<A::Input>(parse_header_input::<A>),
254 run: run_header_erased::<A>,
255 success: A::success,
256 confirm: A::CONFIRM,
257 prompt: A::confirmation,
258 icon: || A::ICON,
259 color: A::COLOR,
260 });
261 self
262 }
263
264 /// Whether no action is declared.
265 #[must_use]
266 pub fn is_empty(&self) -> bool {
267 self.entries.is_empty()
268 }
269
270 /// The declared actions, in order.
271 pub(crate) fn entries(&self) -> &[HeaderEntry] {
272 &self.entries
273 }
274
275 /// The action named `name`.
276 pub(crate) fn find(&self, name: &str) -> Option<&HeaderEntry> {
277 self.entries.iter().find(|e| e.name == name)
278 }
279}
280
281/// An erased header action's body: it acts with the parsed input inside the transaction.
282pub(crate) type RunHeader = for<'a> fn(
283 &'a Cx,
284 ErasedInput,
285 &'a mut dyn toasty::Executor,
286) -> BoxFuture<'a, Result<crate::Outcome, crate::WriteError>>;
287
288/// A [`HeaderAction`] with its type erased.
289#[derive(Clone, Copy)]
290pub(crate) struct HeaderEntry {
291 pub(crate) name: &'static str,
292 pub(crate) label: fn() -> crate::Text,
293 pub(crate) can_run: fn(&Cx) -> bool,
294 pub(crate) input: InputSpec,
295 pub(crate) run: RunHeader,
296 pub(crate) success: fn() -> crate::Text,
297 pub(crate) confirm: bool,
298 /// The question the dialog confirming it asks.
299 pub(crate) prompt: fn() -> crate::Text,
300 /// The icon before its button's label.
301 pub(crate) icon: fn() -> Option<IconData>,
302 /// The colour its button is tinted in.
303 pub(crate) color: Color,
304}
305
306/// [`FormInput::parse`], then [`HeaderAction::validate_input`], behind a function pointer.
307fn parse_header_input<A: HeaderAction>(cx: &Cx, values: &FormValues) -> InputResult {
308 parse_validated(cx, values, A::validate_input)
309}
310
311/// [`HeaderAction::run`] behind a function pointer.
312fn run_header_erased<'a, A: HeaderAction>(
313 cx: &'a Cx,
314 input: ErasedInput,
315 ex: &'a mut dyn toasty::Executor,
316) -> BoxFuture<'a, Result<crate::Outcome, crate::WriteError>> {
317 let input = *input
318 .downcast::<A::Input>()
319 .expect("the pipeline parses the input `run` takes");
320 Box::pin(async move {
321 A::run(cx, input, ex)
322 .await
323 .map_err(|error| error.keyed(<A::Input as FormInput>::key))
324 })
325}