From 453d36dd86f63e7d3d478f873be1539b04e1337a Mon Sep 17 00:00:00 2001 From: Wes Holland Date: Thu, 20 Mar 2025 20:21:55 -0500 Subject: [PATCH] More egonomic form handling --- src/app/item/adjustment/positive.rs | 85 ++++---- src/app/item/create.rs | 204 +++++++----------- src/session.rs | 2 +- src/util/extract/htmx_form_data.rs | 26 --- src/util/extract/mod.rs | 3 - src/util/extract/validated_form.rs | 82 ------- src/util/form/base_response.rs | 17 ++ src/util/form/extractors.rs | 107 +++++++++ src/util/form/field_error.rs | 36 ++++ src/util/form/field_value.rs | 88 ++++++++ .../form_helpers.rs => form/helpers.rs} | 3 + src/util/form/mod.rs | 6 + src/util/form/validate_form.rs | 54 +++++ src/util/mod.rs | 2 +- .../adjustment/positive-adjustment-form.html | 14 +- templates/item/item-create-form.html | 42 ++-- 16 files changed, 464 insertions(+), 307 deletions(-) delete mode 100644 src/util/extract/htmx_form_data.rs delete mode 100644 src/util/extract/mod.rs delete mode 100644 src/util/extract/validated_form.rs create mode 100644 src/util/form/base_response.rs create mode 100644 src/util/form/extractors.rs create mode 100644 src/util/form/field_error.rs create mode 100644 src/util/form/field_value.rs rename src/util/{extract/form_helpers.rs => form/helpers.rs} (77%) create mode 100644 src/util/form/mod.rs create mode 100644 src/util/form/validate_form.rs diff --git a/src/app/item/adjustment/positive.rs b/src/app/item/adjustment/positive.rs index e838314..5dbe402 100644 --- a/src/app/item/adjustment/positive.rs +++ b/src/app/item/adjustment/positive.rs @@ -4,7 +4,6 @@ use crate::db::inventory_item::does_inventory_item_allow_fractional_units; use crate::error::AppError; use crate::session::SessionUser; use crate::util::currency::dollars_string_to_int_cents; -use crate::util::extract::htmx_form_data::{HtmxFormData, HtmxFormDataError}; use askama::Template; use askama_axum::{IntoResponse, Response}; use axum::extract::{Path, State}; @@ -14,13 +13,17 @@ use serde::Deserialize; use sqlx::SqlitePool; use tracing::info; use crate::app::routes::AppState; +use crate::util::form::extractors::{FormWithPathVars, ValidForm}; +use crate::util::form::field_error::FieldError; +use crate::util::form::field_value::{FloatField, StringField}; +use crate::util::form::validate_form::{ValidateForm, ValidateFormError}; -#[derive(Template)] +#[derive(Template, Debug, Default)] #[template(path = "item/adjustment/positive-adjustment-form.html")] pub struct PositiveAdjustmentFormTemplate { pub item_id: i64, - pub amount_error: &'static str, - pub price_error: &'static str, + pub amount: StringField, + pub price: StringField, } #[derive(Deserialize, Debug)] @@ -29,41 +32,43 @@ pub struct PositiveAdjustmentFormData { pub price: String, } -#[debug_handler] +#[async_trait] +impl ValidateForm for FormWithPathVars { + type ValidationErrorResponse = PositiveAdjustmentFormTemplate; + + async fn validate(self, state: &AppState) -> Result> { + let item_id = self.path_data; + let fractional_units_allowed = does_inventory_item_allow_fractional_units(&state.db, item_id).await?; + + let amount = FloatField::new(self.form_data.amount) + .invalid_if(|v| *v == 0.0 || v.is_nan() || v.is_sign_negative(), FieldError::PositiveNumber) + .invalid_if(|v| !(fractional_units_allowed || v.fract() == 0.0), FieldError::WholeNumber) + .map(|v| if fractional_units_allowed { format!("{:.2}", v) } else { format!("{:.0}", v) }); + + let price = StringField::new(self.form_data.price.clone()) + .invalid_if(|v| dollars_string_to_int_cents(v).is_err(), FieldError::Currency); + + if amount.is_error() || price.is_error() { + return Err(ValidateFormError::ValidationError(Self::ValidationErrorResponse { + item_id, + amount, + price, + })); + } + + Ok(self) + } +} + pub async fn positive_adjustment_form_post( State(db): State, - Path(id): Path, user: SessionUser, - form_data: Form, + data: ValidForm> ) -> Result { - let fractional_units_allowed = does_inventory_item_allow_fractional_units(&db, id).await?; - - let amount_error = if form_data.amount == 0.0 { - "Please input a non-zero amount" - } else if !(fractional_units_allowed || form_data.amount.fract() == 0.0) { - "Please input a whole number" - } else { - "" - }; - - let price = dollars_string_to_int_cents(&form_data.price); - let price_error = if price.is_err() { - "Please input a dollar amount" - } else { - "" - }; - - if !(amount_error.is_empty() && price_error.is_empty()) { - return Ok(PositiveAdjustmentFormTemplate { - item_id: id, - amount_error, - price_error, - } - .into_response()); - } - let price = price?; - let unit_price = (price as f64 / form_data.amount) as i64; + let id = data.path_data; + let price = dollars_string_to_int_cents(&data.form_data.price)?; + let unit_price = (price as f64 / data.form_data.amount) as i64; let trigger_events = vec![ HxEvent::from("form-submit-success"), @@ -72,11 +77,11 @@ pub async fn positive_adjustment_form_post( let timestamp = chrono::Utc::now(); - let adjustment_amount = form_data.amount; + let adjustment_amount = data.form_data.amount; info!( "Add adjustment form: {:?} for user {}", - form_data, user.name + data.form_data, user.name ); let _new_id = add_adjustment( @@ -95,19 +100,17 @@ pub async fn positive_adjustment_form_post( HxResponseTrigger::normal(trigger_events), PositiveAdjustmentFormTemplate { item_id: id, - amount_error: "", - price_error: "", + ..Default::default() } .into_response(), ) - .into_response()) + .into_response()) } pub async fn positive_adjustment_form_get(Path(id): Path) -> Result { Ok(PositiveAdjustmentFormTemplate { item_id: id, - amount_error: "", - price_error: "", + ..Default::default() } .into_response()) } diff --git a/src/app/item/create.rs b/src/app/item/create.rs index 04cb199..5222680 100644 --- a/src/app/item/create.rs +++ b/src/app/item/create.rs @@ -3,45 +3,47 @@ use crate::session::SessionUser; use askama::Template; use askama_axum::{IntoResponse, Response}; use axum::extract::State; -use axum::{async_trait, debug_handler, Form}; +use axum::{async_trait, debug_handler}; use axum_htmx::{HxEvent, HxResponseTrigger}; use serde::Deserialize; -use tracing::info; use crate::app::routes::AppState; use crate::db::display_unit::DbDisplayUnit; use crate::db; -use crate::util::extract::htmx_form_data::{HtmxFormData, HtmxFormDataError}; -use crate::util::extract::validated_form::ValidatedForm; -use crate::util::extract::form_helpers::deserialize_form_checkbox; - -#[derive(Template, Debug)] +use crate::util::form::field_error::FieldError; +use crate::util::form::field_value::{BoolField, FloatField, StringField}; +use crate::util::form::validate_form::{ValidateForm, ValidateFormError}; +use crate::util::form::helpers::deserialize_form_checkbox; +use crate::util::form::base_response::BaseResponse; +use crate::util::form::extractors::{FormBase, ValidForm}; + +#[derive(Template, Debug, Default)] #[template(path = "item/item-create-form.html")] pub struct CreateItemFormTemplate { pub display_units: Vec, - pub name_value: String, - pub name_error: &'static str, - pub display_unit_value: String, - pub display_unit_error: &'static str, - pub reorder_point_value: String, - pub reorder_point_error: &'static str, - pub pims_id_value: String, - pub pims_id_error: &'static str, - pub vetcove_id_value: String, - pub vetcove_id_error: &'static str, - pub allow_fractional_units_value: bool, + pub name: StringField, + pub display_unit_value: StringField, + pub reorder_point: StringField, + pub pims_id: StringField, + pub vetcove_id: StringField, + pub allow_fractional_units: BoolField, } -impl CreateItemFormTemplate { - pub fn clear_inputs(&mut self) { - self.name_value.clear(); - self.display_unit_value.clear(); - self.reorder_point_value.clear(); - self.pims_id_value.clear(); - self.vetcove_id_value.clear(); - self.allow_fractional_units_value = false; +#[async_trait] +impl BaseResponse for CreateItemFormTemplate { + type ResponseType = Self; + + async fn base_response(state: &AppState) -> Result { + let db = &state.db; + let display_units = db::display_unit::get_display_units(&db).await?; + + Ok(Self { + display_units, + ..Default::default() + }) } } + #[derive(Deserialize, Debug)] pub struct CreateItemFormData { name: String, @@ -54,122 +56,72 @@ pub struct CreateItemFormData { } #[async_trait] -impl HtmxFormData for CreateItemFormData { - type FormTemplate = CreateItemFormTemplate; - - async fn validate(self, state: &AppState) -> Result> { - let mut base = Self::base_template(&state).await?; - let display_units = &base.display_units; - - let name_error = if self.name.is_empty() { - "Please provide a name" - } else { - "" - }; - - let display_unit_error = if self.display_unit.is_empty() { - "Please provide a display unit" - } else if !display_units.iter().any(|x| x.abbreviation.eq(&self.display_unit)){ - "Invalid display unit" - } else { - "" - }; - - let reorder_point_error = if self.reorder_point.is_nan() - || self.reorder_point.is_infinite() - || self.reorder_point.is_sign_negative() - { - "Provide a positive number" - } else if !(self.allow_fractional_units || self.reorder_point.fract() == 0.0) { - "Fractional units not allowed" - } else { - "" - }; - - let pims_id_error = if let Some(pims_id) = &self.pims_id { - if pims_id.chars().any(char::is_whitespace) { - "Invalid PIMS id" - } - else { - "" - } - } - else { - "" - }; - - - let vetcove_id_error = if let Some(vetcove_id) = &self.vetcove_id { - if !vetcove_id.chars().all(|c| c.is_ascii_digit()) { - "Invalid Vectcove id" - } - else { - "" - } - } - else { - "" - }; - - if !(name_error.is_empty() - && display_unit_error.is_empty() - && reorder_point_error.is_empty() - && pims_id_error.is_empty() - && vetcove_id_error.is_empty()) { - - base.name_value = self.name; - base.name_error = name_error; - base.display_unit_value = self.display_unit; - base.display_unit_error = display_unit_error; - base.reorder_point_value = format!("{:.2}", self.reorder_point); - base.reorder_point_error = reorder_point_error; - base.pims_id_value = self.pims_id.as_deref().unwrap_or_default().to_string(); - base.pims_id_error = pims_id_error; - base.vetcove_id_value = self.vetcove_id.as_deref().unwrap_or_default().to_string(); - base.vetcove_id_error = vetcove_id_error; - base.allow_fractional_units_value = self.allow_fractional_units; - - return Err(HtmxFormDataError::ValidationError(base)); +impl ValidateForm for FormBase { + type ValidationErrorResponse = CreateItemFormTemplate; + + async fn validate(self, state: &AppState) -> Result> { + let display_units = db::display_unit::get_display_units(&state.db).await?; + + let name = StringField::new(self.form_data.name.clone()) + .invalid_if(|v| v.is_empty(), FieldError::Required); + + let display_unit_value = StringField::new(self.form_data.display_unit.clone()) + .invalid_if(|v| v.is_empty(), FieldError::Required) + .invalid_if(|v| !display_units.iter().any( + |x| x.abbreviation.eq(&self.form_data.display_unit)), + FieldError::SelectOption) + .clear_value_if_error(); + + let reorder_point = FloatField::new(self.form_data.reorder_point.clone()) + .invalid_if(|v| v.is_nan() || v.is_infinite() || v.is_sign_negative(), FieldError::PositiveNumber) + .invalid_if(|v| !(self.form_data.allow_fractional_units || v.fract() == 0.0), FieldError::WholeNumber) + .map(|v| format!("{:.2}", v)); + + let pims_id = StringField::new(self.form_data.pims_id.clone().unwrap_or_default()) + .invalid_if(|v| v.chars().any(char::is_whitespace), FieldError::ValidIdentifier); + + let vetcove_id = StringField::new(self.form_data.vetcove_id.clone().unwrap_or_default()) + .invalid_if(|v| !v.chars().all(|c| c.is_ascii_digit()), FieldError::ValidIdentifier); + + let allow_fractional_units = BoolField::new(self.form_data.allow_fractional_units); + + if name.is_error() || + display_unit_value.is_error() || + reorder_point.is_error() || + pims_id.is_error() || + vetcove_id.is_error() { + + return Err(ValidateFormError::ValidationError( + Self::ValidationErrorResponse { + display_units, + name, + display_unit_value, + reorder_point, + pims_id, + vetcove_id, + allow_fractional_units, + })); } Ok(self) } - - - async fn base_template(state: &AppState) -> anyhow::Result { - let db = &state.db; - let display_units = db::display_unit::get_display_units(&db).await?; - - Ok(Self::FormTemplate { - display_units, - name_value: "".to_owned(), - name_error: "", - display_unit_value: "".to_owned(), - display_unit_error: "", - reorder_point_value: "".to_owned(), - reorder_point_error: "", - pims_id_value: "".to_owned(), - pims_id_error: "", - vetcove_id_value: "".to_owned(), - vetcove_id_error: "", - allow_fractional_units_value: false, - }) - } } #[debug_handler] pub async fn create_item_form_post( State(state): State, user: SessionUser, - form_data: ValidatedForm, + form: ValidForm>, ) -> Result { + let form_data = &form.form_data; + let _new_id = db::inventory_item::add_inventory_item(&state.db, &form_data.name, form_data.reorder_point, form_data.allow_fractional_units, &form_data.display_unit, &form_data.pims_id, &form_data.vetcove_id, ).await?; - let fresh_form = CreateItemFormData::base_template(&state).await?; + let fresh_form = CreateItemFormTemplate::base_response(&state).await?; let events = vec![ HxEvent::from("form-submit-success"), @@ -186,5 +138,5 @@ pub async fn create_item_form_post( pub async fn create_item_form_get( State(state): State, ) -> Result { - Ok(CreateItemFormData::base_template(&state).await?.into_response()) + Ok(CreateItemFormTemplate::base_response(&state).await?.into_response()) } diff --git a/src/session.rs b/src/session.rs index 2ad9bdc..b577623 100644 --- a/src/session.rs +++ b/src/session.rs @@ -24,7 +24,7 @@ pub async fn init() -> Result<(SessionManagerLayer, JoinHandle Result>; - - async fn base_template(state: &AppState) -> anyhow::Result; -} - -pub enum HtmxFormDataError { - ValidationError(T), - InternalServerError(anyhow::Error), -} - -impl From for HtmxFormDataError { - fn from(err: anyhow::Error) -> Self { - HtmxFormDataError::InternalServerError(err) - } -} \ No newline at end of file diff --git a/src/util/extract/mod.rs b/src/util/extract/mod.rs deleted file mode 100644 index 233a514..0000000 --- a/src/util/extract/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod validated_form; -pub mod htmx_form_data; -pub mod form_helpers; \ No newline at end of file diff --git a/src/util/extract/validated_form.rs b/src/util/extract/validated_form.rs deleted file mode 100644 index 9bfafa5..0000000 --- a/src/util/extract/validated_form.rs +++ /dev/null @@ -1,82 +0,0 @@ -use std::ops::{Deref, DerefMut}; -use axum::extract::{FromRequest, FromRequestParts, Request}; -use axum::http::StatusCode; -use axum::{async_trait, RequestPartsExt}; -use axum::extract::rejection::FormRejection; -use axum::response::{IntoResponse, IntoResponseParts}; -use crate::app::routes::AppState; -use crate::error::AppError; -use super::htmx_form_data::{HtmxFormData, HtmxFormDataError}; - -#[derive(Debug, Clone, Copy, Default)] -pub struct ValidatedForm(pub T); - -#[async_trait] -impl FromRequest for ValidatedForm -where - T: HtmxFormData -{ - type Rejection = ValidatedFormError; - - async fn from_request(req: Request, state: &AppState) -> Result { - - let raw_form_data = axum::Form::::from_request(req, state) - .await - .map_err(|e| ValidatedFormError::FormError(e))?; - - let value = raw_form_data.0 - .validate(&state) - .await - .map_err(|e| - match e { - HtmxFormDataError::ValidationError(e) => ValidatedFormError::ValidationError(e), - HtmxFormDataError::InternalServerError(e) => ValidatedFormError::InternalServerError(e), - } - )?; - - Ok(ValidatedForm(value)) - } -} - -impl Deref for ValidatedForm { - type Target = T; - - #[inline] - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for ValidatedForm { - #[inline] - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - -pub enum ValidatedFormError { - ValidationError(T), - FormError(FormRejection), - InternalServerError(anyhow::Error) -} - - -impl IntoResponse for ValidatedFormError -where T: IntoResponse -{ - fn into_response(self) -> axum::response::Response { - match self { - ValidatedFormError::FormError(err) => { - err.into_response() - }, - ValidatedFormError::ValidationError(err) => { - let (mut parts, body) = err.into_response().into_parts(); - parts.status = StatusCode::OK; - (parts, body).into_response() - } - ValidatedFormError::InternalServerError(err) => { - AppError::from(err).into_response() - } - } - } -} \ No newline at end of file diff --git a/src/util/form/base_response.rs b/src/util/form/base_response.rs new file mode 100644 index 0000000..c70ff73 --- /dev/null +++ b/src/util/form/base_response.rs @@ -0,0 +1,17 @@ +use askama_axum::IntoResponse; +use axum::async_trait; +use crate::app::routes::AppState; +use crate::error::AppError; + +/** +This is sort of like "Default" but for responses. Some elements need to query the database +or some other part of the state in order to respond coherently. For example: a form that +requires a database query to populate a dropdown. +*/ + +#[async_trait] +pub trait BaseResponse { + type ResponseType: IntoResponse; + + async fn base_response(state: &AppState) -> Result; +} \ No newline at end of file diff --git a/src/util/form/extractors.rs b/src/util/form/extractors.rs new file mode 100644 index 0000000..b5c11ee --- /dev/null +++ b/src/util/form/extractors.rs @@ -0,0 +1,107 @@ +use axum::async_trait; +use axum::extract::{FromRequest, FromRequestParts, Request}; +use serde::de::DeserializeOwned; +use std::ops::{Deref, DerefMut}; +use crate::app::routes::AppState; +use crate::error::AppError; +use crate::util::form::validate_form::{ValidateForm, ValidateFormError}; + +/** +Essentially a wrapper around the Form extractor from axum. Needed in order to provide a common +language for Form handling (see more complex structures below) and standardizes the +error handling by using AppError for rejections. +*/ +pub struct FormBase { + pub form_data: F, +} + +#[async_trait] +impl FromRequest for FormBase +where + F: DeserializeOwned + Send, +{ + type Rejection = AppError; + + async fn from_request(req: Request, state: &AppState) -> Result { + + let form_data = axum::Form::::from_request(req, state) + .await?; + + Ok(Self { form_data: form_data.0 }) + } +} + +/** +A slightly more complex Form that contains data from the Path as well as the form body. This layer +of wrapping allows for validation in one spot as it can package up more of the information +needed to validate values. +*/ +pub struct FormWithPathVars { + pub path_data: P, + pub form_data: F, +} + +#[async_trait] +impl FromRequest for FormWithPathVars +where + P: DeserializeOwned + Send, + F: DeserializeOwned + Send, +{ + type Rejection = AppError; + + async fn from_request(req: Request, state: &AppState) -> Result { + + let (mut parts, body) = req.into_parts(); + + let path_data = axum::extract::Path::

