Improve form validation and toasts

demo-mode
Wes Holland 10 months ago
parent 93abc216e1
commit c4b1d95cf7

@ -2,14 +2,15 @@ use crate::error::AppError;
use crate::session::SessionUser; use crate::session::SessionUser;
use askama::Template; use askama::Template;
use askama_axum::{IntoResponse, Response}; use askama_axum::{IntoResponse, Response};
use axum::extract::{Path, State}; use axum::extract::State;
use axum::{debug_handler, Form}; use axum::{async_trait, debug_handler, Form};
use axum_htmx::{HxEvent, HxResponseTrigger};
use serde::Deserialize; use serde::Deserialize;
use sqlx::SqlitePool; use crate::app::routes::AppState;
use tracing::info;
use crate::db::display_unit::DbDisplayUnit; use crate::db::display_unit::DbDisplayUnit;
use crate::db; 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)] #[derive(Template, Debug)]
#[template(path = "item/item-create-form.html")] #[template(path = "item/item-create-form.html")]
@ -49,29 +50,14 @@ pub struct CreateItemFormData {
vetcove_id: Option<String>, vetcove_id: Option<String>,
} }
pub fn form_checkbox_is_checked(val: &Option<String>) -> bool { #[async_trait]
val.as_ref().map(|val| val == "on").unwrap_or(false) impl HtmxFormData for CreateItemFormData {
} type FormTemplate = CreateItemFormTemplate;
impl CreateItemFormData { async fn validate(self, state: &AppState) -> Result<Self, HtmxFormDataError<Self::FormTemplate>> {
pub fn base_template() -> CreateItemFormTemplate { let mut base = Self::base_template(&state).await?;
CreateItemFormTemplate { let display_units = &base.display_units;
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,
}
}
pub fn validate(&self, display_units: Vec<DbDisplayUnit>) -> Result<CreateItemFormTemplate, CreateItemFormTemplate> {
let allow_fractional_units = form_checkbox_is_checked(&self.allow_fractional_units); let allow_fractional_units = form_checkbox_is_checked(&self.allow_fractional_units);
let name_error = if self.name.is_empty() { 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() if !(name_error.is_empty()
&& display_unit_error.is_empty() && display_unit_error.is_empty()
&& reorder_point_error.is_empty() && reorder_point_error.is_empty()
&& pims_id_error.is_empty() && pims_id_error.is_empty()
&& vetcove_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<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(db): State<SqlitePool>, State(state): State<AppState>,
user: SessionUser, user: SessionUser,
mut form_data: Form<CreateItemFormData>, form_data: ValidatedForm<CreateItemFormData>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
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 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, 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 mut template = validation.unwrap(); Ok(CreateItemFormData::base_template(&state).await?.into_response())
template.clear_inputs();
Ok(template.into_response())
} }
#[debug_handler] #[debug_handler]
pub async fn create_item_form_get( pub async fn create_item_form_get(
State(db): State<SqlitePool>, State(state): State<AppState>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
Ok(CreateItemFormData::base_template(&state).await?.into_response())
let mut base = CreateItemFormData::base_template();
base.display_units = db::display_unit::get_display_units(&db).await?;
Ok(base.into_response())
} }

@ -0,0 +1,3 @@
pub fn form_checkbox_is_checked(val: &Option<String>) -> bool {
val.as_ref().map(|val| val == "on").unwrap_or(false)
}

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

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

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

@ -1,4 +1,5 @@
pub mod time; pub mod time;
pub mod currency; pub mod currency;
pub mod parsing; pub mod parsing;
pub mod formatting; pub mod formatting;
pub mod extract;

@ -5,9 +5,9 @@
<div class="relative h-auto" x-data="{ show_sidebar: false }" > <div class="relative h-auto" x-data="{ show_sidebar: false }" >
<div class="absolute w-screen h-full" x-show="show_sidebar"> <div class="absolute w-screen h-full" x-show="show_sidebar">
<div class="relative w-full h-full bg-neutral-300 backdrop-blur-md opacity-90 py-4 z-40"> <div class="relative w-full h-full bg-neutral-300 backdrop-blur-md opacity-90 py-4 z-10">
</div> </div>
<div class="py-2 px-4 absolute rounded-tl-xl inset-y-0 right-0 bg-slate-100 opacity-100 w-11/12 max-w-[40rem] z-50"> <div class="py-2 px-4 absolute rounded-tl-xl inset-y-0 right-0 bg-slate-100 opacity-100 w-11/12 max-w-[40rem] z-20">
<div class="flex"> <div class="flex">
<div class="flex-1 inline-flex items-center"> <div class="flex-1 inline-flex items-center">
<h2 class="text-lg font-semibold uppercase">Add Item</h2> <h2 class="text-lg font-semibold uppercase">Add Item</h2>

@ -1,7 +1,10 @@
<form <form
id="item-create"
hx-post="/item/create" hx-post="/item/create"
hx-target="this" hx-target="this"
hx-swap="outerHTML"> hx-swap="outerHTML"
x-on:htmx:response-error="$dispatch('notice', {type: 'error', text: 'Unknown error!'})"
>
<div class="mb-5 grid grid-cols-6 gap-4 p-2"> <div class="mb-5 grid grid-cols-6 gap-4 p-2">
<div class="col-span-6"> <div class="col-span-6">
<label for="name" class="mb-2 block text-sm font-medium">Name</label> <label for="name" class="mb-2 block text-sm font-medium">Name</label>

@ -65,9 +65,64 @@
</nav> </nav>
</header> </header>
<main class="bg-slate-100 text-neutral-900 dark:bg-neutral-900 dark:text-slate-100"> <main class="bg-slate-100 text-neutral-900 dark:bg-neutral-900 dark:text-slate-100">
<script>
function toastsHandler() {
return {
notices: [],
visible: [],
add(notice) {
notice.id = Date.now()
this.notices.push(notice)
this.fire(notice.id)
},
fire(id) {
this.visible.push(this.notices.find(notice => notice.id == id))
const timeShown = 2000 * this.visible.length
setTimeout(() => {
this.remove(id)
}, timeShown)
},
remove(id) {
const notice = this.visible.find(notice => notice.id == id)
const index = this.visible.indexOf(notice)
this.visible.splice(index, 1)
const index2 = this.notices.indexOf(notice)
this.notices.splice(index2, 1)
}
}
}
</script>
<div
x-data="toastsHandler()"
class="absolute flex flex-col justify-start h-full w-screen z-40 py-4"
@notice.window="add($event.detail)"
style="pointer-events:none">
<template x-for="notice of notices" :key="notice.id">
<div
x-show="visible.includes(notice)"
x-transition:enter="transition ease-in duration-200"
x-transition:enter-start="transform opacity-0 -translate-y-2"
x-transition:enter-end="transform opacity-100"
x-transition:leave="transition ease-out duration-500"
x-transition:leave-start="transform translate-y-0 opacity-100"
x-transition:leave-end="transform -translate-y-full opacity-0"
@click="remove(notice.id)"
class="rounded mb-4 mx-auto w-56 p-1 flex items-center justify-center text-white shadow-lg font-bold text-sm cursor-pointer"
:class="{
'bg-green-500': notice.type === 'info',
'bg-cerise': notice.type === 'error'
}"
style="pointer-events:all"
x-text="notice.text">
</div>
</template>
</div>
{% block content %} {% block content %}
<p>Content Missing</p> <p>Content Missing</p>
{% endblock %} {% endblock %}
</main> </main>
<footer> <footer>
</footer> </footer>

Loading…
Cancel
Save

Powered by TurnKey Linux.