refactor(routes): extract form option helpers and fragment renderer macro

- Add load_roaster_options, load_roast_options, load_cafe_options
  helpers to support.rs, replacing duplicated inline loading in
  roasts.rs, bags.rs, and cups.rs
- Add define_list_fragment_renderer! macro for the 5 structurally
  identical render_*_list_fragment functions
- Fix gear.rs to return navigator from load_gear_page like all other
  loaders instead of discarding and reconstructing it
This commit is contained in:
Jon Seager 2026-02-03 16:49:36 +00:00
parent 4b55d1b65c
commit 714713827d
No known key found for this signature in database
8 changed files with 132 additions and 135 deletions

View file

@ -9,16 +9,15 @@ use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError, map_app_error}; use crate::application::errors::{ApiError, AppError, map_app_error};
use crate::application::routes::render_html; use crate::application::routes::render_html;
use crate::application::routes::support::{ use crate::application::routes::support::{
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, load_roaster_options,
}; };
use crate::application::server::AppState; use crate::application::server::AppState;
use crate::domain::bags::{BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag}; use crate::domain::bags::{BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
use crate::domain::ids::{BagId, RoastId}; use crate::domain::ids::{BagId, RoastId};
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasters::RoasterSortKey;
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail}; use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
use crate::presentation::web::templates::{BagListTemplate, BagsTemplate}; use crate::presentation::web::templates::{BagListTemplate, BagsTemplate};
use crate::presentation::web::views::{BagView, ListNavigator, Paginated, RoasterOptionView}; use crate::presentation::web::views::{BagView, ListNavigator, Paginated};
const BAG_PAGE_PATH: &str = "/bags"; const BAG_PAGE_PATH: &str = "/bags";
const BAG_FRAGMENT_PATH: &str = "/bags#bag-list"; const BAG_FRAGMENT_PATH: &str = "/bags#bag-list";
@ -85,13 +84,7 @@ pub(crate) async fn bags_page(
.map_err(map_app_error); .map_err(map_app_error);
} }
let roasters = state let roaster_options = load_roaster_options(&state).await.map_err(map_app_error)?;
.roaster_repo
.list_all_sorted(RoasterSortKey::Name, SortDirection::Asc)
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let roaster_options = roasters.into_iter().map(RoasterOptionView::from).collect();
let BagPageData { let BagPageData {
open_bags, open_bags,

View file

@ -4,7 +4,7 @@ use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use serde::Deserialize; use serde::Deserialize;
use super::macros::{define_delete_handler, define_get_handler}; use super::macros::{define_delete_handler, define_get_handler, define_list_fragment_renderer};
use crate::application::auth::AuthenticatedUser; use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError, map_app_error}; use crate::application::errors::{ApiError, AppError, map_app_error};
use crate::application::routes::render_html; use crate::application::routes::render_html;
@ -177,22 +177,13 @@ define_delete_handler!(
render_cafe_list_fragment render_cafe_list_fragment
); );
async fn render_cafe_list_fragment( define_list_fragment_renderer!(
state: AppState, render_cafe_list_fragment,
request: ListRequest<CafeSortKey>, CafeSortKey,
search: Option<String>, load_cafe_page,
is_authenticated: bool, CafeListTemplate { cafes },
) -> Result<Response, AppError> { "#cafe-list"
let (cafes, navigator) = load_cafe_page(&state, request, search.as_deref()).await?; );
let template = CafeListTemplate {
is_authenticated,
cafes,
navigator,
};
crate::application::routes::support::render_fragment(template, "#cafe-list")
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct NearbyQuery { pub struct NearbyQuery {

View file

@ -3,22 +3,22 @@ use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use super::macros::{define_delete_handler, define_enriched_get_handler}; use super::macros::{
define_delete_handler, define_enriched_get_handler, define_list_fragment_renderer,
};
use crate::application::auth::AuthenticatedUser; use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError, map_app_error}; use crate::application::errors::{ApiError, AppError, map_app_error};
use crate::application::routes::render_html; use crate::application::routes::render_html;
use crate::application::routes::support::{ use crate::application::routes::support::{
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, load_cafe_options,
load_roast_options,
}; };
use crate::application::server::AppState; use crate::application::server::AppState;
use crate::domain::cafes::CafeSortKey;
use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup}; use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup};
use crate::domain::ids::CupId; use crate::domain::ids::CupId;
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::presentation::web::templates::{CupListTemplate, CupsTemplate}; use crate::presentation::web::templates::{CupListTemplate, CupsTemplate};
use crate::presentation::web::views::{ use crate::presentation::web::views::{CupView, ListNavigator, Paginated};
CafeOptionView, CupView, ListNavigator, Paginated, RoastOptionView,
};
const CUP_PAGE_PATH: &str = "/cups"; const CUP_PAGE_PATH: &str = "/cups";
const CUP_FRAGMENT_PATH: &str = "/cups#cup-list"; const CUP_FRAGMENT_PATH: &str = "/cups#cup-list";
@ -67,23 +67,9 @@ pub(crate) async fn cups_page(
let is_authenticated = super::is_authenticated(&state, &cookies).await; let is_authenticated = super::is_authenticated(&state, &cookies).await;
let roast_options: Vec<RoastOptionView> = state let roast_options = load_roast_options(&state).await.map_err(map_app_error)?;
.roast_repo
.list_all()
.await
.map_err(|e| map_app_error(AppError::from(e)))?
.into_iter()
.map(RoastOptionView::from)
.collect();
let cafe_options: Vec<CafeOptionView> = state let cafe_options = load_cafe_options(&state).await.map_err(map_app_error)?;
.cafe_repo
.list_all_sorted(CafeSortKey::Name, SortDirection::Asc)
.await
.map_err(|e| map_app_error(AppError::from(e)))?
.into_iter()
.map(CafeOptionView::from)
.collect();
let template = CupsTemplate { let template = CupsTemplate {
nav_active: "cups", nav_active: "cups",
@ -183,19 +169,10 @@ define_delete_handler!(
render_cup_list_fragment render_cup_list_fragment
); );
async fn render_cup_list_fragment( define_list_fragment_renderer!(
state: AppState, render_cup_list_fragment,
request: ListRequest<CupSortKey>, CupSortKey,
search: Option<String>, load_cup_page,
is_authenticated: bool, CupListTemplate { cups },
) -> Result<Response, AppError> { "#cup-list"
let (cups, navigator) = load_cup_page(&state, request, search.as_deref()).await?; );
let template = CupListTemplate {
is_authenticated,
cups,
navigator,
};
crate::application::routes::support::render_fragment(template, "#cup-list")
}

View file

@ -6,7 +6,7 @@ use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response}; use axum::response::{IntoResponse, Redirect, Response};
use serde::Deserialize; use serde::Deserialize;
use super::macros::{define_delete_handler, define_get_handler}; use super::macros::{define_delete_handler, define_get_handler, define_list_fragment_renderer};
use crate::application::auth::AuthenticatedUser; use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError, map_app_error}; use crate::application::errors::{ApiError, AppError, map_app_error};
use crate::application::routes::render_html; use crate::application::routes::render_html;
@ -29,23 +29,21 @@ async fn load_gear_page(
state: &AppState, state: &AppState,
request: ListRequest<GearSortKey>, request: ListRequest<GearSortKey>,
search: Option<&str>, search: Option<&str>,
) -> Result<Paginated<GearView>, AppError> { ) -> Result<(Paginated<GearView>, ListNavigator<GearSortKey>), AppError> {
let page = state let page = state
.gear_repo .gear_repo
.list(GearFilter::all(), &request, search) .list(GearFilter::all(), &request, search)
.await .await
.map_err(AppError::from)?; .map_err(AppError::from)?;
let (gear, _navigator) = crate::application::routes::support::build_page_view( Ok(crate::application::routes::support::build_page_view(
page, page,
request, request,
GearView::from_domain, GearView::from_domain,
GEAR_PAGE_PATH, GEAR_PAGE_PATH,
GEAR_FRAGMENT_PATH, GEAR_FRAGMENT_PATH,
search.map(String::from), search.map(String::from),
); ))
Ok(gear)
} }
#[tracing::instrument(skip(state, cookies, headers, query))] #[tracing::instrument(skip(state, cookies, headers, query))]
@ -64,12 +62,11 @@ pub(crate) async fn gear_page(
.map_err(map_app_error); .map_err(map_app_error);
} }
let gear = load_gear_page(&state, request, search.as_deref()) let (gear, navigator) = load_gear_page(&state, request, search.as_deref())
.await .await
.map_err(map_app_error)?; .map_err(map_app_error)?;
let is_authenticated = super::is_authenticated(&state, &cookies).await; let is_authenticated = super::is_authenticated(&state, &cookies).await;
let navigator = ListNavigator::new(GEAR_PAGE_PATH, GEAR_FRAGMENT_PATH, request, search);
let template = GearTemplate { let template = GearTemplate {
nav_active: "gear", nav_active: "gear",
@ -231,20 +228,10 @@ impl NewGearSubmission {
} }
} }
async fn render_gear_list_fragment( define_list_fragment_renderer!(
state: AppState, render_gear_list_fragment,
request: ListRequest<GearSortKey>, GearSortKey,
search: Option<String>, load_gear_page,
is_authenticated: bool, GearListTemplate { gear },
) -> Result<Response, AppError> { "#gear-list"
let gear = load_gear_page(&state, request, search.as_deref()).await?; );
let navigator = ListNavigator::new(GEAR_PAGE_PATH, GEAR_FRAGMENT_PATH, request, search);
let template = GearListTemplate {
is_authenticated,
gear,
navigator,
};
crate::application::routes::support::render_fragment(template, "#gear-list")
}

