More egonomic form handling

main
Wes Holland 9 months ago
parent bef1ebdae7
commit 453d36dd86

@ -4,7 +4,6 @@ use crate::db::inventory_item::does_inventory_item_allow_fractional_units;
use crate::error::AppError; use crate::error::AppError;
use crate::session::SessionUser; use crate::session::SessionUser;
use crate::util::currency::dollars_string_to_int_cents; use crate::util::currency::dollars_string_to_int_cents;
use crate::util::extract::htmx_form_data::{HtmxFormData, HtmxFormDataError};
use askama::Template; use askama::Template;
use askama_axum::{IntoResponse, Response}; use askama_axum::{IntoResponse, Response};
use axum::extract::{Path, State}; use axum::extract::{Path, State};
@ -14,13 +13,17 @@ use serde::Deserialize;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use tracing::info; use tracing::info;
use crate::app::routes::AppState; 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")] #[template(path = "item/adjustment/positive-adjustment-form.html")]
pub struct PositiveAdjustmentFormTemplate { pub struct PositiveAdjustmentFormTemplate {
pub item_id: i64, pub item_id: i64,
pub amount_error: &'static str, pub amount: StringField,
pub price_error: &'static str, pub price: StringField,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
@ -29,41 +32,43 @@ pub struct PositiveAdjustmentFormData {
pub price: String, pub price: String,
} }
#[debug_handler] #[async_trait]
impl ValidateForm for FormWithPathVars<i64, PositiveAdjustmentFormData> {
type ValidationErrorResponse = PositiveAdjustmentFormTemplate;
async fn validate(self, state: &AppState) -> Result<Self, ValidateFormError<Self::ValidationErrorResponse>> {
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( pub async fn positive_adjustment_form_post(
State(db): State<SqlitePool>, State(db): State<SqlitePool>,
Path(id): Path<i64>,
user: SessionUser, user: SessionUser,
form_data: Form<PositiveAdjustmentFormData>, data: ValidForm<FormWithPathVars<i64,PositiveAdjustmentFormData>>
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
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 id = data.path_data;
let unit_price = (price as f64 / form_data.amount) as i64; 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![ let trigger_events = vec![
HxEvent::from("form-submit-success"), HxEvent::from("form-submit-success"),
@ -72,11 +77,11 @@ pub async fn positive_adjustment_form_post(
let timestamp = chrono::Utc::now(); let timestamp = chrono::Utc::now();
let adjustment_amount = form_data.amount; let adjustment_amount = data.form_data.amount;
info!( info!(
"Add adjustment form: {:?} for user {}", "Add adjustment form: {:?} for user {}",
form_data, user.name data.form_data, user.name
); );
let _new_id = add_adjustment( let _new_id = add_adjustment(
@ -95,19 +100,17 @@ pub async fn positive_adjustment_form_post(
HxResponseTrigger::normal(trigger_events), HxResponseTrigger::normal(trigger_events),
PositiveAdjustmentFormTemplate { PositiveAdjustmentFormTemplate {
item_id: id, item_id: id,
amount_error: "", ..Default::default()
price_error: "",
} }
.into_response(), .into_response(),
) )
.into_response()) .into_response())
} }
pub async fn positive_adjustment_form_get(Path(id): Path<i64>) -> Result<Response, AppError> { pub async fn positive_adjustment_form_get(Path(id): Path<i64>) -> Result<Response, AppError> {
Ok(PositiveAdjustmentFormTemplate { Ok(PositiveAdjustmentFormTemplate {
item_id: id, item_id: id,
amount_error: "", ..Default::default()
price_error: "",
} }
.into_response()) .into_response())
} }

@ -3,45 +3,47 @@ use crate::session::SessionUser;
use askama::Template; use askama::Template;
use askama_axum::{IntoResponse, Response}; use askama_axum::{IntoResponse, Response};
use axum::extract::State; use axum::extract::State;
use axum::{async_trait, debug_handler, Form}; use axum::{async_trait, debug_handler};
use axum_htmx::{HxEvent, HxResponseTrigger}; use axum_htmx::{HxEvent, HxResponseTrigger};
use serde::Deserialize; use serde::Deserialize;
use tracing::info;
use crate::app::routes::AppState; use crate::app::routes::AppState;
use crate::db::display_unit::DbDisplayUnit; use crate::db::display_unit::DbDisplayUnit;
use crate::db; use crate::db;
use crate::util::extract::htmx_form_data::{HtmxFormData, HtmxFormDataError}; use crate::util::form::field_error::FieldError;
use crate::util::extract::validated_form::ValidatedForm; use crate::util::form::field_value::{BoolField, FloatField, StringField};
use crate::util::extract::form_helpers::deserialize_form_checkbox; use crate::util::form::validate_form::{ValidateForm, ValidateFormError};
use crate::util::form::helpers::deserialize_form_checkbox;
#[derive(Template, Debug)] 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")] #[template(path = "item/item-create-form.html")]
pub struct CreateItemFormTemplate { pub struct CreateItemFormTemplate {
pub display_units: Vec<DbDisplayUnit>, pub display_units: Vec<DbDisplayUnit>,
pub name_value: String, pub name: StringField,
pub name_error: &'static str, pub display_unit_value: StringField,
pub display_unit_value: String, pub reorder_point: StringField,
pub display_unit_error: &'static str, pub pims_id: StringField,
pub reorder_point_value: String, pub vetcove_id: StringField,
pub reorder_point_error: &'static str, pub allow_fractional_units: BoolField,
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,
} }
impl CreateItemFormTemplate { #[async_trait]
pub fn clear_inputs(&mut self) { impl BaseResponse for CreateItemFormTemplate {
self.name_value.clear(); type ResponseType = Self;
self.display_unit_value.clear();
self.reorder_point_value.clear(); async fn base_response(state: &AppState) -> Result<Self::ResponseType, AppError> {
self.pims_id_value.clear(); let db = &state.db;
self.vetcove_id_value.clear(); let display_units = db::display_unit::get_display_units(&db).await?;
self.allow_fractional_units_value = false;
Ok(Self {
display_units,
..Default::default()
})
} }
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct CreateItemFormData { pub struct CreateItemFormData {
name: String, name: String,
@ -54,122 +56,72 @@ pub struct CreateItemFormData {
} }
#[async_trait] #[async_trait]
impl HtmxFormData for CreateItemFormData { impl ValidateForm for FormBase<CreateItemFormData> {
type FormTemplate = CreateItemFormTemplate; type ValidationErrorResponse = CreateItemFormTemplate;
async fn validate(self, state: &AppState) -> Result<Self, HtmxFormDataError<Self::FormTemplate>> { async fn validate(self, state: &AppState) -> Result<Self, ValidateFormError<Self::ValidationErrorResponse>> {
let mut base = Self::base_template(&state).await?; let display_units = db::display_unit::get_display_units(&state.db).await?;
let display_units = &base.display_units;
let name = StringField::new(self.form_data.name.clone())
let name_error = if self.name.is_empty() { .invalid_if(|v| v.is_empty(), FieldError::Required);
"Please provide a name"
} else { 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)),
let display_unit_error = if self.display_unit.is_empty() { FieldError::SelectOption)
"Please provide a display unit" .clear_value_if_error();
} else if !display_units.iter().any(|x| x.abbreviation.eq(&self.display_unit)){
"Invalid display unit" let reorder_point = FloatField::new(self.form_data.reorder_point.clone())
} else { .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 reorder_point_error = if self.reorder_point.is_nan() let pims_id = StringField::new(self.form_data.pims_id.clone().unwrap_or_default())
|| self.reorder_point.is_infinite() .invalid_if(|v| v.chars().any(char::is_whitespace), FieldError::ValidIdentifier);
|| self.reorder_point.is_sign_negative()
{ let vetcove_id = StringField::new(self.form_data.vetcove_id.clone().unwrap_or_default())
"Provide a positive number" .invalid_if(|v| !v.chars().all(|c| c.is_ascii_digit()), FieldError::ValidIdentifier);
} else if !(self.allow_fractional_units || self.reorder_point.fract() == 0.0) {
"Fractional units not allowed" let allow_fractional_units = BoolField::new(self.form_data.allow_fractional_units);
} else {
"" if name.is_error() ||
}; display_unit_value.is_error() ||
reorder_point.is_error() ||
let pims_id_error = if let Some(pims_id) = &self.pims_id { pims_id.is_error() ||
if pims_id.chars().any(char::is_whitespace) { vetcove_id.is_error() {
"Invalid PIMS id"
} return Err(ValidateFormError::ValidationError(
else { Self::ValidationErrorResponse {
"" display_units,
} name,
} display_unit_value,
else { reorder_point,
"" pims_id,
}; vetcove_id,
allow_fractional_units,
}));
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));
} }
Ok(self) Ok(self)
} }
async fn base_template(state: &AppState) -> anyhow::Result<Self::FormTemplate> {
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] #[debug_handler]
pub async fn create_item_form_post( pub async fn create_item_form_post(
State(state): State<AppState>, State(state): State<AppState>,
user: SessionUser, user: SessionUser,
form_data: ValidatedForm<CreateItemFormData>, form: ValidForm<FormBase<CreateItemFormData>>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let form_data = &form.form_data;
let _new_id = db::inventory_item::add_inventory_item(&state.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,
form_data.allow_fractional_units, &form_data.display_unit, form_data.allow_fractional_units, &form_data.display_unit,
&form_data.pims_id, &form_data.vetcove_id, &form_data.pims_id, &form_data.vetcove_id,
).await?; ).await?;
let fresh_form = CreateItemFormData::base_template(&state).await?; let fresh_form = CreateItemFormTemplate::base_response(&state).await?;
let events = vec![ let events = vec![
HxEvent::from("form-submit-success"), HxEvent::from("form-submit-success"),
@ -186,5 +138,5 @@ pub async fn create_item_form_post(
pub async fn create_item_form_get( pub async fn create_item_form_get(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
Ok(CreateItemFormData::base_template(&state).await?.into_response()) Ok(CreateItemFormTemplate::base_response(&state).await?.into_response())
} }

@ -24,7 +24,7 @@ pub async fn init() -> Result<(SessionManagerLayer<SqliteStore>, JoinHandle<Resu
let session_store = SqliteStore::new(session_db); let session_store = SqliteStore::new(session_db);
session_store.migrate().await?; session_store.migrate().await?;
// This guy forms the session cookies // This guy form the session cookies
// The session manager layer is the glue between the session store // The session manager layer is the glue between the session store
// and the handlers. The options basically define the options of // and the handlers. The options basically define the options of
// the cookies given to the client // the cookies given to the client

@ -1,26 +0,0 @@
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<Self, HtmxFormDataError<Self::FormTemplate>>;
async fn base_template(state: &AppState) -> anyhow::Result<Self::FormTemplate>;
}
pub enum HtmxFormDataError<T> {
ValidationError(T),
InternalServerError(anyhow::Error),
}
impl<T> From<anyhow::Error> for HtmxFormDataError<T> {
fn from(err: anyhow::Error) -> Self {
HtmxFormDataError::InternalServerError(err)
}
}

@ -1,3 +0,0 @@
pub mod validated_form;
pub mod htmx_form_data;
pub mod form_helpers;

@ -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<T>(pub T);
#[async_trait]
impl<T> FromRequest<AppState> for ValidatedForm<T>
where
T: HtmxFormData
{
type Rejection = ValidatedFormError<T::FormTemplate>;
async fn from_request(req: Request, state: &AppState) -> Result<Self, Self::Rejection> {
let raw_form_data = axum::Form::<T>::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<T> Deref for ValidatedForm<T> {
type Target = T;
#[inline]
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> DerefMut for ValidatedForm<T> {
#[inline]
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
pub enum ValidatedFormError<T> {
ValidationError(T),
FormError(FormRejection),
InternalServerError(anyhow::Error)
}
impl<T> IntoResponse for ValidatedFormError<T>
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()
}
}
}
}

@ -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<Self::ResponseType, AppError>;
}

@ -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<F> {
pub form_data: F,
}
#[async_trait]
impl<F> FromRequest<AppState> for FormBase<F>
where
F: DeserializeOwned + Send,
{
type Rejection = AppError;
async fn from_request(req: Request, state: &AppState) -> Result<Self, Self::Rejection> {
let form_data = axum::Form::<F>::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<P, F> {
pub path_data: P,
pub form_data: F,
}
#[async_trait]
impl<P, F> FromRequest<AppState> for FormWithPathVars<P, F>
where
P: DeserializeOwned + Send,
F: DeserializeOwned + Send,
{
type Rejection = AppError;
async fn from_request(req: Request, state: &AppState) -> Result<Self, Self::Rejection> {
let (mut parts, body) = req.into_parts();
let path_data = axum::extract::Path::<P>::from_request_parts(&mut parts, state)
.await?;
let req = Request::from_parts(parts, body);
let form_data = axum::Form::<F>::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<T>(pub T);
#[async_trait]
impl<T> FromRequest<AppState> for ValidForm<T>
where
T: ValidateForm, ValidateFormError<<T as ValidateForm>::ValidationErrorResponse>: From<<T as FromRequest<AppState>>::Rejection>
{
type Rejection = ValidateFormError<T::ValidationErrorResponse>;
async fn from_request(req: Request, state: &AppState) -> Result<Self, Self::Rejection> {
let raw_data = T::from_request(req, state).await?;
let value = raw_data.validate(&state).await?;
Ok(ValidForm(value))
}
}
impl<T> Deref for ValidForm<T> {
type Target = T;
#[inline]
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> DerefMut for ValidForm<T> {
#[inline]
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}

@ -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)
}
}

@ -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<Value, Error> {
pub value: Value,
pub error: Option<Error>,
}
pub type StringField = FieldValue<String, FieldError>;
pub type BoolField = FieldValue<bool, FieldError>;
pub type FloatField = FieldValue<f64, FieldError>;
impl<Value, Error> FieldValue<Value, Error>
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<I>(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<I, E>(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<F, V>(self, f: F) -> FieldValue<V, Error>
where F: FnOnce(Value) -> V {
FieldValue::<V, Error> {
value: f(self.value),
error: self.error,
}
}
}
impl<Value, Error> Default for FieldValue<Value, Error>
where Value: Default,
{
fn default() -> Self {
Self::new(Value::default())
}
}
impl<Value, Error> Debug for FieldValue<Value, Error>
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()
}
}

@ -1,5 +1,8 @@
use serde::{Deserialize, Deserializer}; 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<bool, D::Error> pub fn deserialize_form_checkbox<'de, D>(deserializer: D) -> Result<bool, D::Error>
where D: Deserializer<'de> { where D: Deserializer<'de> {
let buf = String::deserialize(deserializer)?; let buf = String::deserialize(deserializer)?;

@ -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;

@ -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<AppState> {
type ValidationErrorResponse: IntoResponse;
async fn validate(self, state: &AppState) -> Result<Self, ValidateFormError<Self::ValidationErrorResponse>>;
}
pub enum ValidateFormError<T> {
ValidationError(T),
InternalServerError(AppError)
}
impl<T> IntoResponse for ValidateFormError<T>
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<T> From<anyhow::Error> for ValidateFormError<T> {
fn from(err: anyhow::Error) -> Self {
ValidateFormError::InternalServerError(AppError::from(err))
}
}
impl<T> From<AppError> for ValidateFormError<T> {
fn from(err: AppError) -> Self {
ValidateFormError::InternalServerError(err)
}
}

@ -2,4 +2,4 @@ pub mod time;
pub mod currency; pub mod currency;
pub mod parsing; pub mod parsing;
pub mod formatting; pub mod formatting;
pub mod extract; pub mod form;

@ -13,17 +13,18 @@
step="0.01" step="0.01"
placeholder="Amount" placeholder="Amount"
aria-label="amount" aria-label="amount"
value="{{ amount.value }}"
required required
{% if !amount_error.is_empty() -%} {% if amount.is_error() -%}
aria-invalid="true" aria-invalid="true"
aria-describedby="invalid-amount" aria-describedby="invalid-amount"
{% endif -%} {% endif -%}
/> />
{% if !amount_error.is_empty() -%} {% if amount.is_error() -%}
<small id="invalid-amount" <small id="invalid-amount"
class="mt-2 text-sm text-cerise" class="mt-2 text-sm text-cerise"
> >
{{ amount_error }} {{ amount.error.unwrap() }}
</small> </small>
{% endif -%} {% endif -%}
</div> </div>
@ -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" 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" placeholder="Price"
aria-label="price" aria-label="price"
value="{{ price.value }}"
required required
{% if !price_error.is_empty() -%} {% if price.is_error() -%}
aria-invalid="true" aria-invalid="true"
aria-describedby="invalid-price" aria-describedby="invalid-price"
{% endif -%} {% endif -%}
/> />
{% if !price_error.is_empty() -%} {% if price.is_error() -%}
<small id="invalid-price" <small id="invalid-price"
class="mt-2 text-sm text-cerise" class="mt-2 text-sm text-cerise"
> >
{{ price_error }} {{ price.error.unwrap() }}
</small> </small>
{% endif -%} {% endif -%}
</div> </div>

@ -15,16 +15,16 @@
name="name" 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" 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" aria-label="name"
value="{{ name_value }}" value="{{ name.value }}"
required required
{% if !name_error.is_empty() -%} {% if name.is_error() -%}
aria-invalid="true" aria-invalid="true"
aria-describedby="invalid-name" aria-describedby="invalid-name"
{% endif -%} {% endif -%}
/> />
{% if !name_error.is_empty() -%} {% if name.is_error() -%}
<small id="invalid-name" class="block mt-2 text-sm text-cerise"> <small id="invalid-name" class="block mt-2 text-sm text-cerise">
{{ name_error }} {{ name.error.unwrap() }}
</small> </small>
{% endif -%} {% endif -%}
</div> </div>
@ -38,15 +38,15 @@
step="0.01" 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" 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" aria-label="reorder point"
value="{{ reorder_point_value }}" value="{{ reorder_point.value }}"
{% if !reorder_point_error.is_empty() -%} {% if reorder_point.is_error() -%}
aria-invalid="true" aria-invalid="true"
aria-describedby="invalid-reorder-point" aria-describedby="invalid-reorder-point"
{% endif -%} {% endif -%}
/> />
{% if !reorder_point_error.is_empty() -%} {% if reorder_point.is_error() -%}
<small id="invalid-reorder-point" class="block mt-2 text-sm text-cerise"> <small id="invalid-reorder-point" class="block mt-2 text-sm text-cerise">
{{ pims_id_error }} {{ reorder_point.error.unwrap() }}
</small> </small>
{% endif -%} {% endif -%}
</div> </div>
@ -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" 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" aria-label="name"
required required
{% if !display_unit_error.is_empty() -%} {% if display_unit_value.is_error() -%}
aria-invalid="true" aria-invalid="true"
aria-describedby="invalid-display-unit" aria-describedby="invalid-display-unit"
{% endif -%} {% endif -%}
> >
{% for unit in display_units -%} {% for unit in display_units -%}
<option value="{{ unit.abbreviation }}" {% if unit.abbreviation == display_unit_value %} selected {% endif %}>{{ unit.unit }}</option> <option value="{{ unit.abbreviation }}" {% if unit.abbreviation == display_unit_value.value %} selected {% endif %}>{{ unit.unit }}</option>
{% endfor %} {% endfor %}
</select> </select>
{% if !display_unit_error.is_empty() -%} {% if display_unit_value.is_error() -%}
<small id="invalid-display-unit" class="block mt-2 text-sm text-cerise"> <small id="invalid-display-unit" class="block mt-2 text-sm text-cerise">
{{ display_unit_error }} {{ display_unit_value.error.unwrap() }}
</small> </small>
{% endif -%} {% endif -%}
</div> </div>
@ -82,7 +82,7 @@
id="allow_fractional_units" id="allow_fractional_units"
name="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" 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 -%}
/> />
</div> </div>
@ -94,16 +94,16 @@
id="pims_id" id="pims_id"
name="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" 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" aria-label="pims id"
{% if !pims_id_error.is_empty() -%} {% if pims_id.is_error() -%}
aria-invalid="true" aria-invalid="true"
aria-describedby="invalid-pims-id" aria-describedby="invalid-pims-id"
{% endif -%} {% endif -%}
/> />
{% if !pims_id_error.is_empty() -%} {% if pims_id.is_error() -%}
<small id="invalid-pims-id" class="block mt-2 text-sm text-cerise"> <small id="invalid-pims-id" class="block mt-2 text-sm text-cerise">
{{ pims_id_error }} {{ pims_id.error.unwrap() }}
</small> </small>
{% endif -%} {% endif -%}
</div> </div>
@ -115,16 +115,16 @@
id="vetcove_id" id="vetcove_id"
name="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" 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" aria-label="vetcove id"
{% if !vetcove_id_error.is_empty() -%} {% if vetcove_id.is_error() -%}
aria-invalid="true" aria-invalid="true"
aria-describedby="invalid-vetcove-id" aria-describedby="invalid-vetcove-id"
{% endif -%} {% endif -%}
/> />
{% if !vetcove_id_error.is_empty() -%} {% if vetcove_id.is_error() -%}
<small id="invalid-vetcove-id" class="block mt-2 text-sm text-cerise"> <small id="invalid-vetcove-id" class="block mt-2 text-sm text-cerise">
{{ vetcove_id_error }} {{ vetcove_id.error.unwrap() }}
</small> </small>
{% endif -%} {% endif -%}
</div> </div>

Loading…
Cancel
Save

Powered by TurnKey Linux.