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::routes::render_html;
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::domain::bags::{BagFilter, BagSortKey, BagWithRoast, NewBag, UpdateBag};
use crate::domain::ids::{BagId, RoastId};
use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasters::RoasterSortKey;
use crate::domain::timeline::{NewTimelineEvent, TimelineEventDetail};
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_FRAGMENT_PATH: &str = "/bags#bag-list";
@ -85,13 +84,7 @@ pub(crate) async fn bags_page(
.map_err(map_app_error);
}
let roasters = state
.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 roaster_options = load_roaster_options(&state).await.map_err(map_app_error)?;
let BagPageData {
open_bags,

View file

@ -4,7 +4,7 @@ use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Redirect, Response};
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::errors::{ApiError, AppError, map_app_error};
use crate::application::routes::render_html;
@ -177,22 +177,13 @@ define_delete_handler!(
render_cafe_list_fragment
);
async fn render_cafe_list_fragment(
state: AppState,
request: ListRequest<CafeSortKey>,
search: Option<String>,
is_authenticated: bool,
) -> Result<Response, AppError> {
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")
}
define_list_fragment_renderer!(
render_cafe_list_fragment,
CafeSortKey,
load_cafe_page,
CafeListTemplate { cafes },
"#cafe-list"
);
#[derive(Debug, Deserialize)]
pub struct NearbyQuery {

View file

@ -3,22 +3,22 @@ use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
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::errors::{ApiError, AppError, map_app_error};
use crate::application::routes::render_html;
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::domain::cafes::CafeSortKey;
use crate::domain::cups::{Cup, CupFilter, CupSortKey, CupWithDetails, NewCup, UpdateCup};
use crate::domain::ids::CupId;
use crate::domain::listing::{ListRequest, SortDirection};
use crate::presentation::web::templates::{CupListTemplate, CupsTemplate};
use crate::presentation::web::views::{
CafeOptionView, CupView, ListNavigator, Paginated, RoastOptionView,
};
use crate::presentation::web::views::{CupView, ListNavigator, Paginated};
const CUP_PAGE_PATH: &str = "/cups";
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 roast_options: Vec<RoastOptionView> = state
.roast_repo
.list_all()
.await
.map_err(|e| map_app_error(AppError::from(e)))?
.into_iter()
.map(RoastOptionView::from)
.collect();
let roast_options = load_roast_options(&state).await.map_err(map_app_error)?;
let cafe_options: Vec<CafeOptionView> = state
.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 cafe_options = load_cafe_options(&state).await.map_err(map_app_error)?;
let template = CupsTemplate {
nav_active: "cups",
@ -183,19 +169,10 @@ define_delete_handler!(
render_cup_list_fragment
);
async fn render_cup_list_fragment(
state: AppState,
request: ListRequest<CupSortKey>,
search: Option<String>,
is_authenticated: bool,
) -> Result<Response, AppError> {
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")
}
define_list_fragment_renderer!(
render_cup_list_fragment,
CupSortKey,
load_cup_page,
CupListTemplate { cups },
"#cup-list"
);

View file

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

View file

@ -4,23 +4,24 @@ use axum::http::{HeaderMap, StatusCode};
use axum::response::{Html, IntoResponse, Redirect, Response};
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::errors::{ApiError, AppError, map_app_error};
use crate::application::routes::render_html;
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::domain::bags::{BagFilter, BagSortKey};
use crate::domain::ids::{RoastId, RoasterId};
use crate::domain::listing::{ListRequest, SortDirection};
use crate::domain::roasters::RoasterSortKey;
use crate::domain::roasts::{NewRoast, RoastSortKey, RoastWithRoaster, UpdateRoast};
use crate::presentation::web::templates::{
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_FRAGMENT_PATH: &str = "/roasts#roast-list";
@ -63,13 +64,7 @@ pub(crate) async fn roasts_page(
.map_err(map_app_error);
}
let roasters = state
.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 roaster_options = load_roaster_options(&state).await.map_err(map_app_error)?;
let (roasts, navigator) = load_roast_page(&state, request, search.as_deref())
.await
@ -343,19 +338,10 @@ impl TastingNotesInput {
}
}
async fn render_roast_list_fragment(
state: AppState,
request: ListRequest<RoastSortKey>,
search: Option<String>,
is_authenticated: bool,
) -> Result<Response, AppError> {
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")
}
define_list_fragment_renderer!(
render_roast_list_fragment,
RoastSortKey,
load_roast_page,
RoastListTemplate { roasts },
"#roast-list"
);

View file

@ -6,10 +6,13 @@ use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
use crate::application::errors::{ApiError, AppError};
use crate::application::server::AppState;
use crate::domain::listing::{
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)]
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 {
headers
.get("datastar-request")