quokka-admin 0.1.0

An admin panel for quokka
Documentation
use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc};

use quokka::state::FromState;
pub use quokka_admin_macros::{AdminCreateForm, AdminUpdateForm};

#[derive(Clone)]
pub struct FormBuilder<S> {
    state: S,
}

pub trait AdminCreateForm<S> {
    fn entity_name() -> &'static str;
    fn get_form() -> Form<S>;
    fn create_query(self, state: &S) -> impl Future<Output = quokka::Result<()>> + Send;
}

pub trait AdminUpdateForm<S>: Sized {
    type PrimaryKeys;

    fn entity_name() -> &'static str;

    fn get_form() -> Form<S>;

    fn update_query(self, state: &S) -> impl Future<Output = quokka::Result<()>> + Send;

    fn get_query(
        state: &S,
        pks: Self::PrimaryKeys,
    ) -> impl Future<Output = quokka::Result<Self>> + Send;
}

#[derive(Clone)]
pub struct Form<S> {
    pub entity_name: String,
    pub action: String,
    pub fields: Vec<Arc<dyn FormField<S> + Send + Sync>>,
}

pub struct FormFieldPreProcessorContext<'a> {
    pub field: &'a mut FormFieldData,
}

pub trait FormFieldPreProcessor {
    fn process_form_data(
        &self,
        context: FormFieldPreProcessorContext<'_>,
    ) -> Pin<Box<dyn Future<Output = quokka::Result<()>> + Send + Sync>>;
}

pub trait FormField<S> {
    fn template(&self) -> String;

    fn name(&self) -> String;

    fn label(&self) -> String;

    fn default(&self) -> Option<String> {
        None
    }

    fn required(&self) -> bool {
        false
    }

    fn additional_options(&self) -> HashMap<String, serde_json::Value> {
        HashMap::new()
    }

    fn to_form_field_data(
        &self,
        state: &S,
    ) -> Pin<Box<dyn Future<Output = quokka::Result<FormFieldData>> + Send + Sync>> {
        let data = FormFieldData {
            template: self.template().to_string(),
            name: self.name().to_string(),
            label: self.label().to_string(),
            default: self.default(),
            required: self.required(),
            additional_options: self.additional_options(),
        };

        let processor = self.processor(state);

        Box::pin(async move {
            let mut data = data.clone();

            if let Some(processor) = processor {
                let ctx = FormFieldPreProcessorContext { field: &mut data };

                processor.process_form_data(ctx).await?;
            }

            Ok(data)
        })
    }

    fn processor(&self, _state: &S) -> Option<Box<dyn FormFieldPreProcessor + Send + Sync>> {
        None
    }
}

#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct FormFieldData {
    pub template: String,
    pub name: String,
    pub label: String,
    pub default: Option<String>,
    pub required: bool,
    pub additional_options: HashMap<String, serde_json::Value>,
}

#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
pub struct FormProperties {
    pub action: String,
    pub entity: String,
    pub fields: Vec<FormFieldData>,
}

pub mod fields {
    use super::FormField;
    use std::collections::HashMap;

