Compare commits

...

5 Commits

3
.gitignore vendored

@ -7,3 +7,6 @@
/node_modules
/*.lockb
/.idea/inventory-app.iml
/.idea/vcs.xml

5
.idea/.gitignore vendored

@ -6,3 +6,8 @@
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
/dataSources.xml
/inspectionProfiles/
/sqldialects.xml
/codeStyles/
/prettier.xml

@ -1,15 +0,0 @@
<?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>

@ -1,20 +0,0 @@
<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>

@ -1,13 +0,0 @@
<?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>

@ -1,7 +0,0 @@
<?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>

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

@ -6,18 +6,33 @@ use crate::session::SessionUser;
use askama::Template;
use askama_axum::{IntoResponse, Response};
use axum::extract::{Path, State};
use axum::{debug_handler, Form};
use axum::{async_trait, debug_handler, Form};
use axum_htmx::{HxEvent, HxResponseTrigger};
use serde::Deserialize;
use sqlx::SqlitePool;
use tracing::info;
use crate::app::routes::AppState;
use crate::util::form::extractors::{FormWithPathVars, ValidForm};
use crate::util::form::field_error::FieldError;
use crate::util::form::field_value::{FloatField, StringField};
use crate::util::form::validate_form::{ValidateForm, ValidateFormError};
#[derive(Template)]
#[derive(Template, Debug)]
#[template(path = "item/adjustment/negative-adjustment-form.html")]
pub struct NegativeAdjustmentFormTemplate {
pub item_id: i64,
pub amount_error: &'static str,
pub reason_error: &'static str,
pub amount: StringField,
pub reason: StringField,
}
impl Default for NegativeAdjustmentFormTemplate {
fn default() -> Self {
Self {
item_id: Default::default(),
amount: Default::default(),
reason: StringField::new(DbAdjustmentReason::Sale.into()),
}
}
}
#[derive(Deserialize, Debug)]
@ -26,58 +41,59 @@ pub struct NegativeAdjustmentFormData {
pub reason: Option<String>,
}
#[debug_handler]
#[async_trait]
impl ValidateForm for FormWithPathVars<i64, NegativeAdjustmentFormData> {
type ValidationErrorResponse = NegativeAdjustmentFormTemplate;
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 reason = <Option<String> as Into<StringField>>::into(self.form_data.reason.clone())
.invalid_if(|v| v.is_empty(), FieldError::Required)
.invalid_if(|v| DbAdjustmentReason::try_from(v.as_str()).is_err(), FieldError::SelectOption);
if amount.is_error() || reason.is_error() {
return Err(ValidateFormError::ValidationError(Self::ValidationErrorResponse {
item_id,
amount,
reason,
}));
}
Ok(self)
}
}
pub async fn negative_adjustment_form_post(
State(db): State<SqlitePool>,
Path(id): Path<i64>,
user: SessionUser,
mut form_data: Form<NegativeAdjustmentFormData>,
mut data: ValidForm<FormWithPathVars<i64,NegativeAdjustmentFormData>>
) -> Result<Response, AppError> {
let adjustment_amount = if form_data.amount > 0.0 {
-1.0 * form_data.amount
} else {
form_data.amount
};
let fractional_units_allowed = does_inventory_item_allow_fractional_units(&db, id).await?;
let adjustment_amount = -1.0 * data.form_data.amount;
let reason = form_data
let reason = data.form_data
.reason
.take()
.and_then(|s| DbAdjustmentReason::try_from(s.as_str()).ok())
.unwrap_or_else(|| DbAdjustmentReason::Unknown);
let amount_error = if adjustment_amount == 0.0 {
"Please input a non-zero amount"
} else if !(fractional_units_allowed || adjustment_amount.fract() == 0.0) {
"Please input a whole number"
} else {
""
};
let reason_error = if reason == DbAdjustmentReason::Unknown {
"Unknown adjustment reason"
} else {
""
};
if !(amount_error.is_empty() && reason_error.is_empty()) {
return Ok(NegativeAdjustmentFormTemplate {
item_id: id,
amount_error,
reason_error,
}
.into_response());
}
let trigger_events =
HxResponseTrigger::normal(std::iter::once(HxEvent::from("new-adjustment")));
let trigger_events = vec![
HxEvent::from("new-adjustment"),
HxEvent::from("form-submit-success"),
];
let timestamp = chrono::Utc::now();
info!(
"Add adjustment form: (Amount {}, Reason {:?}, for user {}",
form_data.amount, reason, user.name
data.form_data.amount, reason, user.name
);
let _new_id = add_adjustment(
@ -93,22 +109,21 @@ pub async fn negative_adjustment_form_post(
.await?;
Ok((
trigger_events,
HxResponseTrigger::normal(trigger_events),
NegativeAdjustmentFormTemplate {
item_id: id,
amount_error: "",
reason_error: "",
..Default::default()
}
.into_response(),
)
.into_response())
}
#[debug_handler]
pub async fn negative_adjustment_form_get(Path(id): Path<i64>) -> Result<Response, AppError> {
Ok(NegativeAdjustmentFormTemplate {
item_id: id,
amount_error: "",
reason_error: "",
..Default::default()
}
.into_response())
}

@ -7,18 +7,23 @@ use crate::util::currency::dollars_string_to_int_cents;
use askama::Template;
use askama_axum::{IntoResponse, Response};
use axum::extract::{Path, State};
use axum::{debug_handler, Form};
use axum::{async_trait, debug_handler, Form};
use axum_htmx::{HxEvent, HxResponseTrigger};
use serde::Deserialize;
use sqlx::SqlitePool;
use tracing::info;
use crate::app::routes::AppState;
use crate::util::form::extractors::{FormWithPathVars, ValidForm};
use crate::util::form::field_error::FieldError;
use crate::util::form::field_value::{FloatField, StringField};
use crate::util::form::validate_form::{ValidateForm, ValidateFormError};
#[derive(Template)]
#[derive(Template, Debug, Default)]
#[template(path = "item/adjustment/positive-adjustment-form.html")]
pub struct PositiveAdjustmentFormTemplate {
pub item_id: i64,
pub amount_error: &'static str,
pub price_error: &'static str,
pub amount: StringField,
pub price: StringField,
}
#[derive(Deserialize, Debug)]
@ -27,52 +32,56 @@ pub struct PositiveAdjustmentFormData {
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(
State(db): State<SqlitePool>,
Path(id): Path<i64>,
user: SessionUser,
form_data: Form<PositiveAdjustmentFormData>,
data: ValidForm<FormWithPathVars<i64,PositiveAdjustmentFormData>>
) -> 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 unit_price = (price as f64 / form_data.amount) as i64;
let id = data.path_data;
let price = dollars_string_to_int_cents(&data.form_data.price)?;
let unit_price = (price as f64 / data.form_data.amount) as i64;
let trigger_events =
HxResponseTrigger::normal(std::iter::once(HxEvent::from("new-adjustment")));
let trigger_events = vec![
HxEvent::from("form-submit-success"),
HxEvent::from("new-adjustment"),
];
let timestamp = chrono::Utc::now();
let adjustment_amount = form_data.amount;
let adjustment_amount = data.form_data.amount;
info!(
"Add adjustment form: {:?} for user {}",
form_data, user.name
data.form_data, user.name
);
let _new_id = add_adjustment(
@ -88,11 +97,10 @@ pub async fn positive_adjustment_form_post(
.await?;
Ok((
trigger_events,
HxResponseTrigger::normal(trigger_events),
PositiveAdjustmentFormTemplate {
item_id: id,
amount_error: "",
price_error: "",
..Default::default()
}
.into_response(),
)
@ -102,8 +110,7 @@ pub async fn positive_adjustment_form_post(
pub async fn positive_adjustment_form_get(Path(id): Path<i64>) -> Result<Response, AppError> {
Ok(PositiveAdjustmentFormTemplate {
item_id: id,
amount_error: "",
price_error: "",
..Default::default()
}
.into_response())
}

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

@ -0,0 +1,14 @@
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())
}

@ -0,0 +1,184 @@
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,4 +1,4 @@
use crate::db::inventory_item::{inventory_item_get_by_id_with_unit, DbInventoryItemWithCount};
use crate::db::inventory_item::{inventory_item_get_by_id_with_unit_and_count, DbInventoryItemWithCount};
use crate::error::AppError;
use askama::Template;
use askama_axum::{IntoResponse, Response};
@ -15,7 +15,7 @@ struct ItemTemplate {
#[debug_handler]
pub async fn item(State(db): State<SqlitePool>, Path(id): Path<i64>) -> Result<Response, AppError> {
let item: ItemDisplayItem = inventory_item_get_by_id_with_unit(&db, id).await?.into();
let item: ItemDisplayItem = inventory_item_get_by_id_with_unit_and_count(&db, id).await?.into();
Ok(ItemTemplate { item_id: id, item }.into_response())
}

@ -3,3 +3,5 @@ pub mod item;
pub mod stats;
pub mod count;
pub mod create;
pub mod delete;
pub mod edit;

@ -6,7 +6,7 @@ use anyhow::anyhow;
use axum::debug_handler;
use crate::app::item::item::ItemDisplayItem;
use crate::db::adjustment::get_item_adjustment_valuation_weighted_mean;
use crate::db::inventory_item::inventory_item_get_by_id_with_unit;
use crate::db::inventory_item::inventory_item_get_by_id_with_unit_and_count;
use crate::error::AppError;
use crate::util::currency::int_cents_to_dollars_string;
@ -22,7 +22,7 @@ struct ItemStatsTemplate {
pub async fn item_stats(State(db): State<SqlitePool>, Path(id): Path<i64>) -> Result<Response, AppError> {
//TODO This is pretty chatty with the database. Might could cut down the
// number of queries
let item: ItemDisplayItem = inventory_item_get_by_id_with_unit(&db, id).await?.into();
let item: ItemDisplayItem = inventory_item_get_by_id_with_unit_and_count(&db, id).await?.into();
let value = get_item_adjustment_valuation_weighted_mean(&db, id)
.await?

@ -2,7 +2,7 @@ use crate::app::{catalog, history, home, item, overview, reports, upload};
use crate::session::SessionUser;
use axum::extract::FromRef;
use axum::middleware::from_extractor;
use axum::routing::{get, post};
use axum::routing::{get, post, delete};
use axum::Router;
use axum_htmx::AutoVaryLayer;
use oauth2::basic::BasicClient;
@ -16,27 +16,34 @@ pub fn routes() -> Router<AppState> {
.route("/home/search", get(home::home))
.route("/catalog", get(catalog::catalog))
.route("/catalog/", get(catalog::catalog))
.route("/item/:item_id", get(item::item::item))
.route("/item/:item_id/", get(item::item::item))
.route("/item/:item_id",
get(item::item::item).
delete(item::delete::delete_item)
)
.route("/item/:item_id/",
get(item::item::item).
delete(item::delete::delete_item)
)
.route("/item/:item_id/count", get(item::count::item_count))
.route("/item/:item_id/stats", get(item::stats::item_stats))
.route("/item/:item_id/edit",
get(item::edit::edit_item_form_get).
post(item::edit::edit_item_form_post)
)
.route("/item/create",
get(item::create::create_item_form_get)
.post(item::create::create_item_form_post)
get(item::create::create_item_form_get).
post(item::create::create_item_form_post)
)
.route(
"/item/:item_id/adjustments",
.route("/item/:item_id/adjustments",
get(item::adjustment::table::get_adjustments_table),
)
.route(
"/item/:item_id/adjustment/negative",
get(item::adjustment::negative::negative_adjustment_form_get)
.post(item::adjustment::negative::negative_adjustment_form_post),
.route("/item/:item_id/adjustment/negative",
get(item::adjustment::negative::negative_adjustment_form_get).
post(item::adjustment::negative::negative_adjustment_form_post),
)
.route(
"/item/:item_id/adjustment/positive",
get(item::adjustment::positive::positive_adjustment_form_get)
.post(item::adjustment::positive::positive_adjustment_form_post),
.route("/item/:item_id/adjustment/positive",
get(item::adjustment::positive::positive_adjustment_form_get).
post(item::adjustment::positive::positive_adjustment_form_post),
)
.route("/upload", get(upload::index::upload_index_handler))
.route("/upload/catalog", post(upload::catalog::catalog_import))

@ -60,12 +60,12 @@ impl TryFrom<&str> for DbAdjustmentReason {
impl Into<String> for DbAdjustmentReason {
fn into(self) -> String {
match self {
Self::Unknown => { String::from("unknown") }
Self::Sale => { String::from("sale") }
Self::Destruction => { String::from("destruction") }
Self::Expiration => { String::from("expiration") }
Self::Theft => { String::from("theft") }
Self::NewStock => { String::from("new-stock") }
Self::Unknown => { String::from("Unknown") }
Self::Sale => { String::from("Sale") }
Self::Destruction => { String::from("Destruction") }
Self::Expiration => { String::from("Expiration") }
Self::Theft => { String::from("Theft") }
Self::NewStock => { String::from("New-stock") }
}
}
}

@ -1,6 +1,7 @@
use serde::Serialize;
use anyhow::Result;
use sqlx::SqlitePool;
use tracing::info;
#[derive(Debug, Serialize)]
#[derive(sqlx::FromRow)]
@ -119,6 +120,39 @@ pub async fn add_inventory_item(db: &SqlitePool, name: &str, reorder_point: f64,
Ok(new_id)
}
pub async fn delete_inventory_item(db: &SqlitePool, id: i64) -> Result<()> {
let mut tx = db.begin().await?;
let adjustments = sqlx::query!(
r#"
DELETE FROM Adjustment WHERE item = ?
"#,
id
).execute(&mut *tx)
.await?
.rows_affected();
let items = sqlx::query!(
r#"
DELETE FROM InventoryItem WHERE id = ?
"#,
id
).execute(&mut *tx)
.await?
.rows_affected();
tx.commit().await?;
if items == 0 {
info!("Delete requested for {}, but no such item exists", id);
}
else {
info!("Item {} deleted. {} adjustments", id, adjustments);
}
Ok(())
}
#[derive(Debug, Serialize)]
#[derive(sqlx::FromRow)]
pub struct DbInventoryItemWithCount {
@ -133,7 +167,7 @@ pub struct DbInventoryItemWithCount {
pub active: bool,
}
pub async fn inventory_item_get_by_id_with_unit(db: &SqlitePool, id: i64) -> Result<DbInventoryItemWithCount> {
pub async fn inventory_item_get_by_id_with_unit_and_count(db: &SqlitePool, id: i64) -> Result<DbInventoryItemWithCount> {
sqlx::query_as!(
DbInventoryItemWithCount,
r#"
@ -174,3 +208,75 @@ pub async fn does_inventory_item_allow_fractional_units(db: &SqlitePool, id: i64
Ok(res.allow_fractional_units)
}
#[derive(Debug, Serialize)]
#[derive(sqlx::FromRow)]
pub struct DbInventoryItemEditableFields {
pub id: i64,
pub name: String,
pub reorder_point: f64,
pub allow_fractional_units: bool,
pub display_unit: i64,
pub display_unit_str: String,
pub display_unit_abbreviation: String,
pub active: bool,
pub pims_id: Option<String>,
pub vetcove_id: Option<String>,
}
pub async fn inventory_item_get_by_id_editable_fields(db: &SqlitePool, id: i64) -> Result<DbInventoryItemEditableFields> {
sqlx::query_as!(
DbInventoryItemEditableFields,
r#"
SELECT
item.id as id,
item.name as name,
item.reorder_point as reorder_point,
item.allow_fractional_units as allow_fractional_units,
item.display_unit as display_unit,
display_unit.unit as display_unit_str,
display_unit.abbreviation as display_unit_abbreviation,
item.active as active,
item.pims_id as pims_id,
item.vetcove_id as vetcove_id
FROM
InventoryItem as item
JOIN DisplayUnit as display_unit ON item.display_unit = display_unit.id
WHERE item.id = ?
"#,
id
)
.fetch_one(db).await
.map_err(From::from)
}
pub async fn update_inventory_item(db: &SqlitePool, id: i64, name: &str, reorder_point: f64,
allow_fractional_units: bool, display_unit_abbreviation: &str,
pims_id: &Option<String>, vetcove_id: &Option<String>, active: bool
) -> Result<()> {
let affected = sqlx::query!(
r#"
UPDATE InventoryItem SET
name = ?,
reorder_point = ?,
allow_fractional_units = ?,
display_unit = (SELECT id from DisplayUnit WHERE abbreviation = ? ),
active = ?,
pims_id = ?,
vetcove_id = ?
WHERE id = ?
"#,
name,
reorder_point,
allow_fractional_units,
display_unit_abbreviation,
active,
pims_id,
vetcove_id,
id
).execute(db).await?.rows_affected();
assert_eq!(affected, 1);
Ok(())
}

@ -24,7 +24,7 @@ pub async fn init() -> Result<(SessionManagerLayer<SqliteStore>, JoinHandle<Resu
let session_store = SqliteStore::new(session_db);
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
// and the handlers. The options basically define the options of
// 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,132 @@
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),
}
}
}

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

@ -0,0 +1,7 @@
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;

@ -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 parsing;
pub mod formatting;
pub mod extract;
pub mod form;

@ -3,35 +3,6 @@
{% block content %}
<div class="relative h-auto" x-data="{ show_sidebar: false }"
x-on:form-submit-success="show_sidebar = false;"
>
<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-10">
</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-20">
<div class="flex">
<div class="flex-1 inline-flex items-center">
<h2 class="text-lg font-semibold uppercase">Add Item</h2>
</div>
<button
class="inline-flex items-center whitespace-nowrap p-2 text-sm font-medium tracking-wide text-neutral-900 transition focus:outline-none focus:ring-4 focus:ring-dark-cyan dark:border-neutral-900 dark:text-slate-100"
@click="show_sidebar = ! show_sidebar"
>
<span class="sr-only">Close Sidebar</span>
X
</button>
</div>
<div>
<div
hx-get="/item/create"
hx-trigger="load"
hx-swap="outerHTML"
></div>
</div>
</div>
</div>
<div class="relative h-auto mx-auto max-w-[68.75rem] w-[95vw] px-4 py-4">
<div class="flex space-x-4">
<div class="relative mb-4 max-w-56 content-center">
@ -77,7 +48,7 @@
<div class="flex items-center">
<button
class="mb-2 me-2 rounded-lg bg-paynes-gray 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"
@click="show_sidebar = ! show_sidebar"
@click="showSidebar('item-create-form')"
>
Add
</button>
@ -89,6 +60,20 @@
</div>
</div>
</div>
{% endblock %}
{% block sidebar_title %}
<h2 class="text-lg font-semibold uppercase" x-show="sidebar.content === 'item-create-form'">Add Item</h2>
{% endblock %}
{% block sidebar_content %}
<div x-show="sidebar.content === 'item-create-form'"
x-on:form-submit-success="hideSidebar()"
>
<div
hx-get="/item/create"
hx-trigger="load"
hx-swap="outerHTML"
></div>
</div>
{% endblock %}

@ -3,7 +3,7 @@
hx-target="this"
hx-swap="outerHTML"
x-ref="formNegativeAdjustment"
x-data="{ reason: 'Sale', reason_dropdown_show: false }"
x-data="{ reason: '{{ reason.value }}', reason_dropdown_show: false }"
>
<div class="mb-5">
<label for="amount" class="mb-2 block text-sm font-medium">Amount</label>
@ -15,20 +15,16 @@
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="Amount"
aria-label="amount"
value="{{ amount.value }}"
required
{%
if
!amount_error.is_empty()
-%}
{% if !amount.is_error() -%}
aria-invalid="true"
aria-describedby="invalid-amount"
{%
endif
-%}
{% endif -%}
/>
{% if !amount_error.is_empty() -%}
{% if amount.is_error() -%}
<small id="invalid-amount" class="mt-2 text-sm text-cerise">
{{ amount_error }}
{{ amount.error_string() }}
</small>
{% endif -%}
</div>
@ -72,7 +68,6 @@
/>
</svg>
</button>
<!-- Dropdown Menu -->
<div
x-cloak
x-show="isOpen || openedWithKeyboard"

@ -13,15 +13,18 @@
step="0.01"
placeholder="Amount"
aria-label="amount"
value="{{ amount.value }}"
required
{% if !amount_error.is_empty() -%}
{% if amount.is_error() -%}
aria-invalid="true"
aria-describedby="invalid-amount"
{% endif -%}
/>
{% if !amount_error.is_empty() -%}
<small id="invalid-amount" class="mt-2 text-sm text-cerise">
{{ amount_error }}
{% if amount.is_error() -%}
<small id="invalid-amount"
class="mt-2 text-sm text-cerise"
>
{{ amount.error_string() }}
</small>
{% endif -%}
</div>
@ -34,21 +37,19 @@
class="block max-w-56 rounded-lg border border-neutral-900 p-2.5 text-sm text-neutral-900 focus:border-paynes-gray focus:ring-paynes-gray dark:text-slate-100"
placeholder="Price"
aria-label="price"
value="{{ price.value }}"
required
{%
if
!price_error.is_empty()
-%}
{% if price.is_error() -%}
aria-invalid="true"
aria-describedby="invalid-price"
{%
endif
-%}
{% endif -%}
/>
{% if !price_error.is_empty() -%}
<small id="invalid-price" class="mt-2 text-sm text-cerise"
>{{ price_error }}</small
{% if price.is_error() -%}
<small id="invalid-price"
class="mt-2 text-sm text-cerise"
>
{{ price.error_string() }}
</small>
{% endif -%}
</div>
<div class="mb-5">

@ -15,16 +15,16 @@
name="name"
class="block w-11/12 p-2 rounded-lg border border-neutral-900 text-sm text-neutral-900 focus:border-paynes-gray focus:ring-paynes-gray dark:text-slate-100"
aria-label="name"
value="{{ name_value }}"
value="{{ name.value }}"
required
{% if !name_error.is_empty() -%}
{% if name.is_error() -%}
aria-invalid="true"
aria-describedby="invalid-name"
{% endif -%}
/>
{% if !name_error.is_empty() -%}
{% if name.is_error() -%}
<small id="invalid-name" class="block mt-2 text-sm text-cerise">
{{ name_error }}
{{ name.error_string() }}
</small>
{% endif -%}
</div>
@ -38,15 +38,15 @@
step="0.01"
class="block w-3/4 p-2 rounded-lg border border-neutral-900 text-sm text-neutral-900 focus:border-paynes-gray focus:ring-paynes-gray dark:text-slate-100"
aria-label="reorder point"
value="{{ reorder_point_value }}"
{% if !reorder_point_error.is_empty() -%}
value="{{ reorder_point.value }}"
{% if reorder_point.is_error() -%}
aria-invalid="true"
aria-describedby="invalid-reorder-point"
{% endif -%}
/>
{% if !reorder_point_error.is_empty() -%}
{% if reorder_point.is_error() -%}
<small id="invalid-reorder-point" class="block mt-2 text-sm text-cerise">
{{ pims_id_error }}
{{ reorder_point.error_string() }}
</small>
{% endif -%}
</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"
aria-label="name"
required
{% if !display_unit_error.is_empty() -%}
{% if display_unit_value.is_error() -%}
aria-invalid="true"
aria-describedby="invalid-display-unit"
{% endif -%}
>
{% for unit in display_units -%}
<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 %}
</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">
{{ display_unit_error }}
{{ display_unit_value.error_string() }}
</small>
{% endif -%}
</div>
@ -82,7 +82,7 @@
id="allow_fractional_units"
name="allow_fractional_units"
class="block w-4 h-4 text-dark-cyan border-slate-100 rounded-sm focus:ring-space-cadet focus:ring-2"
{% if allow_fractional_units_value -%} checked {% endif -%}
{% if allow_fractional_units.value -%} checked {% endif -%}
/>
</div>
@ -94,16 +94,16 @@
id="pims_id"
name="pims_id"
class="block p-2 rounded-lg border border-neutral-900 text-sm text-neutral-900 focus:border-paynes-gray focus:ring-paynes-gray dark:text-slate-100"
value="{{ pims_id_value }}"
value="{{ pims_id.value }}"
aria-label="pims id"
{% if !pims_id_error.is_empty() -%}
{% if pims_id.is_error() -%}
aria-invalid="true"
aria-describedby="invalid-pims-id"
{% endif -%}
/>
{% if !pims_id_error.is_empty() -%}
{% if pims_id.is_error() -%}
<small id="invalid-pims-id" class="block mt-2 text-sm text-cerise">
{{ pims_id_error }}
{{ pims_id.error_string() }}
</small>
{% endif -%}
</div>
@ -115,16 +115,16 @@
id="vetcove_id"
name="vetcove_id"
class="block p-2 rounded-lg border border-neutral-900 text-sm text-neutral-900 focus:border-paynes-gray focus:ring-paynes-gray dark:text-slate-100"
value="{{ vetcove_id_value }}"
value="{{ vetcove_id.value }}"
aria-label="vetcove id"
{% if !vetcove_id_error.is_empty() -%}
{% if vetcove_id.is_error() -%}
aria-invalid="true"
aria-describedby="invalid-vetcove-id"
{% endif -%}
/>
{% if !vetcove_id_error.is_empty() -%}
{% if vetcove_id.is_error() -%}
<small id="invalid-vetcove-id" class="block mt-2 text-sm text-cerise">
{{ vetcove_id_error }}
{{ vetcove_id.error_string() }}
</small>
{% endif -%}
</div>

@ -0,0 +1,148 @@
<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>

@ -1,5 +1,8 @@
{% extends "main.html" %} {% block title %} Items {% endblock %} {% block
content %}
{% extends "main.html" %}
{% block title %} Items {% endblock %}
{% block content %}
<div class="relative h-auto mx-auto max-w-[68.75rem] w-[95vw] px-4 py-4">
@ -20,50 +23,70 @@ content %}
></section>
{% if item.active %}
<section
class="mx-auto mb-5"
x-data="{ negative_form_open: false, positive_form_open: false }"
>
<section class="mx-auto mb-5">
<div class="flex justify-evenly">
<button
class="mb-2 me-2 rounded-lg bg-paynes-gray 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"
@click="negative_form_open = ! negative_form_open"
@click="showSidebar('negative-adjustment-form')"
>
Minus
</button>
<button
class="mb-2 me-2 rounded-lg bg-paynes-gray 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"
@click="positive_form_open = ! positive_form_open"
@click="showSidebar('positive-adjustment-form')"
>
Plus
</button>
<button
class="mb-2 me-2 rounded-lg bg-paynes-gray 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"
@click="showSidebar('item-edit-form')"
>
Edit
</button>
</div>
</section>
{% endif %}
<div x-show="negative_form_open" @click.outside="negative_form_open = false">
<section class="mx-auto">
<div
hx-get="/item/{{item_id}}/adjustment/negative"
hx-get="/item/{{item_id}}/adjustments"
hx-trigger="load"
hx-swap="outerHTML"
></div>
</section>
</div>
<div x-show="positive_form_open" @click.outside="positive_form_open = false">
{% endblock %}
{% block sidebar_title %}
<h2 class="text-lg font-semibold uppercase" x-show="sidebar.content === 'negative-adjustment-form'">Negative Adjustment</h2>
<h2 class="text-lg font-semibold uppercase" x-show="sidebar.content === 'positive-adjustment-form'">Positive Adjustment</h2>
<h2 class="text-lg font-semibold uppercase" x-show="sidebar.content === 'item-edit-form'">Edit</h2>
{% endblock %}
{% block sidebar_content %}
<div x-on:form-submit-success="hideSidebar()">
<div x-show="sidebar.content === 'positive-adjustment-form'">
<div
hx-get="/item/{{item_id}}/adjustment/positive"
hx-trigger="load"
hx-swap="outerHTML"
></div>
</div>
</section>
{% endif %}
<section class="mx-auto">
<div x-show="sidebar.content === 'negative-adjustment-form'">
<div
hx-get="/item/{{item_id}}/adjustments"
hx-get="/item/{{item_id}}/adjustment/negative"
hx-trigger="load"
hx-swap="outerHTML"
></div>
</section>
</div>
<div x-show="sidebar.content === 'item-edit-form'">
<div
hx-get="/item/{{item_id}}/edit"
hx-trigger="load"
hx-swap="outerHTML"
></div>
</div>
</div>
{% endblock %}

@ -18,7 +18,7 @@
<body
class="min-h-screen bg-slate-100 text-neutral-900 dark:bg-neutral-900 dark:text-slate-100"
>
<header class="top-0 w-full bg-slate-100 border-b border-neutral-600 border-opacity-90 font-sans">
<header class="top-0 w-full bg-slate-100 border-b border-neutral-600 border-opacity-90 font-sans dark:bg-neutral-900 dark:text-slate-100">
<nav
class="mx-auto flex max-w-7xl justify-between gap-2 px-2 py-4 sm:px-6 lg:px-8"
>
@ -33,31 +33,31 @@
<div>
<a
href="/overview"
class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white"
class="rounded-md px-3 py-2 text-sm font-medium text-neutral-900 dark:text-slate-100 hover:bg-gray-700 hover:text-white"
>
Overview
</a>
<a
href="/catalog"
class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white"
class="rounded-md px-3 py-2 text-sm font-medium text-neutral-900 dark:text-slate-100 hover:bg-gray-700 hover:text-white"
>
Catalog
</a>
<a
href="/upload"
class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white"
class="rounded-md px-3 py-2 text-sm font-medium text-neutral-900 dark:text-slate-100 hover:bg-gray-700 hover:text-white"
>
Upload
</a>
<a
href="/reports"
class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white"
class="rounded-md px-3 py-2 text-sm font-medium text-neutral-900 dark:text-slate-100 hover:bg-gray-700 hover:text-white"
>
Reports
</a>
<a
href="/history"
class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white"
class="rounded-md px-3 py-2 text-sm font-medium text-neutral-900 dark:text-slate-100 hover:bg-gray-700 hover:text-white"
>
History
</a>
@ -66,33 +66,7 @@
</header>
<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>
<!-- Toasts -->
<div
x-data="toastsHandler()"
class="absolute flex flex-col justify-start h-full w-screen z-40 py-4"
@ -119,10 +93,97 @@
</template>
</div>
<!-- Main -->
<div x-data="sidebarHandler()">
<!-- Sidebar -->
<div class="relative h-auto">
<div class="absolute inset-x-0 h-lvh bg-neutral-300 opacity-90 z-10"
x-show="sidebar.show"
x-transition:enter="transition ease-in duration-200"
x-transition:enter-start="transform opacity-0"
x-transition:enter-end="transform opacity-90"
x-transition:leave="transition ease-out duration-500"
x-transition:leave-start="transform opacity-90"
x-transition:leave-end="transform opacity-0"
></div>
<div class="py-2 px-4 absolute rounded-tl-xl inset-y-0 right-0 bg-slate-100 opacity-100 w-11/12 h-lvh max-w-[40rem] z-20"
x-show="sidebar.show"
x-transition:enter="transition ease-in duration-200"
x-transition:enter-start="transform translate-x-full opacity-0"
x-transition:enter-end="transform translate-x-0 opacity-100"
x-transition:leave="transition ease-out duration-500"
x-transition:leave-start="transform translate-x-0 opacity-100"
x-transition:leave-end="transform translate-x-full opacity-0"
>
<div class="flex">
<div class="flex-1 inline-flex items-center">
{% block sidebar_title %}
<h2 class="text-lg font-semibold uppercase">None</h2>
{% endblock %}
</div>
<button
class="inline-flex items-center whitespace-nowrap p-2 text-sm font-medium tracking-wide text-neutral-900 transition focus:outline-none focus:ring-4 focus:ring-dark-cyan dark:border-neutral-900 dark:text-slate-100"
@click="hideSidebar()"
>
<span class="sr-only">Close Sidebar</span>
X
</button>
</div>
{% block sidebar_content %}
<p>None</p>
{% endblock %}
</div>
<!-- Content -->
{% block content %}
<p>Content Missing</p>
{% endblock %}
</div>
<script>
function sidebarHandler() {
return {
sidebar: {
show: false,
content: 'none',
},
showSidebar(content) {
this.sidebar.show = true;
this.sidebar.content = content;
},
hideSidebar() {
this.sidebar.show = false;
}
}
}
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>
</main>
<footer>
</footer>

Loading…
Cancel
Save

Powered by TurnKey Linux.