mod input;
use std::{any::Any, collections::HashMap, future::Future, pin::Pin};
pub use input::ActionInput;
pub(crate) use input::RESERVED_KEYS;
#[doc(hidden)]
pub use input::required_input;
use topcoat::{Result, context::Cx};
use super::{Mounted, Resource};
use crate::{
form::{FieldError, FieldErrors},
naming::sentence_case,
policy::Ability,
schema::Schema,
};
pub trait Action<R: Resource>: 'static {
type Input: ActionInput;
const NAME: &'static str;
const PLACES: Places = Places::ALL;
const CONFIRM: bool = false;
fn label(_cx: &Cx) -> String {
sentence_case(Self::NAME)
}
fn can_run(_cx: &Cx, _record: &R::Model) -> bool {
true
}
fn validate_input(_cx: &Cx, _input: &Self::Input) -> FieldErrors {
FieldErrors::new()
}
fn run(
cx: &Cx,
records: &[R::Model],
input: Self::Input,
ex: &mut dyn toasty::Executor,
) -> impl Future<Output = Result<()>> + Send;
fn success(cx: &Cx, count: usize) -> String {
let noun = if count == 1 { "record" } else { "records" };
format!("{}: {count} {noun}", Self::label(cx))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Places(u8);
impl Places {
pub const ROW: Self = Self(1);
pub const DETAIL: Self = Self(1 << 1);
pub const EDIT: Self = Self(1 << 2);
pub const BULK: Self = Self(1 << 3);
pub const RECORD: Self = Self(Self::ROW.0 | Self::DETAIL.0 | Self::EDIT.0);
pub const ALL: Self = Self(Self::RECORD.0 | Self::BULK.0);
#[must_use]
pub const fn with(self, other: Self) -> Self {
Self(self.0 | other.0)
}
#[must_use]
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
#[must_use]
pub const fn intersects(self, other: Self) -> bool {
self.0 & other.0 != 0
}
}
impl<M> Ability<'_, M> {
pub fn is_action<A, R>(self) -> bool
where
A: Action<R>,
R: Resource<Model = M>,
{
matches!(self, Ability::RunAny { action } | Ability::Run { action, .. } if action == A::NAME)
}
}
pub(crate) type ActionFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
pub(crate) type ErasedInput = Box<dyn Any + Send>;
pub(crate) type InputResult = std::result::Result<ErasedInput, Vec<FieldError>>;
#[derive(Clone, Copy)]
pub(crate) struct InputSpec {
pub(crate) schema: fn() -> Schema,
pub(crate) takes_input: bool,
pub(crate) parse: fn(&Cx, &HashMap<String, String>) -> InputResult,
}
impl InputSpec {
pub(crate) fn of<I: ActionInput>(
parse: fn(&Cx, &HashMap<String, String>) -> InputResult,
) -> Self {
Self {
schema: I::schema,
takes_input: !I::schema().is_empty(),
parse,
}
}
pub(crate) fn none() -> Self {
Self {
schema: Schema::empty,
takes_input: false,
parse: parse_nothing,
}
}
}
pub(crate) struct Actions<R: Resource> {
entries: Vec<ActionEntry<R>>,
}
impl<R: Resource> Default for Actions<R> {
fn default() -> Self {
Self {
entries: Vec::new(),
}
}
}
impl<R: Resource> std::fmt::Debug for Actions<R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_list()
.entries(self.entries.iter().map(|e| e.name))
.finish()
}
}
impl<R: Resource> Actions<R> {
pub(crate) fn add<A: Action<R>>(mut self) -> Self {
const {
assert!(
crate::declaration::segment_fault(A::NAME).is_none(),
"`Action::NAME` must be a single path segment"
);
}
self.entries.push(ActionEntry {
name: A::NAME,
label: A::label,
places: A::PLACES,
resource_wide: Ability::RunAny { action: A::NAME },
can_run: can_run_erased::<R, A>,
input: InputSpec::of::<A::Input>(parse_input_erased::<R, A>),
run: run_erased::<R, A>,
success: A::success,
acted: super::Committed::acted::<R, A>,
failure: "run the action",
confirm: A::CONFIRM,
});
self
}
pub(crate) fn entries(&self) -> &[ActionEntry<R>] {
&self.entries
}
pub(crate) fn find(&self, name: &str) -> Option<&ActionEntry<R>> {
self.entries.iter().find(|e| e.name == name)
}
}
pub(crate) struct ActionEntry<R: Resource> {
pub(crate) name: &'static str,
pub(crate) label: fn(&Cx) -> String,
pub(crate) places: Places,
pub(crate) resource_wide: Ability<'static, R::Model>,
pub(crate) can_run: fn(&Mounted<R>, &Cx, &R::Model) -> bool,
pub(crate) input: InputSpec,
pub(crate) run: for<'a> fn(
&'a Cx,
&'a [R::Model],
ErasedInput,
&'a mut dyn toasty::Executor,
) -> ActionFuture<'a>,
pub(crate) success: fn(&Cx, usize) -> String,
pub(crate) acted: fn(Vec<R::Model>) -> super::Committed<R::Model>,
pub(crate) failure: &'static str,
pub(crate) confirm: bool,
}
impl<R: Resource> Clone for ActionEntry<R> {
fn clone(&self) -> Self {
*self
}
}
impl<R: Resource> Copy for ActionEntry<R> {}
impl<R: Resource> ActionEntry<R> {
pub(crate) fn delete() -> Self {
Self::deleting(false)
}
pub(crate) fn bulk_delete() -> Self {
Self::deleting(true)
}
fn deleting(bulk: bool) -> Self {
Self {
name: "delete",
label: |_| "Delete".to_string(),
places: if bulk { Places::BULK } else { Places::RECORD },
resource_wide: Ability::DeleteAny,
can_run: |resource, cx, record| resource.can(cx, Ability::Delete(record)),
input: InputSpec::none(),
run: if bulk {
bulk_delete_erased::<R>
} else {
delete_erased::<R>
},
success: if bulk {
|_, _| "Bulk deleted".to_string()
} else {
|_, _| "Deleted".to_string()
},
acted: super::Committed::deleted,
failure: if bulk {
"delete the selected rows"
} else {
"delete the record"
},
confirm: true,
}
}
}
fn can_run_erased<R: Resource, A: Action<R>>(
resource: &Mounted<R>,
cx: &Cx,
record: &R::Model,
) -> bool {
resource.can(
cx,
Ability::Run {
action: A::NAME,
record,
},
) && A::can_run(cx, record)
}
fn parse_input_erased<R: Resource, A: Action<R>>(
cx: &Cx,
values: &HashMap<String, String>,
) -> InputResult {
parse_validated(cx, values, A::validate_input)
}
pub(crate) fn parse_validated<I: ActionInput>(
cx: &Cx,
values: &HashMap<String, String>,
validate: fn(&Cx, &I) -> FieldErrors,
) -> InputResult {
let input = I::parse(cx, values)?;
let refused: Vec<FieldError> = validate(cx, &input).into_iter().collect();
if !refused.is_empty() {
return Err(refused);
}
Ok(Box::new(input))
}
fn parse_nothing(_cx: &Cx, _values: &HashMap<String, String>) -> InputResult {
Ok(Box::new(()))
}
fn run_erased<'a, R: Resource, A: Action<R>>(
cx: &'a Cx,
records: &'a [R::Model],
input: ErasedInput,
ex: &'a mut dyn toasty::Executor,
) -> ActionFuture<'a> {
let input = *input
.downcast::<A::Input>()
.expect("the pipeline parses the input `run` takes");
Box::pin(A::run(cx, records, input, ex))
}
fn delete_erased<'a, R: Resource>(
cx: &'a Cx,
records: &'a [R::Model],
_input: ErasedInput,
ex: &'a mut dyn toasty::Executor,
) -> ActionFuture<'a> {
Box::pin(async move {
for record in records {
R::delete_record(cx, record, &mut *ex).await?;
}
Ok(())
})
}
fn bulk_delete_erased<'a, R: Resource>(
cx: &'a Cx,
records: &'a [R::Model],
_input: ErasedInput,
ex: &'a mut dyn toasty::Executor,
) -> ActionFuture<'a> {
Box::pin(R::bulk_delete_records(cx, records, ex))
}