Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
773a2787d9 | 12 months ago |
@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="inventory-app.db" uuid="a0dc3bc5-119e-4708-b51f-2c3820c30deb">
|
||||||
|
<driver-ref>sqlite.xerial</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:sqlite:inventory-app.db</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
<driver-properties>
|
||||||
|
<property name="foreign_keys" value="true" />
|
||||||
|
</driver-properties>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="myValues">
|
||||||
|
<value>
|
||||||
|
<list size="3">
|
||||||
|
<item index="0" class="java.lang.String" itemvalue="x-show" />
|
||||||
|
<item index="1" class="java.lang.String" itemvalue="x-model" />
|
||||||
|
<item index="2" class="java.lang.String" itemvalue="x-data" />
|
||||||
|
</list>
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
<option name="myCustomValuesEnabled" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="RsUnusedImport" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="enableOnlyIfProcMacrosEnabled" value="false" />
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="EMPTY_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="library" name="alpinejs" level="application" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="SqlDialectMappings">
|
||||||
|
<file url="file://$PROJECT_DIR$/migrations/20241107225934_initial.sql" dialect="SQLite" />
|
||||||
|
<file url="PROJECT" dialect="SQLite" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@ -1,14 +0,0 @@
|
|||||||
use axum::debug_handler;
|
|
||||||
use axum::extract::{Path, State};
|
|
||||||
use sqlx::SqlitePool;
|
|
||||||
use askama_axum::{IntoResponse, Response};
|
|
||||||
use crate::db::inventory_item::delete_inventory_item;
|
|
||||||
use crate::error::AppError;
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
pub async fn delete_item(State(db): State<SqlitePool>, Path(id): Path<i64>) -> Result<Response, AppError> {
|
|
||||||
delete_inventory_item(&db, id).await?;
|
|
||||||
|
|
||||||
let response = format!("Item {} Deleted", id);
|
|
||||||
Ok(response.into_response())
|
|
||||||
}
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
use crate::error::AppError;
|
|
||||||
use crate::session::SessionUser;
|
|
||||||
use askama::Template;
|
|
||||||
use askama_axum::{IntoResponse, Response};
|
|
||||||
use axum::extract::{Path, State};
|
|
||||||
use axum::{async_trait, debug_handler};
|
|
||||||
use axum_htmx::{HxEvent, HxResponseTrigger};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use crate::app::routes::AppState;
|
|
||||||
use crate::db::display_unit::DbDisplayUnit;
|
|
||||||
use crate::db;
|
|
||||||
use crate::db::inventory_item::{DbInventoryItem, DbInventoryItemEditableFields};
|
|
||||||
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::edit_field_value::{EditBoolField, EditFloatField, EditStringField};
|
|
||||||
use crate::util::form::extractors::{FormBase, FormWithPathVars, ValidForm};
|
|
||||||
use crate::util::form::field_error::FieldError;
|
|
||||||
|
|
||||||
#[derive(Template, Debug, Default)]
|
|
||||||
#[template(path = "item/item-edit-form.html")]
|
|
||||||
pub struct EditItemFormTemplate {
|
|
||||||
pub item_id: i64,
|
|
||||||
pub display_units: Vec<DbDisplayUnit>,
|
|
||||||
pub name: EditStringField,
|
|
||||||
pub display_unit_value: EditStringField,
|
|
||||||
pub reorder_point: EditStringField,
|
|
||||||
pub pims_id: EditStringField,
|
|
||||||
pub vetcove_id: EditStringField,
|
|
||||||
pub allow_fractional_units: EditBoolField,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EditItemFormTemplate {
|
|
||||||
pub fn base(item: DbInventoryItemEditableFields, display_units: Vec<DbDisplayUnit>) -> Self {
|
|
||||||
let name = EditStringField::new(item.name);
|
|
||||||
let pims_id = EditStringField::from(item.pims_id);
|
|
||||||
let vetcove_id = EditStringField::from(item.vetcove_id);
|
|
||||||
let allow_fractional_units = EditBoolField::new(item.allow_fractional_units);
|
|
||||||
let display_unit_value = EditStringField::new(item.display_unit_abbreviation);
|
|
||||||
|
|
||||||
let precision = if item.allow_fractional_units { 2 } else { 0 };
|
|
||||||
let reorder_point= format!("{:.*}", precision, item.reorder_point);
|
|
||||||
let reorder_point = EditStringField::new(reorder_point);
|
|
||||||
|
|
||||||
Self {
|
|
||||||
item_id: item.id,
|
|
||||||
name,
|
|
||||||
reorder_point,
|
|
||||||
pims_id,
|
|
||||||
vetcove_id,
|
|
||||||
allow_fractional_units,
|
|
||||||
display_unit_value,
|
|
||||||
display_units,
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
|
||||||
pub struct EditItemFormData {
|
|
||||||
name: String,
|
|
||||||
display_unit: String,
|
|
||||||
reorder_point: f64,
|
|
||||||
pims_id: Option<String>,
|
|
||||||
vetcove_id: Option<String>,
|
|
||||||
#[serde(default, deserialize_with = "deserialize_form_checkbox")]
|
|
||||||
allow_fractional_units: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl ValidateForm for FormWithPathVars<i64,EditItemFormData> {
|
|
||||||
type ValidationErrorResponse = EditItemFormTemplate;
|
|
||||||
|
|
||||||
async fn validate(self, state: &AppState) -> Result<Self, ValidateFormError<Self::ValidationErrorResponse>> {
|
|
||||||
let item_id = self.path_data;
|
|
||||||
let display_units = db::display_unit::get_display_units(&state.db).await?;
|
|
||||||
let current_values = db::inventory_item::inventory_item_get_by_id_editable_fields(&state.db, item_id).await?;
|
|
||||||
|
|
||||||
let name = EditStringField::new_with_base(
|
|
||||||
self.form_data.name.clone(),
|
|
||||||
current_values.name.clone(),
|
|
||||||
)
|
|
||||||
.invalid_if(|v| v.is_empty(), FieldError::Required);
|
|
||||||
|
|
||||||
let display_unit_value = EditStringField::new_with_base(
|
|
||||||
self.form_data.display_unit.clone(),
|
|
||||||
current_values.display_unit_abbreviation.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)
|
|
||||||
.reset_if_error();
|
|
||||||
|
|
||||||
let reorder_point = EditFloatField::new_with_base(
|
|
||||||
self.form_data.reorder_point.clone(),
|
|
||||||
current_values.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, b| (format!("{:.2}", v), format!("{:.2}", b)));
|
|
||||||
|
|
||||||
let pims_id = EditStringField::new_with_base(
|
|
||||||
self.form_data.pims_id.clone().unwrap_or_default(),
|
|
||||||
current_values.pims_id.unwrap_or_default(),
|
|
||||||
)
|
|
||||||
.invalid_if(|v| v.chars().any(char::is_whitespace), FieldError::ValidIdentifier);
|
|
||||||
|
|
||||||
let vetcove_id = EditStringField::new_with_base(
|
|
||||||
self.form_data.vetcove_id.clone().unwrap_or_default(),
|
|
||||||
current_values.vetcove_id.unwrap_or_default(),
|
|
||||||
)
|
|
||||||
.invalid_if(|v| !v.chars().all(|c| c.is_ascii_digit()), FieldError::ValidIdentifier);
|
|
||||||
|
|
||||||
let allow_fractional_units = EditBoolField::new_with_base(
|
|
||||||
self.form_data.allow_fractional_units,
|
|
||||||
current_values.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 {
|
|
||||||
item_id,
|
|
||||||
display_units,
|
|
||||||
name,
|
|
||||||
display_unit_value,
|
|
||||||
reorder_point,
|
|
||||||
pims_id,
|
|
||||||
vetcove_id,
|
|
||||||
allow_fractional_units,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Ok(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn edit_item_form_post(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
user: SessionUser,
|
|
||||||
Path(id): Path<i64>,
|
|
||||||
form: ValidForm<FormWithPathVars<i64,EditItemFormData>>,
|
|
||||||
) -> Result<Response, AppError> {
|
|
||||||
|
|
||||||
let form_data = &form.form_data;
|
|
||||||
|
|
||||||
db::inventory_item::update_inventory_item(&state.db, id, &form_data.name, form_data.reorder_point,
|
|
||||||
form_data.allow_fractional_units, &form_data.display_unit,
|
|
||||||
&form_data.pims_id, &form_data.vetcove_id, true,
|
|
||||||
).await?;
|
|
||||||
|
|
||||||
let new_base = db::inventory_item::inventory_item_get_by_id_editable_fields(&state.db, id).await?;
|
|
||||||
let display_units = db::display_unit::get_display_units(&state.db).await?;
|
|
||||||
let fresh_form = EditItemFormTemplate::base(new_base, display_units);
|
|
||||||
|
|
||||||
let events = vec![
|
|
||||||
HxEvent::from("form-submit-success"),
|
|
||||||
HxEvent::from("item-updated"),
|
|
||||||
];
|
|
||||||
|
|
||||||
Ok( (
|
|
||||||
HxResponseTrigger::normal(events),
|
|
||||||
fresh_form.into_response()
|
|
||||||
).into_response() )
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
pub async fn edit_item_form_get(
|
|
||||||
Path(id): Path<i64>,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
) -> Result<Response, AppError> {
|
|
||||||
let item_vars = db::inventory_item::inventory_item_get_by_id_editable_fields(&state.db, id).await?;
|
|
||||||
let display_units = db::display_unit::get_display_units(&state.db).await?;
|
|
||||||
let template = EditItemFormTemplate::base(item_vars, display_units);
|
|
||||||
Ok(template.into_response())
|
|
||||||
}
|
|
||||||
@ -1,8 +1,5 @@
|
|||||||
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,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,17 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
||||||
@ -1,132 +0,0 @@
|
|||||||
use std::fmt::{Debug, Display};
|
|
||||||
use crate::util::form::field_error::FieldError;
|
|
||||||
|
|
||||||
/**
|
|
||||||
A sum type for use with forms and form validation. Similar to a basic field_value,
|
|
||||||
but contains a base value that can be compared to and reverted to. The base value is
|
|
||||||
assumed to be "good" in terms of validation, meaning the Error refers to the current
|
|
||||||
value rather than the base value.
|
|
||||||
*/
|
|
||||||
|
|
||||||
pub struct EditFieldValue<Value, Error> {
|
|
||||||
pub value: Value,
|
|
||||||
pub base: Value,
|
|
||||||
pub error: Option<Error>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type EditStringField = EditFieldValue<String, FieldError>;
|
|
||||||
pub type EditBoolField = EditFieldValue<bool, FieldError>;
|
|
||||||
pub type EditFloatField = EditFieldValue<f64, FieldError>;
|
|
||||||
|
|
||||||
impl<Value, Error> EditFieldValue<Value, Error>
|
|
||||||
where Value: Default + Clone + PartialEq, Error: ToString
|
|
||||||
{
|
|
||||||
pub fn new(value: Value) -> Self {
|
|
||||||
let base = value.clone();
|
|
||||||
Self { value, base, error: None }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_with_base(value: Value, base: Value) -> Self {
|
|
||||||
Self { value, base, 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 with_base(mut self, base: Value) -> Self {
|
|
||||||
self.base = base;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_changed(&self) -> bool {
|
|
||||||
self.value != self.base
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reset(mut self) -> Self {
|
|
||||||
self.value = self.base.clone();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reset_if_error(mut self) -> Self {
|
|
||||||
if self.is_error() { self.reset() } else { self }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn map<F, V>(self, f: F) -> EditFieldValue<V, Error>
|
|
||||||
where F: FnOnce(Value, Value) -> (V, V) {
|
|
||||||
let (value, base) = f(self.value, self.base);
|
|
||||||
EditFieldValue::<V, Error> {
|
|
||||||
value,
|
|
||||||
base,
|
|
||||||
error: self.error,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_error(mut self, error: Error) -> Self {
|
|
||||||
self.set_error(error);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error_string(&self) -> String {
|
|
||||||
match self.error {
|
|
||||||
Some(ref error) => error.to_string(),
|
|
||||||
None => "Unknown".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Value, Error> Default for EditFieldValue<Value, Error>
|
|
||||||
where Value: Default + Clone + PartialEq, Error: ToString
|
|
||||||
{
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new(Value::default())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Value, Error> Debug for EditFieldValue<Value, Error>
|
|
||||||
where Value: Debug,
|
|
||||||
Error: Debug {
|
|
||||||
fn fmt(&self, fmt: &mut ::std::fmt::Formatter) -> Result<(), ::std::fmt::Error> {
|
|
||||||
fmt.debug_struct("EditFieldValue")
|
|
||||||
.field("value", &self.value)
|
|
||||||
.field("error", &self.error)
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Value, Error> From<Option<Value>> for EditFieldValue<Value, Error>
|
|
||||||
where Value: Default + Clone + PartialEq, Error: ToString {
|
|
||||||
fn from(value: Option<Value>) -> Self {
|
|
||||||
Self::new(value.unwrap_or_default())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Value, Error> From<Result<Value, Error>> for EditFieldValue<Value, Error>
|
|
||||||
where Value: Default + Clone + PartialEq, Error: ToString {
|
|
||||||
fn from(value: Result<Value, Error>) -> Self {
|
|
||||||
match value {
|
|
||||||
Ok(value) => Self::new(value),
|
|
||||||
Err(error) => Self::default().with_error(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
use std::fmt::{Debug, Display};
|
|
||||||
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, Error: ToString
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_error(mut self, error: Error) -> Self {
|
|
||||||
self.set_error(error);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error_string(&self) -> String {
|
|
||||||
match self.error {
|
|
||||||
Some(ref error) => error.to_string(),
|
|
||||||
None => "Unknown".to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Value, Error> Default for FieldValue<Value, Error>
|
|
||||||
where Value: Default, Error: ToString
|
|
||||||
{
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Value, Error> From<Option<Value>> for FieldValue<Value, Error>
|
|
||||||
where Value: Default, Error: ToString {
|
|
||||||
fn from(value: Option<Value>) -> Self {
|
|
||||||
Self::new(value.unwrap_or_default())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Value, Error> From<Result<Value, Error>> for FieldValue<Value, Error>
|
|
||||||
where Value: Default, Error: ToString {
|
|
||||||
fn from(value: Result<Value, Error>) -> Self {
|
|
||||||
match value {
|
|
||||||
Ok(value) => Self::new(value),
|
|
||||||
Err(error) => Self::default().with_error(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
pub mod field_error;
|
|
||||||
pub mod field_value;
|
|
||||||
pub mod validate_form;
|
|
||||||
pub mod extractors;
|
|
||||||
pub mod helpers;
|
|
||||||
pub mod base_response;
|
|
||||||
pub mod edit_field_value;
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
<form
|
|
||||||
id="item-create"
|
|
||||||
hx-post="/item/{{item_id}}/edit"
|
|
||||||
hx-target="this"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
x-on:htmx:response-error="$dispatch('notice', {type: 'error', text: 'Unknown error'})"
|
|
||||||
x-on:form-submit-success="$dispatch('notice', {type: 'info', text: 'Changes saved'})"
|
|
||||||
>
|
|
||||||
<div class="mb-5 grid grid-cols-6 gap-4 p-2">
|
|
||||||
<div class="col-span-6">
|
|
||||||
<label for="name" class="mb-2 block text-sm font-medium">Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="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"
|
|
||||||
aria-label="name"
|
|
||||||
value="{{ name.value }}"
|
|
||||||
required
|
|
||||||
{% if name.is_error() -%}
|
|
||||||
aria-invalid="true"
|
|
||||||
aria-describedby="invalid-name"
|
|
||||||
{% endif -%}
|
|
||||||
/>
|
|
||||||
{% if name.is_error() -%}
|
|
||||||
<small id="invalid-name" class="block mt-2 text-sm text-cerise">
|
|
||||||
{{ name.error_string() }}
|
|
||||||
</small>
|
|
||||||
{% endif -%}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label for="reorder_point" class="mb-2 block text-sm font-medium">Reorder Point</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="reorder_point"
|
|
||||||
name="reorder_point"
|
|
||||||
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.is_error() -%}
|
|
||||||
aria-invalid="true"
|
|
||||||
aria-describedby="invalid-reorder-point"
|
|
||||||
{% endif -%}
|
|
||||||
/>
|
|
||||||
{% if reorder_point.is_error() -%}
|
|
||||||
<small id="invalid-reorder-point" class="block mt-2 text-sm text-cerise">
|
|
||||||
{{ reorder_point.error_string() }}
|
|
||||||
</small>
|
|
||||||
{% endif -%}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label for="display_unit" class="mb-2 block text-sm font-medium">Unit</label>
|
|
||||||
<select
|
|
||||||
id="display_unit"
|
|
||||||
name="display_unit"
|
|
||||||
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_value.is_error() -%}
|
|
||||||
aria-invalid="true"
|
|
||||||
aria-describedby="invalid-display-unit"
|
|
||||||
{% endif -%}
|
|
||||||
>
|
|
||||||
{% for unit in display_units -%}
|
|
||||||
<option value="{{ unit.abbreviation }}" {% if unit.abbreviation == display_unit_value.value %} selected {% endif %}>{{ unit.unit }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{% if display_unit_value.is_error() -%}
|
|
||||||
<small id="invalid-display-unit" class="block mt-2 text-sm text-cerise">
|
|
||||||
{{ display_unit_value.error_string() }}
|
|
||||||
</small>
|
|
||||||
{% endif -%}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label for="allow_fractional_units" class="block mb-2 text-sm font-medium">Fractional</label>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
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 -%}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="col-span-3">
|
|
||||||
<label for="pims_id" class="mb-2 block text-sm font-medium">PIMS Id</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
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 }}"
|
|
||||||
aria-label="pims id"
|
|
||||||
{% if pims_id.is_error() -%}
|
|
||||||
aria-invalid="true"
|
|
||||||
aria-describedby="invalid-pims-id"
|
|
||||||
{% endif -%}
|
|
||||||
/>
|
|
||||||
{% if pims_id.is_error() -%}
|
|
||||||
<small id="invalid-pims-id" class="block mt-2 text-sm text-cerise">
|
|
||||||
{{ pims_id.error_string() }}
|
|
||||||
</small>
|
|
||||||
{% endif -%}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-span-3">
|
|
||||||
<label for="vetcove_id" class="mb-2 block text-sm font-medium">Vetcove Id</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
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 }}"
|
|
||||||
aria-label="vetcove id"
|
|
||||||
{% if vetcove_id.is_error() -%}
|
|
||||||
aria-invalid="true"
|
|
||||||
aria-describedby="invalid-vetcove-id"
|
|
||||||
{% endif -%}
|
|
||||||
/>
|
|
||||||
{% if vetcove_id.is_error() -%}
|
|
||||||
<small id="invalid-vetcove-id" class="block mt-2 text-sm text-cerise">
|
|
||||||
{{ vetcove_id.error_string() }}
|
|
||||||
</small>
|
|
||||||
{% endif -%}
|
|
||||||
</div>
|
|
||||||
<div class="col-span-4">
|
|
||||||
<button
|
|
||||||
class="mb-2 me-2 rounded-lg bg-english-violet px-5 py-2.5 text-sm font-medium text-slate-100 hover:bg-dark-cyan focus:outline-none focus:ring-4 focus:ring-dark-cyan"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-4">
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="mb-2 me-2 rounded-lg bg-cerise px-5 py-2.5 text-sm font-medium text-neutral-900 hover:bg-orchid-pink focus:outline-none focus:ring-4 focus:ring-dark-cyan"
|
|
||||||
hx-delete="/item/{{item_id}}"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
Loading…
Reference in new issue