Compare commits
5 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
123a95277d | 10 months ago |
|
|
9e6b17b154 | 10 months ago |
|
|
453d36dd86 | 11 months ago |
|
|
bef1ebdae7 | 12 months ago |
|
|
5977cbae77 | 12 months ago |
@ -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>
|
||||
@ -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,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,69 +1,92 @@
|
||||
{% extends "main.html" %} {% block title %} Items {% endblock %} {% block
|
||||
content %}
|
||||
{% extends "main.html" %}
|
||||
|
||||
<div class="relative h-auto mx-auto max-w-[68.75rem] w-[95vw] px-4 py-4">
|
||||
{% block title %} Items {% endblock %}
|
||||
|
||||
<h2 class="mb-4 flex items-center text-4xl font-extrabold">
|
||||
{{item.name}} {% if !item.active %}
|
||||
<span
|
||||
class="me-2 ms-2 rounded border border-cerise bg-orchid-pink px-2.5 py-0.5 text-2xl font-semibold text-cerise"
|
||||
>
|
||||
{% block content %}
|
||||
|
||||
<div class="relative h-auto mx-auto max-w-[68.75rem] w-[95vw] px-4 py-4">
|
||||
|
||||
<h2 class="mb-4 flex items-center text-4xl font-extrabold">
|
||||
{{item.name}} {% if !item.active %}
|
||||
<span
|
||||
class="me-2 ms-2 rounded border border-cerise bg-orchid-pink px-2.5 py-0.5 text-2xl font-semibold text-cerise"
|
||||
>
|
||||
Inactive
|
||||
</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
<section
|
||||
hx-get="/item/{{item_id}}/stats"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML"
|
||||
></section>
|
||||
<section
|
||||
hx-get="/item/{{item_id}}/stats"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML"
|
||||
></section>
|
||||
|
||||
{% if item.active %}
|
||||
<section
|
||||
class="mx-auto mb-5"
|
||||
x-data="{ negative_form_open: false, positive_form_open: false }"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
Plus
|
||||
</button>
|
||||
</div>
|
||||
{% if item.active %}
|
||||
<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="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="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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div x-show="positive_form_open" @click.outside="positive_form_open = false">
|
||||
<div
|
||||
hx-get="/item/{{item_id}}/adjustment/positive"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML"
|
||||
></div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% 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 %}
|
||||
|
||||
<section class="mx-auto">
|
||||
{% block sidebar_content %}
|
||||
<div x-on:form-submit-success="hideSidebar()">
|
||||
<div x-show="sidebar.content === 'positive-adjustment-form'">
|
||||
<div
|
||||
hx-get="/item/{{item_id}}/adjustments"
|
||||
hx-get="/item/{{item_id}}/adjustment/positive"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML"
|
||||
></div>
|
||||
</div>
|
||||
<div x-show="sidebar.content === 'negative-adjustment-form'">
|
||||
<div
|
||||
hx-get="/item/{{item_id}}/adjustment/negative"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML"
|
||||
></div>
|
||||
</div>
|
||||
<div x-show="sidebar.content === 'item-edit-form'">
|
||||
<div
|
||||
hx-get="/item/{{item_id}}/edit"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML"
|
||||
></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
Loading…
Reference in new issue