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:
Jon Seager 2026-02-10 20:04:24 +00:00
parent 8ced58039b
commit 2cd579a574
No known key found for this signature in database
17 changed files with 170 additions and 305 deletions

View file

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

View file

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

View file

@ -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!(

View file

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

View file

@ -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!(

View file

@ -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!(

View file

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

View file

@ -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>,

View file

@ -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&hellip;
</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 %}

View file

@ -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&hellip;
</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 %}

View file

@ -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&hellip;
</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 %}

View file

@ -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&hellip;
</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 %}

View file

@ -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&hellip;
</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 %}

View file

@ -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&hellip;
</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 %}

View file

@ -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&hellip;
</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 %}

View file

@ -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&hellip;
</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

View file

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