View file

@ -106,6 +106,48 @@ macro_rules! define_delete_handler {
}; };
} }
/// Generates a list-fragment renderer for Datastar partial updates.
///
/// Produces a function that loads a page via `$loader`, builds the given list
/// template, and returns it as a Datastar fragment targeting `$selector`.
///
/// # Arguments
/// * `$fn_name` - Name of the generated function
/// * `$sort_key` - Sort key type (e.g., `RoasterSortKey`)
/// * `$loader` - Page-loader function returning `(Paginated<V>, ListNavigator<K>)`
/// * `$template { $field }` - List template type and its items field name
/// * `$selector` - CSS selector for Datastar patching (e.g., `"#roaster-list"`)
///
/// # Example
/// ```ignore
/// define_list_fragment_renderer!(
/// render_roaster_list_fragment,
/// RoasterSortKey,
/// load_roaster_page,
/// RoasterListTemplate { roasters },
/// "#roaster-list"
/// );
/// ```
macro_rules! define_list_fragment_renderer {
($fn_name:ident, $sort_key:ty, $loader:ident, $template:ident { $field:ident }, $selector:literal) => {
async fn $fn_name(
state: crate::application::server::AppState,
request: crate::domain::listing::ListRequest<$sort_key>,
search: Option<String>,
is_authenticated: bool,
) -> Result<axum::response::Response, crate::application::errors::AppError> {
let (items, navigator) = $loader(&state, request, search.as_deref()).await?;
let template = $template {
is_authenticated,
$field: items,
navigator,
};
crate::application::routes::support::render_fragment(template, $selector)
}
};
}
pub(super) use define_delete_handler; pub(super) use define_delete_handler;
pub(super) use define_enriched_get_handler; pub(super) use define_enriched_get_handler;
pub(super) use define_get_handler; pub(super) use define_get_handler;
pub(super) use define_list_fragment_renderer;

