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