From c4b1d95cf70862e31184acbcdbf49e86116e1a0b Mon Sep 17 00:00:00 2001 From: Wes Holland Date: Wed, 12 Feb 2025 16:11:07 -0600 Subject: [PATCH] Improve form validation and toasts --- src/app/item/create.rs | 118 ++++++++++++--------------- src/util/extract/form_helpers.rs | 3 + src/util/extract/htmx_form_data.rs | 26 ++++++ src/util/extract/mod.rs | 3 + src/util/extract/validated_form.rs | 82 +++++++++++++++++++ src/util/mod.rs | 3 +- templates/catalog.html | 4 +- templates/item/item-create-form.html | 5 +- templates/main.html | 55 +++++++++++++ 9 files changed, 231 insertions(+), 68 deletions(-) create mode 100644 src/util/extract/form_helpers.rs create mode 100644 src/util/extract/htmx_form_data.rs create mode 100644 src/util/extract/mod.rs create mode 100644 src/util/extract/validated_form.rs diff --git a/src/app/item/create.rs b/src/app/item/create.rs index a93cc44..4c56f57 100644 --- a/src/app/item/create.rs +++ b/src/app/item/create.rs @@ -2,14 +2,15 @@ use crate::error::AppError; use crate::session::SessionUser; use askama::Template; use askama_axum::{IntoResponse, Response}; -use axum::extract::{Path, State}; -use axum::{debug_handler, Form}; -use axum_htmx::{HxEvent, HxResponseTrigger}; +use axum::extract::State; +use axum::{async_trait, debug_handler, Form}; use serde::Deserialize; -use sqlx::SqlitePool; -use tracing::info; +use crate::app::routes::AppState; use crate::db::display_unit::DbDisplayUnit; use crate::db; +use crate::util::extract::form_helpers::form_checkbox_is_checked; +use crate::util::extract::htmx_form_data::{HtmxFormData, HtmxFormDataError}; +use crate::util::extract::validated_form::ValidatedForm; #[derive(Template, Debug)] #[template(path = "item/item-create-form.html")] @@ -49,29 +50,14 @@ pub struct CreateItemFormData { vetcove_id: Option, } -pub fn form_checkbox_is_checked(val: &Option) -> bool { - val.as_ref().map(|val| val == "on").unwrap_or(false) -} +#[async_trait] +impl HtmxFormData for CreateItemFormData { + type FormTemplate = CreateItemFormTemplate; -impl CreateItemFormData { - pub fn base_template() -> CreateItemFormTemplate { - CreateItemFormTemplate { - display_units: vec![], - 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, - } - } + async fn validate(self, state: &AppState) -> Result> { + let mut base = Self::base_template(&state).await?; + let display_units = &base.display_units; - pub fn validate(&self, display_units: Vec) -> Result { let allow_fractional_units = form_checkbox_is_checked(&self.allow_fractional_units); let name_error = if self.name.is_empty() { @@ -124,68 +110,72 @@ impl CreateItemFormData { "" }; - let template = CreateItemFormTemplate { - display_units, - name_value: self.name.clone(), - name_error, - display_unit_value: self.display_unit.clone(), - display_unit_error, - reorder_point_value: format!("{:.2}", self.reorder_point), - reorder_point_error, - pims_id_value: self.pims_id.as_deref().unwrap_or_default().to_string(), - pims_id_error, - vetcove_id_value: self.vetcove_id.as_deref().unwrap_or_default().to_string(), - vetcove_id_error, - allow_fractional_units_value: allow_fractional_units, - }; - 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()) { - return Err(template); + + 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 = allow_fractional_units; + + return Err(HtmxFormDataError::ValidationError(base)); } - Ok(template) + 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(db): State, + State(state): State, user: SessionUser, - mut form_data: Form, + form_data: ValidatedForm, ) -> Result { - let display_units = db::display_unit::get_display_units(&db).await?; - - let validation = form_data.validate(display_units); - - if let Err(resp) = validation { - return Ok(resp.into_response()); - } - let allow_fractional_units = form_checkbox_is_checked(&form_data.allow_fractional_units); - let _new_id = db::inventory_item::add_inventory_item(&db, &form_data.name, form_data.reorder_point, + let _new_id = db::inventory_item::add_inventory_item(&state.db, &form_data.name, form_data.reorder_point, allow_fractional_units, &form_data.display_unit, &form_data.pims_id, &form_data.vetcove_id, ).await?; - let mut template = validation.unwrap(); - template.clear_inputs(); - - Ok(template.into_response()) + Ok(CreateItemFormData::base_template(&state).await?.into_response()) } #[debug_handler] pub async fn create_item_form_get( - State(db): State, + State(state): State, ) -> Result { - - let mut base = CreateItemFormData::base_template(); - base.display_units = db::display_unit::get_display_units(&db).await?; - - Ok(base.into_response()) + Ok(CreateItemFormData::base_template(&state).await?.into_response()) } diff --git a/src/util/extract/form_helpers.rs b/src/util/extract/form_helpers.rs new file mode 100644 index 0000000..e1a2f18 --- /dev/null +++ b/src/util/extract/form_helpers.rs @@ -0,0 +1,3 @@ +pub fn form_checkbox_is_checked(val: &Option) -> bool { + val.as_ref().map(|val| val == "on").unwrap_or(false) +} \ No newline at end of file diff --git a/src/util/extract/htmx_form_data.rs b/src/util/extract/htmx_form_data.rs new file mode 100644 index 0000000..b6e9fad --- /dev/null +++ b/src/util/extract/htmx_form_data.rs @@ -0,0 +1,26 @@ +use axum::async_trait; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde::de::DeserializeOwned; +use crate::app::routes::AppState; +use crate::error::AppError; + +#[async_trait] +pub trait HtmxFormData : DeserializeOwned + Send { + type FormTemplate: IntoResponse; + + async fn validate(self, state: &AppState) -> 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 new file mode 100644 index 0000000..233a514 --- /dev/null +++ b/src/util/extract/mod.rs @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..9bfafa5 --- /dev/null +++ b/src/util/extract/validated_form.rs @@ -0,0 +1,82 @@ +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/mod.rs b/src/util/mod.rs index 59965db..c9b336d 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,4 +1,5 @@ pub mod time; pub mod currency; pub mod parsing; -pub mod formatting; \ No newline at end of file +pub mod formatting; +pub mod extract; \ No newline at end of file diff --git a/templates/catalog.html b/templates/catalog.html index d8f86f5..1908394 100644 --- a/templates/catalog.html +++ b/templates/catalog.html @@ -5,9 +5,9 @@
-
+
-
+

Add Item

diff --git a/templates/item/item-create-form.html b/templates/item/item-create-form.html index dfe3081..0d5da99 100644 --- a/templates/item/item-create-form.html +++ b/templates/item/item-create-form.html @@ -1,7 +1,10 @@
+ hx-swap="outerHTML" + x-on:htmx:response-error="$dispatch('notice', {type: 'error', text: 'Unknown error!'})" +>
diff --git a/templates/main.html b/templates/main.html index 509ebcf..f25f467 100644 --- a/templates/main.html +++ b/templates/main.html @@ -65,9 +65,64 @@
+ + +
+ +
+ {% block content %}

Content Missing

{% endblock %} +