refactor: deduplicate update handlers and edit templates
Add HasChanges trait with impl_has_changes! macro, validate_update() and update_response() helpers to reduce boilerplate across all 7 entity update handlers. Extract edit form actions (error, spinner, buttons) into a shared Askama macro. Also adds missing no-changes validation to the bag update handler.
This commit is contained in:
parent
8ced58039b
commit
2cd579a574
17 changed files with 170 additions and 305 deletions
|
|
@ -11,7 +11,8 @@ use crate::application::errors::{ApiError, AppError};
|
||||||
use crate::application::routes::api::images::save_deferred_image;
|
use crate::application::routes::api::images::save_deferred_image;
|
||||||
use crate::application::routes::api::macros::{define_delete_handler, define_enriched_get_handler};
|
use crate::application::routes::api::macros::{define_delete_handler, define_enriched_get_handler};
|
||||||
use crate::application::routes::support::{
|
use crate::application::routes::support::{
|
||||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
|
FlexiblePayload, ListQuery, PayloadSource, impl_has_changes, is_datastar_request,
|
||||||
|
validate_update,
|
||||||
};
|
};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::bags::{BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
|
use crate::domain::bags::{BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
|
||||||
|
|
@ -156,6 +157,17 @@ impl UpdateBagSubmission {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl_has_changes!(
|
||||||
|
UpdateBag,
|
||||||
|
roast_id,
|
||||||
|
roast_date,
|
||||||
|
amount,
|
||||||
|
remaining,
|
||||||
|
closed,
|
||||||
|
finished_at,
|
||||||
|
created_at
|
||||||
|
);
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user, headers, query, payload))]
|
#[tracing::instrument(skip(state, _auth_user, headers, query, payload))]
|
||||||
pub(crate) async fn update_bag(
|
pub(crate) async fn update_bag(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -180,6 +192,8 @@ pub(crate) async fn update_bag(
|
||||||
created_at: body_update.created_at,
|
created_at: body_update.created_at,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
validate_update(&update, image_data_url.as_ref())?;
|
||||||
|
|
||||||
// When the bag amount changes, recompute remaining based on how much has been consumed.
|
// When the bag amount changes, recompute remaining based on how much has been consumed.
|
||||||
if let Some(new_amount) = update.amount
|
if let Some(new_amount) = update.amount
|
||||||
&& update.remaining.is_none()
|
&& update.remaining.is_none()
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ use crate::application::errors::{ApiError, AppError};
|
||||||
use crate::application::routes::api::images::save_deferred_image;
|
use crate::application::routes::api::images::save_deferred_image;
|
||||||
use crate::application::routes::api::macros::{define_delete_handler, define_enriched_get_handler};
|
use crate::application::routes::api::macros::{define_delete_handler, define_enriched_get_handler};
|
||||||
use crate::application::routes::support::{
|
use crate::application::routes::support::{
|
||||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request,
|
FlexiblePayload, ListQuery, PayloadSource, impl_has_changes, is_datastar_request,
|
||||||
|
validate_update,
|
||||||
};
|
};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::bags::BagFilter;
|
use crate::domain::bags::BagFilter;
|
||||||
|
|
@ -375,6 +376,21 @@ impl UpdateBrewSubmission {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl_has_changes!(
|
||||||
|
UpdateBrew,
|
||||||
|
bag_id,
|
||||||
|
coffee_weight,
|
||||||
|
grinder_id,
|
||||||
|
grind_setting,
|
||||||
|
brewer_id,
|
||||||
|
filter_paper_id,
|
||||||
|
water_volume,
|
||||||
|
water_temp,
|
||||||
|
quick_notes,
|
||||||
|
brew_time,
|
||||||
|
created_at
|
||||||
|
);
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
||||||
pub(crate) async fn update_brew(
|
pub(crate) async fn update_brew(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -386,22 +402,7 @@ pub(crate) async fn update_brew(
|
||||||
let (submission, source) = payload.into_parts();
|
let (submission, source) = payload.into_parts();
|
||||||
let (update, image_data_url) = submission.into_parts();
|
let (update, image_data_url) = submission.into_parts();
|
||||||
|
|
||||||
let has_changes = update.bag_id.is_some()
|
validate_update(&update, image_data_url.as_ref())?;
|
||||||
|| update.coffee_weight.is_some()
|
|
||||||
|| update.grinder_id.is_some()
|
|
||||||
|| update.grind_setting.is_some()
|
|
||||||
|| update.brewer_id.is_some()
|
|
||||||
|| update.filter_paper_id.is_some()
|
|
||||||
|| update.water_volume.is_some()
|
|
||||||
|| update.water_temp.is_some()
|
|
||||||
|| update.quick_notes.is_some()
|
|
||||||
|| update.brew_time.is_some()
|
|
||||||
|| update.created_at.is_some()
|
|
||||||
|| image_data_url.is_some();
|
|
||||||
|
|
||||||
if !has_changes {
|
|
||||||
return Err(AppError::validation("no changes provided").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
state
|
state
|
||||||
.brew_repo
|
.brew_repo
|
||||||
|
|
@ -414,12 +415,6 @@ pub(crate) async fn update_brew(
|
||||||
|
|
||||||
save_deferred_image(&state, "brew", i64::from(id), image_data_url.as_deref()).await;
|
save_deferred_image(&state, "brew", i64::from(id), image_data_url.as_deref()).await;
|
||||||
|
|
||||||
let enriched = state
|
|
||||||
.brew_repo
|
|
||||||
.get_with_details(id)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
let detail_url = format!("/brews/{id}");
|
let detail_url = format!("/brews/{id}");
|
||||||
|
|
||||||
if is_datastar_request(&headers) {
|
if is_datastar_request(&headers) {
|
||||||
|
|
@ -428,6 +423,11 @@ pub(crate) async fn update_brew(
|
||||||
} else if matches!(source, PayloadSource::Form) {
|
} else if matches!(source, PayloadSource::Form) {
|
||||||
Ok(Redirect::to(&detail_url).into_response())
|
Ok(Redirect::to(&detail_url).into_response())
|
||||||
} else {
|
} else {
|
||||||
|
let enriched = state
|
||||||
|
.brew_repo
|
||||||
|
.get_with_details(id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
Ok(Json(enriched).into_response())
|
Ok(Json(enriched).into_response())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ use crate::application::routes::api::images::save_deferred_image;
|
||||||
use crate::application::routes::api::macros::{
|
use crate::application::routes::api::macros::{
|
||||||
define_delete_handler, define_get_handler, define_list_fragment_renderer,
|
define_delete_handler, define_get_handler, define_list_fragment_renderer,
|
||||||
};
|
};
|
||||||
|
use crate::application::routes::support::impl_has_changes;
|
||||||
use crate::application::routes::support::{
|
use crate::application::routes::support::{
|
||||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, render_redirect_script,
|
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, render_redirect_script,
|
||||||
|
update_response, validate_update,
|
||||||
};
|
};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe};
|
use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe};
|
||||||
|
|
@ -175,6 +177,10 @@ impl UpdateCafeSubmission {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl_has_changes!(
|
||||||
|
UpdateCafe, name, city, country, latitude, longitude, website, created_at
|
||||||
|
);
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
||||||
pub(crate) async fn update_cafe(
|
pub(crate) async fn update_cafe(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -186,18 +192,7 @@ pub(crate) async fn update_cafe(
|
||||||
let (submission, source) = payload.into_parts();
|
let (submission, source) = payload.into_parts();
|
||||||
let (update, image_data_url) = submission.into_parts();
|
let (update, image_data_url) = submission.into_parts();
|
||||||
|
|
||||||
let has_changes = update.name.is_some()
|
validate_update(&update, image_data_url.as_ref())?;
|
||||||
|| update.city.is_some()
|
|
||||||
|| update.country.is_some()
|
|
||||||
|| update.latitude.is_some()
|
|
||||||
|| update.longitude.is_some()
|
|
||||||
|| update.website.is_some()
|
|
||||||
|| update.created_at.is_some()
|
|
||||||
|| image_data_url.is_some();
|
|
||||||
|
|
||||||
if !has_changes {
|
|
||||||
return Err(AppError::validation("no changes provided").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let cafe = state
|
let cafe = state
|
||||||
.cafe_repo
|
.cafe_repo
|
||||||
|
|
@ -216,14 +211,7 @@ pub(crate) async fn update_cafe(
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let detail_url = format!("/cafes/{}", cafe.slug);
|
let detail_url = format!("/cafes/{}", cafe.slug);
|
||||||
|
update_response(&headers, source, &detail_url, Json(cafe).into_response())
|
||||||
if is_datastar_request(&headers) {
|
|
||||||
render_redirect_script(&detail_url).map_err(ApiError::from)
|
|
||||||
} else if matches!(source, PayloadSource::Form) {
|
|
||||||
Ok(Redirect::to(&detail_url).into_response())
|
|
||||||
} else {
|
|
||||||
Ok(Json(cafe).into_response())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
define_delete_handler!(
|
define_delete_handler!(
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,10 @@ use crate::application::routes::api::images::save_deferred_image;
|
||||||
use crate::application::routes::api::macros::{
|
use crate::application::routes::api::macros::{
|
||||||
define_delete_handler, define_enriched_get_handler, define_list_fragment_renderer,
|
define_delete_handler, define_enriched_get_handler, define_list_fragment_renderer,
|
||||||
};
|
};
|
||||||
|
use crate::application::routes::support::impl_has_changes;
|
||||||
use crate::application::routes::support::{
|
use crate::application::routes::support::{
|
||||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, render_redirect_script,
|
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, render_redirect_script,
|
||||||
|
validate_update,
|
||||||
};
|
};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::cups::{CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup};
|
use crate::domain::cups::{CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup};
|
||||||
|
|
@ -116,6 +118,8 @@ impl UpdateCupSubmission {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl_has_changes!(UpdateCup, roast_id, cafe_id, created_at);
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
||||||
pub(crate) async fn update_cup(
|
pub(crate) async fn update_cup(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -127,14 +131,7 @@ pub(crate) async fn update_cup(
|
||||||
let (submission, source) = payload.into_parts();
|
let (submission, source) = payload.into_parts();
|
||||||
let (update, image_data_url) = submission.into_parts();
|
let (update, image_data_url) = submission.into_parts();
|
||||||
|
|
||||||
let has_changes = update.roast_id.is_some()
|
validate_update(&update, image_data_url.as_ref())?;
|
||||||
|| update.cafe_id.is_some()
|
|
||||||
|| update.created_at.is_some()
|
|
||||||
|| image_data_url.is_some();
|
|
||||||
|
|
||||||
if !has_changes {
|
|
||||||
return Err(AppError::validation("no changes provided").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let cup = state
|
let cup = state
|
||||||
.cup_repo
|
.cup_repo
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,10 @@ use crate::application::routes::api::images::save_deferred_image;
|
||||||
use crate::application::routes::api::macros::{
|
use crate::application::routes::api::macros::{
|
||||||
define_delete_handler, define_get_handler, define_list_fragment_renderer,
|
define_delete_handler, define_get_handler, define_list_fragment_renderer,
|
||||||
};
|
};
|
||||||
|
use crate::application::routes::support::impl_has_changes;
|
||||||
use crate::application::routes::support::{
|
use crate::application::routes::support::{
|
||||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, render_redirect_script,
|
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, render_redirect_script,
|
||||||
|
update_response, validate_update,
|
||||||
};
|
};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear, UpdateGear};
|
use crate::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear, UpdateGear};
|
||||||
|
|
@ -148,6 +150,8 @@ impl UpdateGearSubmission {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl_has_changes!(UpdateGear, make, model, created_at);
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
||||||
pub(crate) async fn update_gear(
|
pub(crate) async fn update_gear(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -159,14 +163,7 @@ pub(crate) async fn update_gear(
|
||||||
let (submission, source) = payload.into_parts();
|
let (submission, source) = payload.into_parts();
|
||||||
let (update, image_data_url) = submission.into_parts();
|
let (update, image_data_url) = submission.into_parts();
|
||||||
|
|
||||||
let has_changes = update.make.is_some()
|
validate_update(&update, image_data_url.as_ref())?;
|
||||||
|| update.model.is_some()
|
|
||||||
|| update.created_at.is_some()
|
|
||||||
|| image_data_url.is_some();
|
|
||||||
|
|
||||||
if !has_changes {
|
|
||||||
return Err(AppError::validation("no changes provided").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let gear = state
|
let gear = state
|
||||||
.gear_repo
|
.gear_repo
|
||||||
|
|
@ -186,14 +183,7 @@ pub(crate) async fn update_gear(
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let detail_url = format!("/gear/{}", gear.id);
|
let detail_url = format!("/gear/{}", gear.id);
|
||||||
|
update_response(&headers, source, &detail_url, Json(gear).into_response())
|
||||||
if is_datastar_request(&headers) {
|
|
||||||
render_redirect_script(&detail_url).map_err(ApiError::from)
|
|
||||||
} else if matches!(source, PayloadSource::Form) {
|
|
||||||
Ok(Redirect::to(&detail_url).into_response())
|
|
||||||
} else {
|
|
||||||
Ok(Json(gear).into_response())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
define_delete_handler!(
|
define_delete_handler!(
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,10 @@ use crate::application::routes::api::images::save_deferred_image;
|
||||||
use crate::application::routes::api::macros::{
|
use crate::application::routes::api::macros::{
|
||||||
define_delete_handler, define_get_handler, define_list_fragment_renderer,
|
define_delete_handler, define_get_handler, define_list_fragment_renderer,
|
||||||
};
|
};
|
||||||
|
use crate::application::routes::support::impl_has_changes;
|
||||||
use crate::application::routes::support::{
|
use crate::application::routes::support::{
|
||||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, render_redirect_script,
|
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, render_redirect_script,
|
||||||
|
update_response, validate_update,
|
||||||
};
|
};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::ids::RoasterId;
|
use crate::domain::ids::RoasterId;
|
||||||
|
|
@ -168,6 +170,8 @@ impl UpdateRoasterSubmission {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl_has_changes!(UpdateRoaster, name, country, city, homepage, created_at);
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
||||||
pub(crate) async fn update_roaster(
|
pub(crate) async fn update_roaster(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -180,16 +184,7 @@ pub(crate) async fn update_roaster(
|
||||||
let (update, image_data_url) = submission.into_parts();
|
let (update, image_data_url) = submission.into_parts();
|
||||||
let update = update.normalize();
|
let update = update.normalize();
|
||||||
|
|
||||||
let has_changes = update.name.is_some()
|
validate_update(&update, image_data_url.as_ref())?;
|
||||||
|| update.country.is_some()
|
|
||||||
|| update.city.is_some()
|
|
||||||
|| update.homepage.is_some()
|
|
||||||
|| update.created_at.is_some()
|
|
||||||
|| image_data_url.is_some();
|
|
||||||
|
|
||||||
if !has_changes {
|
|
||||||
return Err(AppError::validation("no changes provided").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
let roaster = state
|
let roaster = state
|
||||||
.roaster_repo
|
.roaster_repo
|
||||||
|
|
@ -208,14 +203,7 @@ pub(crate) async fn update_roaster(
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let detail_url = format!("/roasters/{}", roaster.slug);
|
let detail_url = format!("/roasters/{}", roaster.slug);
|
||||||
|
update_response(&headers, source, &detail_url, Json(roaster).into_response())
|
||||||
if is_datastar_request(&headers) {
|
|
||||||
render_redirect_script(&detail_url).map_err(ApiError::from)
|
|
||||||
} else if matches!(source, PayloadSource::Form) {
|
|
||||||
Ok(Redirect::to(&detail_url).into_response())
|
|
||||||
} else {
|
|
||||||
Ok(Json(roaster).into_response())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
define_delete_handler!(
|
define_delete_handler!(
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ use crate::application::routes::api::macros::{
|
||||||
define_delete_handler, define_enriched_get_handler, define_list_fragment_renderer,
|
define_delete_handler, define_enriched_get_handler, define_list_fragment_renderer,
|
||||||
};
|
};
|
||||||
use crate::application::routes::support::{
|
use crate::application::routes::support::{
|
||||||
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, render_redirect_script,
|
FlexiblePayload, ListQuery, PayloadSource, impl_has_changes, is_datastar_request,
|
||||||
|
render_redirect_script, validate_update,
|
||||||
};
|
};
|
||||||
use crate::application::state::AppState;
|
use crate::application::state::AppState;
|
||||||
use crate::domain::ids::{RoastId, RoasterId};
|
use crate::domain::ids::{RoastId, RoasterId};
|
||||||
|
|
@ -214,6 +215,18 @@ impl UpdateRoastSubmission {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl_has_changes!(
|
||||||
|
UpdateRoast,
|
||||||
|
roaster_id,
|
||||||
|
name,
|
||||||
|
origin,
|
||||||
|
region,
|
||||||
|
producer,
|
||||||
|
tasting_notes,
|
||||||
|
process,
|
||||||
|
created_at
|
||||||
|
);
|
||||||
|
|
||||||
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
#[tracing::instrument(skip(state, _auth_user, headers, payload))]
|
||||||
pub(crate) async fn update_roast(
|
pub(crate) async fn update_roast(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|
@ -225,19 +238,7 @@ pub(crate) async fn update_roast(
|
||||||
let (submission, source) = payload.into_parts();
|
let (submission, source) = payload.into_parts();
|
||||||
let (update, image_data_url) = submission.into_parts();
|
let (update, image_data_url) = submission.into_parts();
|
||||||
|
|
||||||
let has_changes = update.roaster_id.is_some()
|
validate_update(&update, image_data_url.as_ref())?;
|
||||||
|| update.name.is_some()
|
|
||||||
|| update.origin.is_some()
|
|
||||||
|| update.region.is_some()
|
|
||||||
|| update.producer.is_some()
|
|
||||||
|| update.tasting_notes.is_some()
|
|
||||||
|| update.process.is_some()
|
|
||||||
|| update.created_at.is_some()
|
|
||||||
|| image_data_url.is_some();
|
|
||||||
|
|
||||||
if !has_changes {
|
|
||||||
return Err(AppError::validation("no changes provided").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
state
|
state
|
||||||
.roast_repo
|
.roast_repo
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::extract::{Form, FromRequest, Json as JsonPayload, Request};
|
use axum::extract::{Form, FromRequest, Json as JsonPayload, Request};
|
||||||
use axum::http::{HeaderMap, HeaderValue, header::CONTENT_TYPE};
|
use axum::http::{HeaderMap, HeaderValue, header::CONTENT_TYPE};
|
||||||
use axum::response::{Html, IntoResponse, Response};
|
use axum::response::{Html, IntoResponse, Redirect, Response};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
|
|
@ -32,6 +32,50 @@ impl<T> FlexiblePayload<T> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Trait for update structs that can report whether any field was set.
|
||||||
|
pub(crate) trait HasChanges {
|
||||||
|
fn has_changes(&self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implement `HasChanges` for an update struct by checking `is_some()` on each field.
|
||||||
|
macro_rules! impl_has_changes {
|
||||||
|
($type:ty, $($field:ident),+) => {
|
||||||
|
impl $crate::application::routes::support::HasChanges for $type {
|
||||||
|
fn has_changes(&self) -> bool {
|
||||||
|
$(self.$field.is_some())||+
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
pub(crate) use impl_has_changes;
|
||||||
|
|
||||||
|
/// Return `400 Bad Request` if neither the update struct nor the image has any changes.
|
||||||
|
pub(crate) fn validate_update<T: HasChanges>(
|
||||||
|
update: &T,
|
||||||
|
image: Option<&String>,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
if !update.has_changes() && image.is_none() {
|
||||||
|
return Err(AppError::validation("no changes provided").into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three-way response for update handlers: Datastar redirect, form redirect, or JSON.
|
||||||
|
pub(crate) fn update_response(
|
||||||
|
headers: &HeaderMap,
|
||||||
|
source: PayloadSource,
|
||||||
|
detail_url: &str,
|
||||||
|
json_body: Response,
|
||||||
|
) -> Result<Response, ApiError> {
|
||||||
|
if is_datastar_request(headers) {
|
||||||
|
render_redirect_script(detail_url).map_err(ApiError::from)
|
||||||
|
} else if matches!(source, PayloadSource::Form) {
|
||||||
|
Ok(Redirect::to(detail_url).into_response())
|
||||||
|
} else {
|
||||||
|
Ok(json_body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
pub(crate) struct ListQuery {
|
pub(crate) struct ListQuery {
|
||||||
page: Option<u32>,
|
page: Option<u32>,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
|
{% import "partials/detail_cards.html" as detail_cards %}
|
||||||
{% block title %}Brewlog · Edit Bag{% endblock %}
|
{% block title %}Brewlog · Edit Bag{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
@ -75,35 +76,7 @@
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p
|
{{ detail_cards::edit_form_actions() }}
|
||||||
data-show="$_submitError"
|
|
||||||
data-text="$_submitError"
|
|
||||||
style="display:none"
|
|
||||||
class="text-sm text-error"
|
|
||||||
role="alert"
|
|
||||||
></p>
|
|
||||||
<div
|
|
||||||
data-show="$_submitting"
|
|
||||||
style="display:none"
|
|
||||||
class="flex items-center justify-center gap-3 py-2 text-sm text-accent"
|
|
||||||
>
|
|
||||||
{{ icons::spinner("h-5 w-5") }} Saving…
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2" data-show="!$_submitting">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ icons::check("h-4 w-4") }} Save Changes
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="history.back()"
|
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
|
|
||||||
>
|
|
||||||
{{ icons::x_mark("h-4 w-4") }} Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
{% import "partials/image_section.html" as img %}
|
{% import "partials/image_section.html" as img %}
|
||||||
|
{% import "partials/detail_cards.html" as detail_cards %}
|
||||||
{% block title %}Brewlog · Edit Brew{% endblock %}
|
{% block title %}Brewlog · Edit Brew{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
@ -361,35 +362,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ img::deferred_upload_with_preview("edit-brew-image", "Brew Image", "brew", id, image_url) }}
|
{{ img::deferred_upload_with_preview("edit-brew-image", "Brew Image", "brew", id, image_url) }}
|
||||||
<p
|
{{ detail_cards::edit_form_actions() }}
|
||||||
data-show="$_submitError"
|
|
||||||
data-text="$_submitError"
|
|
||||||
style="display:none"
|
|
||||||
class="text-sm text-error"
|
|
||||||
role="alert"
|
|
||||||
></p>
|
|
||||||
<div
|
|
||||||
data-show="$_submitting"
|
|
||||||
style="display:none"
|
|
||||||
class="flex items-center justify-center gap-3 py-2 text-sm text-accent"
|
|
||||||
>
|
|
||||||
{{ icons::spinner("h-5 w-5") }} Saving…
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2" data-show="!$_submitting">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ icons::check("h-4 w-4") }} Save Changes
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="history.back()"
|
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
|
|
||||||
>
|
|
||||||
{{ icons::x_mark("h-4 w-4") }} Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
{% import "partials/image_section.html" as img %}
|
{% import "partials/image_section.html" as img %}
|
||||||
|
{% import "partials/detail_cards.html" as detail_cards %}
|
||||||
{% block title %}Brewlog · Edit Cafe{% endblock %}
|
{% block title %}Brewlog · Edit Cafe{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
@ -118,35 +119,7 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{{ img::deferred_upload_with_preview("edit-cafe-image", "Cafe Image", "cafe", id, image_url) }}
|
{{ img::deferred_upload_with_preview("edit-cafe-image", "Cafe Image", "cafe", id, image_url) }}
|
||||||
<p
|
{{ detail_cards::edit_form_actions() }}
|
||||||
data-show="$_submitError"
|
|
||||||
data-text="$_submitError"
|
|
||||||
style="display:none"
|
|
||||||
class="text-sm text-error"
|
|
||||||
role="alert"
|
|
||||||
></p>
|
|
||||||
<div
|
|
||||||
data-show="$_submitting"
|
|
||||||
style="display:none"
|
|
||||||
class="flex items-center justify-center gap-3 py-2 text-sm text-accent"
|
|
||||||
>
|
|
||||||
{{ icons::spinner("h-5 w-5") }} Saving…
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2" data-show="!$_submitting">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ icons::check("h-4 w-4") }} Save Changes
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="history.back()"
|
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
|
|
||||||
>
|
|
||||||
{{ icons::x_mark("h-4 w-4") }} Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
{% import "partials/image_section.html" as img %}
|
{% import "partials/image_section.html" as img %}
|
||||||
|
{% import "partials/detail_cards.html" as detail_cards %}
|
||||||
{% block title %}Brewlog · Edit Cup{% endblock %}
|
{% block title %}Brewlog · Edit Cup{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
@ -72,35 +73,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ img::deferred_upload_with_preview("edit-cup-image", "Cup Image", "cup", id, image_url) }}
|
{{ img::deferred_upload_with_preview("edit-cup-image", "Cup Image", "cup", id, image_url) }}
|
||||||
<p
|
{{ detail_cards::edit_form_actions() }}
|
||||||
data-show="$_submitError"
|
|
||||||
data-text="$_submitError"
|
|
||||||
style="display:none"
|
|
||||||
class="text-sm text-error"
|
|
||||||
role="alert"
|
|
||||||
></p>
|
|
||||||
<div
|
|
||||||
data-show="$_submitting"
|
|
||||||
style="display:none"
|
|
||||||
class="flex items-center justify-center gap-3 py-2 text-sm text-accent"
|
|
||||||
>
|
|
||||||
{{ icons::spinner("h-5 w-5") }} Saving…
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2" data-show="!$_submitting">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ icons::check("h-4 w-4") }} Save Changes
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="history.back()"
|
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
|
|
||||||
>
|
|
||||||
{{ icons::x_mark("h-4 w-4") }} Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
{% import "partials/image_section.html" as img %}
|
{% import "partials/image_section.html" as img %}
|
||||||
|
{% import "partials/detail_cards.html" as detail_cards %}
|
||||||
{% block title %}Brewlog · Edit Gear{% endblock %}
|
{% block title %}Brewlog · Edit Gear{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
@ -64,35 +65,7 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{{ img::deferred_upload_with_preview("edit-gear-image", "Gear Image", "gear", id, image_url) }}
|
{{ img::deferred_upload_with_preview("edit-gear-image", "Gear Image", "gear", id, image_url) }}
|
||||||
<p
|
{{ detail_cards::edit_form_actions() }}
|
||||||
data-show="$_submitError"
|
|
||||||
data-text="$_submitError"
|
|
||||||
style="display:none"
|
|
||||||
class="text-sm text-error"
|
|
||||||
role="alert"
|
|
||||||
></p>
|
|
||||||
<div
|
|
||||||
data-show="$_submitting"
|
|
||||||
style="display:none"
|
|
||||||
class="flex items-center justify-center gap-3 py-2 text-sm text-accent"
|
|
||||||
>
|
|
||||||
{{ icons::spinner("h-5 w-5") }} Saving…
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2" data-show="!$_submitting">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ icons::check("h-4 w-4") }} Save Changes
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="history.back()"
|
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
|
|
||||||
>
|
|
||||||
{{ icons::x_mark("h-4 w-4") }} Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
{% import "partials/image_section.html" as img %}
|
{% import "partials/image_section.html" as img %}
|
||||||
|
{% import "partials/detail_cards.html" as detail_cards %}
|
||||||
{% block title %}Brewlog · Edit Roast{% endblock %}
|
{% block title %}Brewlog · Edit Roast{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
@ -140,35 +141,7 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{{ img::deferred_upload_with_preview("edit-roast-image", "Roast Image", "roast", id, image_url) }}
|
{{ img::deferred_upload_with_preview("edit-roast-image", "Roast Image", "roast", id, image_url) }}
|
||||||
<p
|
{{ detail_cards::edit_form_actions() }}
|
||||||
data-show="$_submitError"
|
|
||||||
data-text="$_submitError"
|
|
||||||
style="display:none"
|
|
||||||
class="text-sm text-error"
|
|
||||||
role="alert"
|
|
||||||
></p>
|
|
||||||
<div
|
|
||||||
data-show="$_submitting"
|
|
||||||
style="display:none"
|
|
||||||
class="flex items-center justify-center gap-3 py-2 text-sm text-accent"
|
|
||||||
>
|
|
||||||
{{ icons::spinner("h-5 w-5") }} Saving…
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2" data-show="!$_submitting">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ icons::check("h-4 w-4") }} Save Changes
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="history.back()"
|
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
|
|
||||||
>
|
|
||||||
{{ icons::x_mark("h-4 w-4") }} Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% import "partials/icons.html" as icons %}
|
{% import "partials/icons.html" as icons %}
|
||||||
{% import "partials/image_section.html" as img %}
|
{% import "partials/image_section.html" as img %}
|
||||||
|
{% import "partials/detail_cards.html" as detail_cards %}
|
||||||
{% block title %}Brewlog · Edit Roaster{% endblock %}
|
{% block title %}Brewlog · Edit Roaster{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
@ -82,35 +83,7 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{{ img::deferred_upload_with_preview("edit-roaster-image", "Roaster Image", "roaster", id, image_url) }}
|
{{ img::deferred_upload_with_preview("edit-roaster-image", "Roaster Image", "roaster", id, image_url) }}
|
||||||
<p
|
{{ detail_cards::edit_form_actions() }}
|
||||||
data-show="$_submitError"
|
|
||||||
data-text="$_submitError"
|
|
||||||
style="display:none"
|
|
||||||
class="text-sm text-error"
|
|
||||||
role="alert"
|
|
||||||
></p>
|
|
||||||
<div
|
|
||||||
data-show="$_submitting"
|
|
||||||
style="display:none"
|
|
||||||
class="flex items-center justify-center gap-3 py-2 text-sm text-accent"
|
|
||||||
>
|
|
||||||
{{ icons::spinner("h-5 w-5") }} Saving…
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2" data-show="!$_submitting">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{{ icons::check("h-4 w-4") }} Save Changes
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="history.back()"
|
|
||||||
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
|
|
||||||
>
|
|
||||||
{{ icons::x_mark("h-4 w-4") }} Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,38 @@
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro edit_form_actions() %}
|
||||||
|
<p
|
||||||
|
data-show="$_submitError"
|
||||||
|
data-text="$_submitError"
|
||||||
|
style="display:none"
|
||||||
|
class="text-sm text-error"
|
||||||
|
role="alert"
|
||||||
|
></p>
|
||||||
|
<div
|
||||||
|
data-show="$_submitting"
|
||||||
|
style="display:none"
|
||||||
|
class="flex items-center justify-center gap-3 py-2 text-sm text-accent"
|
||||||
|
>
|
||||||
|
{{ icons::spinner("h-5 w-5") }} Saving…
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2" data-show="!$_submitting">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ icons::check("h-4 w-4") }} Save Changes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="history.back()"
|
||||||
|
class="inline-flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
|
||||||
|
>
|
||||||
|
{{ icons::x_mark("h-4 w-4") }} Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro edit_delete_buttons(edit_url, entity_label, api_path, id) %}
|
{% macro edit_delete_buttons(edit_url, entity_label, api_path, id) %}
|
||||||
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
|
<div class="rounded-lg border bg-surface p-5 flex items-center gap-2">
|
||||||
<a
|
<a
|
||||||
|
|
|
||||||
|
|
@ -662,7 +662,7 @@ async fn edit_form_cancel_navigates_back() {
|
||||||
// Click Cancel
|
// Click Cancel
|
||||||
let cancel_btn = session
|
let cancel_btn = session
|
||||||
.driver
|
.driver
|
||||||
.find(By::XPath("//button[contains(text(), 'Cancel')]"))
|
.find(By::Css("button[onclick='history.back()']"))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
cancel_btn.click().await.unwrap();
|
cancel_btn.click().await.unwrap();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue