From 714713827d48f8decad4ad32e24a05edcc8c4741 Mon Sep 17 00:00:00 2001 From: Jon Seager Date: Tue, 3 Feb 2026 16:49:36 +0000 Subject: [PATCH] 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 --- src/application/routes/bags.rs | 13 ++------ src/application/routes/cafes.rs | 25 +++++--------- src/application/routes/cups.rs | 53 +++++++++--------------------- src/application/routes/gear.rs | 37 +++++++-------------- src/application/routes/macros.rs | 42 +++++++++++++++++++++++ src/application/routes/roasters.rs | 25 +++++--------- src/application/routes/roasts.rs | 40 ++++++++-------------- src/application/routes/support.rs | 32 +++++++++++++++++- 8 files changed, 132 insertions(+), 135 deletions(-) diff --git a/src/application/routes/bags.rs b/src/application/routes/bags.rs index a9049d3..d786a48 100644 --- a/src/application/routes/bags.rs +++ b/src/application/routes/bags.rs @@ -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, diff --git a/src/application/routes/cafes.rs b/src/application/routes/cafes.rs index 22b355d..5397e29 100644 --- a/src/application/routes/cafes.rs +++ b/src/application/routes/cafes.rs @@ -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, - search: Option, - is_authenticated: bool, -) -> Result { - 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 { diff --git a/src/application/routes/cups.rs b/src/application/routes/cups.rs index 5b8ce79..8723b8f 100644 --- a/src/application/routes/cups.rs +++ b/src/application/routes/cups.rs @@ -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 = 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 = 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, - search: Option, - is_authenticated: bool, -) -> Result { - 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" +); diff --git a/src/application/routes/gear.rs b/src/application/routes/gear.rs index 369971c..4dc2473 100644 --- a/src/application/routes/gear.rs +++ b/src/application/routes/gear.rs @@ -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, search: Option<&str>, -) -> Result, AppError> { +) -> Result<(Paginated, ListNavigator), 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, - search: Option, - is_authenticated: bool, -) -> Result { - 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" +); diff --git a/src/application/routes/macros.rs b/src/application/routes/macros.rs index fd7b098..6044428 100644 --- a/src/application/routes/macros.rs +++ b/src/application/routes/macros.rs @@ -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, ListNavigator)` +/// * `$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, + is_authenticated: bool, + ) -> Result { + 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; diff --git a/src/application/routes/roasters.rs b/src/application/routes/roasters.rs index 350d640..58c52c7 100644 --- a/src/application/routes/roasters.rs +++ b/src/application/routes/roasters.rs @@ -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, - search: Option, - is_authenticated: bool, -) -> Result { - 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" +); diff --git a/src/application/routes/roasts.rs b/src/application/routes/roasts.rs index 32255ff..7538812 100644 --- a/src/application/routes/roasts.rs +++ b/src/application/routes/roasts.rs @@ -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, - search: Option, - is_authenticated: bool, -) -> Result { - 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" +); diff --git a/src/application/routes/support.rs b/src/application/routes/support.rs index 552e08e..be4d9fe 100644 --- a/src/application/routes/support.rs +++ b/src/application/routes/support.rs @@ -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, 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, 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, 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")