::from_request_parts(&mut parts, state) + .await?; + + let req = Request::from_parts(parts, body); + + let form_data = axum::Form::::from_request(req, state) + .await?; + + Ok(Self { path_data: path_data.0, form_data: form_data.0 }) + } +} + +/** +An extractor that can take anything that implements ValidateForm and handle validation errors +before they get into the body of the handler. Regular application errors will respond with +500 errors, but validation errors will respond with 200 and contain the error info +*/ +#[derive(Debug, Clone, Copy, Default)] +pub struct ValidForm(pub T); + +#[async_trait] +impl FromRequest for ValidForm +where + T: ValidateForm, ValidateFormError<::ValidationErrorResponse>: From<>::Rejection> +{ + type Rejection = ValidateFormError; + + async fn from_request(req: Request, state: &AppState) -> Result { + + let raw_data = T::from_request(req, state).await?; + + let value = raw_data.validate(&state).await?; + + Ok(ValidForm(value)) + } +} + +impl Deref for ValidForm { + type Target = T; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for ValidForm { + #[inline] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} \ No newline at end of file diff --git a/src/util/form/field_error.rs b/src/util/form/field_error.rs new file mode 100644 index 0000000..9a0373c --- /dev/null +++ b/src/util/form/field_error.rs @@ -0,0 +1,36 @@ +use std::fmt::{Display, Formatter}; + +/** +A common type that can be used as a validation error for a field value in a form. +*/ +#[derive(Debug, Copy, Clone)] +pub enum FieldError { + Required, + ValidIdentifier, + PositiveNumber, + WholeNumber, + Currency, + SelectOption, + Custom(&'static str), +} + +impl Display for FieldError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + FieldError::Required => write!(f, "Requires a value"), + FieldError::ValidIdentifier => write!(f, "Invalid identifier"), + FieldError::PositiveNumber => write!(f, "Requires a positive number"), + FieldError::WholeNumber => write!(f, "Requires a whole number"), + FieldError::Currency => write!(f, "Requires a dollar amount"), + FieldError::SelectOption => write!(f, "Requires a valid selection"), + FieldError::Custom(s) => write!(f, "{}", s), + } + } +} + +impl From<&'static str> for FieldError { + fn from(s: &'static str) -> Self { + FieldError::Custom(s) + } +} + diff --git a/src/util/form/field_value.rs b/src/util/form/field_value.rs new file mode 100644 index 0000000..1aa0e17 --- /dev/null +++ b/src/util/form/field_value.rs @@ -0,0 +1,88 @@ +use std::fmt::Debug; +use crate::util::form::field_error::FieldError; + +/** +A sum type that contains a value and possibly an error. Used for form validation. +This isn't exactly like a sum type like Result as even in the error case, we want +to keep the value intact. It can't be just an either/or situation. For instance, +when doing form validation, in most cases, we would want to preserve the value +sent to us through the form, but inform the user of why it was rejected through an +error message. +*/ + +pub struct FieldValue { + pub value: Value, + pub error: Option, +} + +pub type StringField = FieldValue; +pub type BoolField = FieldValue; +pub type FloatField = FieldValue; + +impl FieldValue +where Value: Default +{ + pub fn new(value: Value) -> Self { + Self { value, error: None } + } + + pub fn set_error(&mut self, error: Error) { + self.error = Some(error); + } + + pub fn is_error(&self) -> bool { + self.error.is_some() + } + + pub fn invalid_if(mut self, is_invalid: I, err: Error ) -> Self + where I: FnOnce(&Value) -> bool { + if is_invalid(&self.value) { + self.set_error(err); + } + self + } + + pub fn invalid_if_then(mut self, is_invalid: I, err_if: E ) -> Self + where I: FnOnce(&Value) -> bool, E: FnOnce() -> Error { + if is_invalid(&self.value) { + self.set_error(err_if()); + } + self + } + + pub fn clear_value(mut self) -> Self { + self.value = Default::default(); + self + } + + pub fn clear_value_if_error(mut self) -> Self { + if self.is_error() { self.clear_value() } else { self } + } + + pub fn map(self, f: F) -> FieldValue + where F: FnOnce(Value) -> V { + FieldValue:: { + value: f(self.value), + error: self.error, + } + } +} + +impl Default for FieldValue +where Value: Default, +{ + fn default() -> Self { + Self::new(Value::default()) + } +} + +impl Debug for FieldValue +where Value: Debug, + Error: Debug { + fn fmt(&self, fmt: &mut ::std::fmt::Formatter) -> Result<(), ::std::fmt::Error> { + fmt.debug_struct("FieldValue") + .field("value", &self.value) + .field("error", &self.error) + .finish() + } +} \ No newline at end of file diff --git a/src/util/extract/form_helpers.rs b/src/util/form/helpers.rs similarity index 77% rename from src/util/extract/form_helpers.rs rename to src/util/form/helpers.rs index 177ab51..9b84cc3 100644 --- a/src/util/extract/form_helpers.rs +++ b/src/util/form/helpers.rs @@ -1,5 +1,8 @@ use serde::{Deserialize, Deserializer}; +/** +Deserialize a checkbox field from a html form into a bool +*/ pub fn deserialize_form_checkbox<'de, D>(deserializer: D) -> Result where D: Deserializer<'de> { let buf = String::deserialize(deserializer)?; diff --git a/src/util/form/mod.rs b/src/util/form/mod.rs new file mode 100644 index 0000000..a03f4cf --- /dev/null +++ b/src/util/form/mod.rs @@ -0,0 +1,6 @@ +pub mod field_error; +pub mod field_value; +pub mod validate_form; +pub mod extractors; +pub mod helpers; +pub mod base_response; \ No newline at end of file diff --git a/src/util/form/validate_form.rs b/src/util/form/validate_form.rs new file mode 100644 index 0000000..5359540 --- /dev/null +++ b/src/util/form/validate_form.rs @@ -0,0 +1,54 @@ +use axum::extract::FromRequest; +use axum::http::StatusCode; +use axum::{async_trait, RequestPartsExt}; +use axum::response::{IntoResponse, IntoResponseParts}; +use crate::app::routes::AppState; +use crate::error::AppError; + +/** +Validate a form. Allows forms to implement one validation function and be automatically hooked +up with extractors to reduce boilerplate in endpoints +*/ +#[async_trait] +pub trait ValidateForm: FromRequest { + type ValidationErrorResponse: IntoResponse; + + async fn validate(self, state: &AppState) -> Result>; +} + + +pub enum ValidateFormError { + ValidationError(T), + InternalServerError(AppError) +} + +impl IntoResponse for ValidateFormError +where T: IntoResponse +{ + fn into_response(self) -> axum::response::Response { + match self { + ValidateFormError::ValidationError(err) => { + let (mut parts, body) = err.into_response().into_parts(); + parts.status = StatusCode::OK; + (parts, body).into_response() + } + ValidateFormError::InternalServerError(err) => { + err.into_response() + } + } + } +} + +impl From for ValidateFormError { + fn from(err: anyhow::Error) -> Self { + ValidateFormError::InternalServerError(AppError::from(err)) + } +} + +impl From for ValidateFormError { + fn from(err: AppError) -> Self { + ValidateFormError::InternalServerError(err) + } +} + + diff --git a/src/util/mod.rs b/src/util/mod.rs index c9b336d..e6de68f 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -2,4 +2,4 @@ pub mod time; pub mod currency; pub mod parsing; pub mod formatting; -pub mod extract; \ No newline at end of file +pub mod form; \ No newline at end of file diff --git a/templates/item/adjustment/positive-adjustment-form.html b/templates/item/adjustment/positive-adjustment-form.html index 10ea247..6df02f7 100644 --- a/templates/item/adjustment/positive-adjustment-form.html +++ b/templates/item/adjustment/positive-adjustment-form.html @@ -13,17 +13,18 @@ step="0.01" placeholder="Amount" aria-label="amount" + value="{{ amount.value }}" required - {% if !amount_error.is_empty() -%} + {% if amount.is_error() -%} aria-invalid="true" aria-describedby="invalid-amount" {% endif -%} /> - {% if !amount_error.is_empty() -%} + {% if amount.is_error() -%} - {{ amount_error }} + {{ amount.error.unwrap() }} {% endif -%} @@ -36,17 +37,18 @@ class="block max-w-56 rounded-lg border border-neutral-900 p-2.5 text-sm text-neutral-900 focus:border-paynes-gray focus:ring-paynes-gray dark:text-slate-100" placeholder="Price" aria-label="price" + value="{{ price.value }}" required - {% if !price_error.is_empty() -%} + {% if price.is_error() -%} aria-invalid="true" aria-describedby="invalid-price" {% endif -%} /> - {% if !price_error.is_empty() -%} + {% if price.is_error() -%} - {{ price_error }} + {{ price.error.unwrap() }} {% endif -%} diff --git a/templates/item/item-create-form.html b/templates/item/item-create-form.html index c303436..6f574d8 100644 --- a/templates/item/item-create-form.html +++ b/templates/item/item-create-form.html @@ -15,16 +15,16 @@ name="name" class="block w-11/12 p-2 rounded-lg border border-neutral-900 text-sm text-neutral-900 focus:border-paynes-gray focus:ring-paynes-gray dark:text-slate-100" aria-label="name" - value="{{ name_value }}" + value="{{ name.value }}" required - {% if !name_error.is_empty() -%} + {% if name.is_error() -%} aria-invalid="true" aria-describedby="invalid-name" {% endif -%} /> - {% if !name_error.is_empty() -%} + {% if name.is_error() -%} - {{ name_error }} + {{ name.error.unwrap() }} {% endif -%} @@ -38,15 +38,15 @@ step="0.01" class="block w-3/4 p-2 rounded-lg border border-neutral-900 text-sm text-neutral-900 focus:border-paynes-gray focus:ring-paynes-gray dark:text-slate-100" aria-label="reorder point" - value="{{ reorder_point_value }}" - {% if !reorder_point_error.is_empty() -%} + value="{{ reorder_point.value }}" + {% if reorder_point.is_error() -%} aria-invalid="true" aria-describedby="invalid-reorder-point" {% endif -%} /> - {% if !reorder_point_error.is_empty() -%} + {% if reorder_point.is_error() -%} - {{ pims_id_error }} + {{ reorder_point.error.unwrap() }} {% endif -%} @@ -59,18 +59,18 @@ class="block rounded-lg border border-neutral-900 p-2 text-sm text-neutral-900 focus:border-paynes-gray focus:ring-paynes-gray dark:text-slate-100" aria-label="name" required - {% if !display_unit_error.is_empty() -%} + {% if display_unit_value.is_error() -%} aria-invalid="true" aria-describedby="invalid-display-unit" {% endif -%} > {% for unit in display_units -%} - + {% endfor %} - {% if !display_unit_error.is_empty() -%} + {% if display_unit_value.is_error() -%} - {{ display_unit_error }} + {{ display_unit_value.error.unwrap() }} {% endif -%} @@ -82,7 +82,7 @@ id="allow_fractional_units" name="allow_fractional_units" class="block w-4 h-4 text-dark-cyan border-slate-100 rounded-sm focus:ring-space-cadet focus:ring-2" - {% if allow_fractional_units_value -%} checked {% endif -%} + {% if allow_fractional_units.value -%} checked {% endif -%} /> @@ -94,16 +94,16 @@ id="pims_id" name="pims_id" class="block p-2 rounded-lg border border-neutral-900 text-sm text-neutral-900 focus:border-paynes-gray focus:ring-paynes-gray dark:text-slate-100" - value="{{ pims_id_value }}" + value="{{ pims_id.value }}" aria-label="pims id" - {% if !pims_id_error.is_empty() -%} + {% if pims_id.is_error() -%} aria-invalid="true" aria-describedby="invalid-pims-id" {% endif -%} /> - {% if !pims_id_error.is_empty() -%} + {% if pims_id.is_error() -%} - {{ pims_id_error }} + {{ pims_id.error.unwrap() }} {% endif -%} @@ -115,16 +115,16 @@ id="vetcove_id" name="vetcove_id" class="block p-2 rounded-lg border border-neutral-900 text-sm text-neutral-900 focus:border-paynes-gray focus:ring-paynes-gray dark:text-slate-100" - value="{{ vetcove_id_value }}" + value="{{ vetcove_id.value }}" aria-label="vetcove id" - {% if !vetcove_id_error.is_empty() -%} + {% if vetcove_id.is_error() -%} aria-invalid="true" aria-describedby="invalid-vetcove-id" {% endif -%} /> - {% if !vetcove_id_error.is_empty() -%} + {% if vetcove_id.is_error() -%} - {{ vetcove_id_error }} + {{ vetcove_id.error.unwrap() }} {% endif -%}