View file

@ -3,7 +3,7 @@ use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode}; use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::response::{Html, IntoResponse, Redirect, Response};
use super::macros::{define_delete_handler, define_get_handler}; use super::macros::{define_delete_handler, define_get_handler, define_list_fragment_renderer};
use crate::application::auth::AuthenticatedUser; use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError, map_app_error}; use crate::application::errors::{ApiError, AppError, map_app_error};
use crate::application::routes::render_html; use crate::application::routes::render_html;
@ -183,19 +183,10 @@ define_delete_handler!(
render_roaster_list_fragment render_roaster_list_fragment
); );
async fn render_roaster_list_fragment( define_list_fragment_renderer!(
state: AppState, render_roaster_list_fragment,
request: ListRequest<RoasterSortKey>, RoasterSortKey,
search: Option<String>, load_roaster_page,
is_authenticated: bool, RoasterListTemplate { roasters },
) -> Result<Response, AppError> { "#roaster-list"
let (roasters, navigator) = load_roaster_page(&state, request, search.as_deref()).await?; );
let template = RoasterListTemplate {
is_authenticated,
roasters,
navigator,
};
crate::application::routes::support::render_fragment(template, "#roaster-list")
}

View file

@ -4,23 +4,24 @@ use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Redirect, Response}; use axum::response::{Html, IntoResponse, Redirect, Response};
use serde::Deserialize; use serde::Deserialize;
use super::macros::{define_delete_handler, define_enriched_get_handler}; use super::macros::{
define_delete_handler, define_enriched_get_handler, define_list_fragment_renderer,
};
use crate::application::auth::AuthenticatedUser; use crate::application::auth::AuthenticatedUser;
use crate::application::errors::{ApiError, AppError, map_app_error}; use crate::application::errors::{ApiError, AppError, map_app_error};
use crate::application::routes::render_html; use crate::application::routes::render_html;
use crate::application::routes::support::{ use crate::application::routes::support::{
FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, FlexiblePayload, ListQuery, PayloadSource, is_datastar_request, load_roaster_options,
}; };
use crate::application::server::AppState; use crate::application::server::AppState;
use crate::domain::bags::{BagFilter, BagSortKey}; use crate::domain::bags::{BagFilter, BagSortKey};
use crate::domain::ids::{RoastId, RoasterId}; use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::listing::{ListRequest, SortDirection}; use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasters::RoasterSortKey;
use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster, UpdateRoast}; use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster, UpdateRoast};
use crate::presentation::web::templates::{ use crate::presentation::web::templates::{
RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate, RoastDetailTemplate, RoastListTemplate, RoastOptionsTemplate, RoastsTemplate,
}; };
use crate::presentation::web::views::{ListNavigator, Paginated, RoastView, RoasterOptionView}; use crate::presentation::web::views::{ListNavigator, Paginated, RoastView};
const ROAST_PAGE_PATH: &str = "/roasts"; const ROAST_PAGE_PATH: &str = "/roasts";
const ROAST_FRAGMENT_PATH: &str = "/roasts#roast-list"; const ROAST_FRAGMENT_PATH: &str = "/roasts#roast-list";
@ -63,13 +64,7 @@ pub(crate) async fn roasts_page(
.map_err(map_app_error); .map_err(map_app_error);
} }
let roasters = state let roaster_options = load_roaster_options(&state).await.map_err(map_app_error)?;
.roaster_repo
.list_all_sorted(RoasterSortKey::Name, SortDirection::Asc)
.await
.map_err(|err| map_app_error(AppError::from(err)))?;
let roaster_options = roasters.into_iter().map(RoasterOptionView::from).collect();
let (roasts, navigator) = load_roast_page(&state, request, search.as_deref()) let (roasts, navigator) = load_roast_page(&state, request, search.as_deref())
.await .await
@ -343,19 +338,10 @@ impl TastingNotesInput {
} }
} }
async fn render_roast_list_fragment( define_list_fragment_renderer!(
state: AppState, render_roast_list_fragment,
request: ListRequest<RoastSortKey>, RoastSortKey,
search: Option<String>, load_roast_page,
is_authenticated: bool, RoastListTemplate { roasts },
) -> Result<Response, AppError> { "#roast-list"
let (roasts, navigator) = load_roast_page(&state, request, search.as_deref()).await?; );
let template = RoastListTemplate {
is_authenticated,
roasts,
navigator,
};
crate::application::routes::support::render_fragment(template, "#roast-list")
}