    macro_rules! input_field {
        ($(#[$attr:meta])* $field_name:ident, $template:expr) => {
            $(#[$attr])*
            #[derive(Clone, Debug, Default)]
            pub struct $field_name {
                pub name: String,
                pub label: String,
                pub template: String,
                pub default: Option<String>,
                pub required: bool,
                pub additional_options: HashMap<String, serde_json::Value>,
            }

            impl<S> FormField<S> for $field_name {
                fn name(&self) -> String {
                    self.name.clone()
                }

                fn label(&self) -> String {
                    self.label.clone()
                }

                fn template(&self) -> String {
                    self.template.clone()
                }

                fn default(&self) -> Option<String> {
                    self.default.clone()
                }

                fn required(&self) -> bool {
                    self.required
                }

                fn additional_options(&self) -> HashMap<String, serde_json::Value> {
                    self.additional_options.clone()
                }
            }

            impl $field_name {
                pub fn new(name: impl ToString, label: impl ToString) -> Self {
                    Self {
                        name: name.to_string(),
                        label: label.to_string(),
                        template: $template.to_string(),
                        ..Default::default()
                    }
                }

                pub fn set_template(mut self, new: String) -> Self {
                    self.template = new;

                    self
                }

                pub fn set_label(mut self, new: String) -> Self {
                    self.label = new;

                    self
                }

                pub fn set_default(mut self, new: Option<String>) -> Self {
                    self.default = new;

                    self
                }

                pub fn set_required(mut self, new: bool) -> Self {
                    self.required = new;

                    self
                }
            }
        };
    }

    input_field!(
        /// A `<input type=text>`
        TextField,
        "partials/admin/field/text_field"
    );
    input_field!(
        /// A `<input type=password>`
        PasswordField,
        "partials/admin/field/text_field/password"
    );
    input_field!(
        /// A `<input type=number>`
        NumberField,
        "partials/admin/field/number_field"
    );
    input_field!(
        /// A `<input type=checkbox>`
        CheckboxField,
        "partials/admin/field/checkbox_field"
    );
    input_field!(
        /// A field for a selection.
        /// Using the [SelectFieldStyle] it can either be displayed as a combobox or multiple radio buttons
        SelectField,
        "partials/admin/field/select_field/combo_box"
    );
    input_field!(
        /// A field that is not visible in the frontend (`type=hidden`). Can be used to keep things like the
        /// "id" of an entity or other meta data around
        HiddenField,
        "partials/admin/field/hidden_field"
    );
    input_field!(
        /// A field that is `<input type=text disabled>`. Used to display some static information that is not
        /// supposed to be set or edited by the user (like an entity's id)
        DisplayField,
        "partials/admin/field/display_field"
    );
    input_field!(
        /// A field that is configurable in the `<input type=$TYPE>`. It comes with a label and except for this
        /// is just a plain old HTML input element. This can be used for things that do not (yet) have a respective
        /// `*Field` type.
        HtmlInputField,
        "partials/admin/field/html_input_field"
    );

    /// Set the style of a [SelectField]
    ///
    /// - The [Combobox] is the usual drop down view of a <select> field in HTML
    /// - The [Radio] is shown as a list of radio buttons
    pub enum SelectFieldStyle {
        Combobox,
        Radio,
    }

    impl SelectField {
        pub fn add_option(mut self, label: impl ToString, value: impl ToString) -> Self {
            self.additional_options
                .entry("options".to_string())
                .and_modify(|entry| {
                    if let serde_json::Value::Array(values) = entry {
                        values.push(serde_json::json! {{
                            "label": label.to_string(),
                            "value": value.to_string(),
                        }});
                    }
                })
                .or_insert(serde_json::json!([{
                    "label": label.to_string(),
                    "value": value.to_string(),
                }]));

            self
        }

        pub fn style(mut self, style: SelectFieldStyle) -> Self {
            match style {
                SelectFieldStyle::Combobox => {
                    self.template = String::from("partials/admin/field/select_field/combo_box")
                }
                SelectFieldStyle::Radio => {
                    self.template = String::from("partials/admin/field/select_field/radio_buttons")
                }
            }

            self
        }
    }

    impl HtmlInputField {
        pub fn set_type(mut self, typ: impl ToString) -> Self {
            self.additional_options
                .insert("type".to_string(), typ.to_string().into());

            self
        }

        pub fn set_attribute(mut self, name: impl ToString, value: impl ToString) -> Self {
            self.additional_options
                .entry("attributes".to_string())
                .and_modify(|entry| {
                    if let serde_json::Value::Object(object) = entry {
                        object.insert(name.to_string(), value.to_string().into());
                    }
                })
                .or_insert(serde_json::json! {{ name.to_string(): value.to_string() }});

            self
        }
    }
}

impl<S: Clone + Send + Sync + 'static> FromState<S> for FormBuilder<S> {
    fn from_state(state: &S) -> Self {
        Self {
            state: state.clone(),
        }
    }
}

impl<S: Send + Sync + 'static> FormBuilder<S> {
    pub async fn construct_form_data(&self, form: Form<S>) -> quokka::Result<FormProperties> {
        let fields = futures::future::try_join_all(
            form.fields
                .into_iter()
                .map(|field| field.to_form_field_data(&self.state)),
        )
        .await?;

        let form = FormProperties {
            action: format!("/admin/entity/{}/{}", form.entity_name, form.action),
            fields,
            entity: form.entity_name,
        };

        Ok(form)
    }
}

impl<S> Form<S> {
    pub fn new(entity_name: impl ToString, action: impl ToString) -> Self {
        Self {
            entity_name: entity_name.to_string(),
            action: action.to_string(),
            fields: Default::default(),
        }
    }

    pub fn add_field(mut self, field: impl FormField<S> + Send + Sync + 'static) -> Self {
        self.fields.push(Arc::new(field));

        self
    }
}