View file

@ -6,10 +6,13 @@ use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize; use serde::Deserialize;
use crate::application::errors::{ApiError, AppError}; use crate::application::errors::{ApiError, AppError};
use crate::application::server::AppState;
use crate::domain::listing::{ use crate::domain::listing::{
DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortDirection, SortKey, DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortDirection, SortKey,
}; };
use crate::presentation::web::views::{ListNavigator, Paginated}; use crate::presentation::web::views::{
CafeOptionView, ListNavigator, Paginated, RoastOptionView, RoasterOptionView,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PayloadSource { pub enum PayloadSource {
@ -202,6 +205,33 @@ where
} }
} }
pub(super) async fn load_roaster_options(
state: &AppState,
) -> Result<Vec<RoasterOptionView>, AppError> {
use crate::domain::roasters::RoasterSortKey;
let roasters = state
.roaster_repo
.list_all_sorted(RoasterSortKey::Name, SortDirection::Asc)
.await
.map_err(AppError::from)?;
Ok(roasters.into_iter().map(RoasterOptionView::from).collect())
}
pub(super) async fn load_roast_options(state: &AppState) -> Result<Vec<RoastOptionView>, AppError> {
let roasts = state.roast_repo.list_all().await.map_err(AppError::from)?;
Ok(roasts.into_iter().map(RoastOptionView::from).collect())
}
pub(super) async fn load_cafe_options(state: &AppState) -> Result<Vec<CafeOptionView>, AppError> {
use crate::domain::cafes::CafeSortKey;
let cafes = state
.cafe_repo
.list_all_sorted(CafeSortKey::Name, SortDirection::Asc)
.await
.map_err(AppError::from)?;
Ok(cafes.into_iter().map(CafeOptionView::from).collect())
}
pub fn is_datastar_request(headers: &HeaderMap) -> bool { pub fn is_datastar_request(headers: &HeaderMap) -> bool {
headers headers
.get("datastar-request") .get("datastar-request")