feat(search): add search to ListQuery, ListNavigator, and route handlers
Add q parameter to ListQuery with into_request_and_search(). Extend ListNavigator with search state, search_value(), has_search(), path(), and search_query_base() for template URL generation. Thread search through all route handlers and update backup tests.
This commit is contained in:
parent
1e151123a0
commit
2edf102b41
10 changed files with 168 additions and 83 deletions
|
|
@ -33,11 +33,12 @@ struct BagPageData {
|
|||
async fn load_bag_page(
|
||||
state: &AppState,
|
||||
request: ListRequest<BagSortKey>,
|
||||
search: Option<&str>,
|
||||
) -> Result<BagPageData, AppError> {
|
||||
let open_request = ListRequest::show_all(BagSortKey::RoastDate, SortDirection::Desc);
|
||||
let open_page = state
|
||||
.bag_repo
|
||||
.list(BagFilter::open(), &open_request)
|
||||
.list(BagFilter::open(), &open_request, None)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
let open_bags_view = open_page
|
||||
|
|
@ -48,7 +49,7 @@ async fn load_bag_page(
|
|||
|
||||
let page = state
|
||||
.bag_repo
|
||||
.list(BagFilter::closed(), &request)
|
||||
.list(BagFilter::closed(), &request, search)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
|
@ -58,6 +59,7 @@ async fn load_bag_page(
|
|||
BagView::from_domain,
|
||||
BAG_PAGE_PATH,
|
||||
BAG_FRAGMENT_PATH,
|
||||
search.map(String::from),
|
||||
);
|
||||
|
||||
Ok(BagPageData {
|
||||
|
|
@ -74,11 +76,11 @@ pub(crate) async fn bags_page(
|
|||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let request = query.into_request::<BagSortKey>();
|
||||
let (request, search) = query.into_request_and_search::<BagSortKey>();
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
return render_bag_list_fragment(state, request, is_authenticated)
|
||||
return render_bag_list_fragment(state, request, search, is_authenticated)
|
||||
.await
|
||||
.map_err(map_app_error);
|
||||
}
|
||||
|
|
@ -95,7 +97,7 @@ pub(crate) async fn bags_page(
|
|||
open_bags,
|
||||
bags,
|
||||
navigator,
|
||||
} = load_bag_page(&state, request)
|
||||
} = load_bag_page(&state, request, search.as_deref())
|
||||
.await
|
||||
.map_err(map_app_error)?;
|
||||
|
||||
|
|
@ -121,7 +123,7 @@ pub(crate) async fn create_bag(
|
|||
Query(query): Query<ListQuery>,
|
||||
payload: FlexiblePayload<NewBagSubmission>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let request = query.into_request::<BagSortKey>();
|
||||
let (request, search) = query.into_request_and_search::<BagSortKey>();
|
||||
let (submission, source) = payload.into_parts();
|
||||
let new_bag = submission.into_new_bag().map_err(ApiError::from)?;
|
||||
|
||||
|
|
@ -168,11 +170,12 @@ pub(crate) async fn create_bag(
|
|||
let _ = state.timeline_repo.insert(event).await;
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
render_bag_list_fragment(state, request, true)
|
||||
render_bag_list_fragment(state, request, search, true)
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
} else if matches!(source, PayloadSource::Form) {
|
||||
let target = ListNavigator::new(BAG_PAGE_PATH, BAG_FRAGMENT_PATH, request).page_href(1);
|
||||
let target =
|
||||
ListNavigator::new(BAG_PAGE_PATH, BAG_FRAGMENT_PATH, request, search).page_href(1);
|
||||
Ok(Redirect::to(&target).into_response())
|
||||
} else {
|
||||
let enriched = state
|
||||
|
|
@ -196,7 +199,7 @@ pub(crate) async fn list_bags(
|
|||
let request = ListRequest::show_all(BagSortKey::RoastDate, SortDirection::Desc);
|
||||
let page = state
|
||||
.bag_repo
|
||||
.list(filter, &request)
|
||||
.list(filter, &request, None)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(page.items))
|
||||
|
|
@ -214,7 +217,7 @@ pub(crate) async fn update_bag(
|
|||
Query(update_params): Query<UpdateBag>,
|
||||
payload: Option<Json<UpdateBag>>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let request = query.into_request::<BagSortKey>();
|
||||
let (request, search) = query.into_request_and_search::<BagSortKey>();
|
||||
|
||||
let body_update = payload.map_or(
|
||||
UpdateBag {
|
||||
|
|
@ -274,7 +277,7 @@ pub(crate) async fn update_bag(
|
|||
}
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
render_bag_list_fragment(state, request, true)
|
||||
render_bag_list_fragment(state, request, search, true)
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
} else {
|
||||
|
|
@ -337,13 +340,14 @@ impl NewBagSubmission {
|
|||
async fn render_bag_list_fragment(
|
||||
state: AppState,
|
||||
request: ListRequest<BagSortKey>,
|
||||
search: Option<String>,
|
||||
is_authenticated: bool,
|
||||
) -> Result<Response, AppError> {
|
||||
let BagPageData {
|
||||
open_bags,
|
||||
bags,
|
||||
navigator,
|
||||
} = load_bag_page(&state, request).await?;
|
||||
} = load_bag_page(&state, request, search.as_deref()).await?;
|
||||
|
||||
let template = BagListTemplate {
|
||||
is_authenticated,
|
||||
|
|
|
|||
|
|
@ -35,10 +35,11 @@ struct BrewPageData {
|
|||
async fn load_brew_page(
|
||||
state: &AppState,
|
||||
request: ListRequest<BrewSortKey>,
|
||||
search: Option<&str>,
|
||||
) -> Result<BrewPageData, AppError> {
|
||||
let page = state
|
||||
.brew_repo
|
||||
.list(BrewFilter::all(), &request)
|
||||
.list(BrewFilter::all(), &request, search)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
|
@ -48,6 +49,7 @@ async fn load_brew_page(
|
|||
BrewView::from_domain,
|
||||
BREW_PAGE_PATH,
|
||||
BREW_FRAGMENT_PATH,
|
||||
search.map(String::from),
|
||||
);
|
||||
|
||||
Ok(BrewPageData { brews, navigator })
|
||||
|
|
@ -60,11 +62,11 @@ pub(crate) async fn brews_page(
|
|||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let request = query.into_request::<BrewSortKey>();
|
||||
let (request, search) = query.into_request_and_search::<BrewSortKey>();
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
return render_brew_list_fragment(state, request, is_authenticated)
|
||||
return render_brew_list_fragment(state, request, search, is_authenticated)
|
||||
.await
|
||||
.map_err(map_app_error);
|
||||
}
|
||||
|
|
@ -76,7 +78,7 @@ pub(crate) async fn brews_page(
|
|||
);
|
||||
let open_bags = state
|
||||
.bag_repo
|
||||
.list(BagFilter::open(), &open_bags_request)
|
||||
.list(BagFilter::open(), &open_bags_request, None)
|
||||
.await
|
||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
let bag_options: Vec<BagOptionView> = open_bags
|
||||
|
|
@ -92,6 +94,7 @@ pub(crate) async fn brews_page(
|
|||
.list(
|
||||
GearFilter::for_category(GearCategory::Grinder),
|
||||
&grinder_request,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
|
|
@ -108,6 +111,7 @@ pub(crate) async fn brews_page(
|
|||
.list(
|
||||
GearFilter::for_category(GearCategory::Brewer),
|
||||
&brewer_request,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
|
|
@ -124,6 +128,7 @@ pub(crate) async fn brews_page(
|
|||
.list(
|
||||
GearFilter::for_category(GearCategory::FilterPaper),
|
||||
&filter_paper_request,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
|
|
@ -133,7 +138,7 @@ pub(crate) async fn brews_page(
|
|||
.map(GearOptionView::from)
|
||||
.collect();
|
||||
|
||||
let BrewPageData { brews, navigator } = load_brew_page(&state, request)
|
||||
let BrewPageData { brews, navigator } = load_brew_page(&state, request, search.as_deref())
|
||||
.await
|
||||
.map_err(map_app_error)?;
|
||||
|
||||
|
|
@ -225,7 +230,7 @@ pub(crate) async fn create_brew(
|
|||
Query(query): Query<ListQuery>,
|
||||
payload: FlexiblePayload<NewBrewSubmission>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let request = query.into_request::<BrewSortKey>();
|
||||
let (request, search) = query.into_request_and_search::<BrewSortKey>();
|
||||
let (submission, source) = payload.into_parts();
|
||||
let new_brew = submission.into_new_brew().map_err(ApiError::from)?;
|
||||
|
||||
|
|
@ -267,12 +272,13 @@ pub(crate) async fn create_brew(
|
|||
.insert("datastar-mode", HeaderValue::from_static("append"));
|
||||
Ok(response)
|
||||
} else {
|
||||
render_brew_list_fragment(state, request, true)
|
||||
render_brew_list_fragment(state, request, search, true)
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
}
|
||||
} else if matches!(source, PayloadSource::Form) {
|
||||
let target = ListNavigator::new(BREW_PAGE_PATH, BREW_FRAGMENT_PATH, request).page_href(1);
|
||||
let target =
|
||||
ListNavigator::new(BREW_PAGE_PATH, BREW_FRAGMENT_PATH, request, search).page_href(1);
|
||||
Ok(Redirect::to(&target).into_response())
|
||||
} else {
|
||||
Ok((StatusCode::CREATED, Json(enriched)).into_response())
|
||||
|
|
@ -373,7 +379,7 @@ pub(crate) async fn list_brews(
|
|||
let request = ListRequest::show_all(BrewSortKey::CreatedAt, SortDirection::Desc);
|
||||
let page = state
|
||||
.brew_repo
|
||||
.list(filter, &request)
|
||||
.list(filter, &request, None)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(page.items))
|
||||
|
|
@ -398,9 +404,11 @@ define_delete_handler!(
|
|||
async fn render_brew_list_fragment(
|
||||
state: AppState,
|
||||
request: ListRequest<BrewSortKey>,
|
||||
search: Option<String>,
|
||||
is_authenticated: bool,
|
||||
) -> Result<Response, AppError> {
|
||||
let BrewPageData { brews, navigator } = load_brew_page(&state, request).await?;
|
||||
let BrewPageData { brews, navigator } =
|
||||
load_brew_page(&state, request, search.as_deref()).await?;
|
||||
|
||||
let template = BrewListTemplate {
|
||||
is_authenticated,
|
||||
|
|
|
|||
|
|
@ -28,10 +28,11 @@ const GEAR_FRAGMENT_PATH: &str = "/gear#gear-list";
|
|||
async fn load_gear_page(
|
||||
state: &AppState,
|
||||
request: ListRequest<GearSortKey>,
|
||||
search: Option<&str>,
|
||||
) -> Result<Paginated<GearView>, AppError> {
|
||||
let page = state
|
||||
.gear_repo
|
||||
.list(GearFilter::all(), &request)
|
||||
.list(GearFilter::all(), &request, search)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
|
@ -41,6 +42,7 @@ async fn load_gear_page(
|
|||
GearView::from_domain,
|
||||
GEAR_PAGE_PATH,
|
||||
GEAR_FRAGMENT_PATH,
|
||||
search.map(String::from),
|
||||
);
|
||||
|
||||
Ok(gear)
|
||||
|
|
@ -53,21 +55,21 @@ pub(crate) async fn gear_page(
|
|||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let request = query.into_request::<GearSortKey>();
|
||||
let (request, search) = query.into_request_and_search::<GearSortKey>();
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
return render_gear_list_fragment(state, request, is_authenticated)
|
||||
return render_gear_list_fragment(state, request, search, is_authenticated)
|
||||
.await
|
||||
.map_err(map_app_error);
|
||||
}
|
||||
|
||||
let gear = load_gear_page(&state, request)
|
||||
let gear = 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);
|
||||
let navigator = ListNavigator::new(GEAR_PAGE_PATH, GEAR_FRAGMENT_PATH, request, search);
|
||||
|
||||
let template = GearTemplate {
|
||||
nav_active: "gear",
|
||||
|
|
@ -87,7 +89,7 @@ pub(crate) async fn create_gear(
|
|||
Query(query): Query<ListQuery>,
|
||||
payload: FlexiblePayload<NewGearSubmission>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let request = query.into_request::<GearSortKey>();
|
||||
let (request, search) = query.into_request_and_search::<GearSortKey>();
|
||||
let (submission, source) = payload.into_parts();
|
||||
let new_gear = submission.into_new_gear().map_err(ApiError::from)?;
|
||||
|
||||
|
|
@ -126,11 +128,12 @@ pub(crate) async fn create_gear(
|
|||
let _ = state.timeline_repo.insert(event).await;
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
render_gear_list_fragment(state, request, true)
|
||||
render_gear_list_fragment(state, request, search, true)
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
} else if matches!(source, PayloadSource::Form) {
|
||||
let target = ListNavigator::new(GEAR_PAGE_PATH, GEAR_FRAGMENT_PATH, request).page_href(1);
|
||||
let target =
|
||||
ListNavigator::new(GEAR_PAGE_PATH, GEAR_FRAGMENT_PATH, request, search).page_href(1);
|
||||
Ok(Redirect::to(&target).into_response())
|
||||
} else {
|
||||
Ok((StatusCode::CREATED, Json(gear)).into_response())
|
||||
|
|
@ -153,7 +156,7 @@ pub(crate) async fn list_gear(
|
|||
let request = ListRequest::show_all(GearSortKey::Make, SortDirection::Asc);
|
||||
let page = state
|
||||
.gear_repo
|
||||
.list(filter, &request)
|
||||
.list(filter, &request, None)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
Ok(Json(page.items))
|
||||
|
|
@ -170,7 +173,7 @@ pub(crate) async fn update_gear(
|
|||
Query(query): Query<ListQuery>,
|
||||
payload: Json<UpdateGear>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let request = query.into_request::<GearSortKey>();
|
||||
let (request, search) = query.into_request_and_search::<GearSortKey>();
|
||||
|
||||
let gear = state
|
||||
.gear_repo
|
||||
|
|
@ -179,7 +182,7 @@ pub(crate) async fn update_gear(
|
|||
.map_err(AppError::from)?;
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
render_gear_list_fragment(state, request, true)
|
||||
render_gear_list_fragment(state, request, search, true)
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
} else {
|
||||
|
|
@ -231,10 +234,11 @@ 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).await?;
|
||||
let navigator = ListNavigator::new(GEAR_PAGE_PATH, GEAR_FRAGMENT_PATH, request);
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ macro_rules! define_delete_handler {
|
|||
crate::application::routes::support::ListQuery,
|
||||
>,
|
||||
) -> Result<axum::response::Response, crate::application::errors::ApiError> {
|
||||
let request = query.into_request::<$sort_key>();
|
||||
let (request, search) = query.into_request_and_search::<$sort_key>();
|
||||
state
|
||||
.$repo_field
|
||||
.delete(id)
|
||||
|
|
@ -96,7 +96,7 @@ macro_rules! define_delete_handler {
|
|||
.map_err(crate::application::errors::AppError::from)?;
|
||||
|
||||
if crate::application::routes::support::is_datastar_request(&headers) {
|
||||
$render_fragment(state, request, true)
|
||||
$render_fragment(state, request, search, true)
|
||||
.await
|
||||
.map_err(crate::application::errors::ApiError::from)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -26,10 +26,11 @@ const ROASTER_FRAGMENT_PATH: &str = "/roasters#roaster-list";
|
|||
async fn load_roaster_page(
|
||||
state: &AppState,
|
||||
request: ListRequest<RoasterSortKey>,
|
||||
search: Option<&str>,
|
||||
) -> Result<(Paginated<RoasterView>, ListNavigator<RoasterSortKey>), AppError> {
|
||||
let page = state
|
||||
.roaster_repo
|
||||
.list(&request)
|
||||
.list(&request, search)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ async fn load_roaster_page(
|
|||
RoasterView::from,
|
||||
ROASTER_PAGE_PATH,
|
||||
ROASTER_FRAGMENT_PATH,
|
||||
search.map(String::from),
|
||||
))
|
||||
}
|
||||
|
||||
|
|
@ -49,16 +51,16 @@ pub(crate) async fn roasters_page(
|
|||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let request = query.into_request::<RoasterSortKey>();
|
||||
let (request, search) = query.into_request_and_search::<RoasterSortKey>();
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
return render_roaster_list_fragment(state, request, is_authenticated)
|
||||
return render_roaster_list_fragment(state, request, search, is_authenticated)
|
||||
.await
|
||||
.map_err(map_app_error);
|
||||
}
|
||||
|
||||
let (roasters, navigator) = load_roaster_page(&state, request)
|
||||
let (roasters, navigator) = load_roaster_page(&state, request, search.as_deref())
|
||||
.await
|
||||
.map_err(map_app_error)?;
|
||||
|
||||
|
|
@ -124,7 +126,7 @@ pub(crate) async fn create_roaster(
|
|||
Query(query): Query<ListQuery>,
|
||||
payload: FlexiblePayload<NewRoaster>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let request = query.into_request::<RoasterSortKey>();
|
||||
let (request, search) = query.into_request_and_search::<RoasterSortKey>();
|
||||
let (new_roaster, source) = payload.into_parts();
|
||||
let new_roaster = new_roaster.normalize();
|
||||
let roaster = state
|
||||
|
|
@ -134,12 +136,12 @@ pub(crate) async fn create_roaster(
|
|||
.map_err(AppError::from)?;
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
render_roaster_list_fragment(state, request, true)
|
||||
render_roaster_list_fragment(state, request, search, true)
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
} else if matches!(source, PayloadSource::Form) {
|
||||
let target =
|
||||
ListNavigator::new(ROASTER_PAGE_PATH, ROASTER_FRAGMENT_PATH, request).page_href(1);
|
||||
let target = ListNavigator::new(ROASTER_PAGE_PATH, ROASTER_FRAGMENT_PATH, request, search)
|
||||
.page_href(1);
|
||||
Ok(Redirect::to(&target).into_response())
|
||||
} else {
|
||||
Ok((StatusCode::CREATED, Json(roaster)).into_response())
|
||||
|
|
@ -184,9 +186,10 @@ define_delete_handler!(
|
|||
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).await?;
|
||||
let (roasters, navigator) = load_roaster_page(&state, request, search.as_deref()).await?;
|
||||
|
||||
let template = RoasterListTemplate {
|
||||
is_authenticated,
|
||||
|
|
|
|||
|
|
@ -29,10 +29,11 @@ const ROAST_FRAGMENT_PATH: &str = "/roasts#roast-list";
|
|||
async fn load_roast_page(
|
||||
state: &AppState,
|
||||
request: ListRequest<RoastSortKey>,
|
||||
search: Option<&str>,
|
||||
) -> Result<(Paginated<RoastView>, ListNavigator<RoastSortKey>), AppError> {
|
||||
let page = state
|
||||
.roast_repo
|
||||
.list(&request)
|
||||
.list(&request, search)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
|
@ -42,6 +43,7 @@ async fn load_roast_page(
|
|||
RoastView::from_list_item,
|
||||
ROAST_PAGE_PATH,
|
||||
ROAST_FRAGMENT_PATH,
|
||||
search.map(String::from),
|
||||
))
|
||||
}
|
||||
|
||||
|
|
@ -52,11 +54,11 @@ pub(crate) async fn roasts_page(
|
|||
headers: HeaderMap,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let request = query.into_request::<RoastSortKey>();
|
||||
let (request, search) = query.into_request_and_search::<RoastSortKey>();
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
let is_authenticated = super::is_authenticated(&state, &cookies).await;
|
||||
return render_roast_list_fragment(state, request, is_authenticated)
|
||||
return render_roast_list_fragment(state, request, search, is_authenticated)
|
||||
.await
|
||||
.map_err(map_app_error);
|
||||
}
|
||||
|
|
@ -69,7 +71,7 @@ pub(crate) async fn roasts_page(
|
|||
|
||||
let roaster_options = roasters.into_iter().map(RoasterOptionView::from).collect();
|
||||
|
||||
let (roasts, navigator) = load_roast_page(&state, request)
|
||||
let (roasts, navigator) = load_roast_page(&state, request, search.as_deref())
|
||||
.await
|
||||
.map_err(map_app_error)?;
|
||||
|
||||
|
|
@ -107,7 +109,7 @@ pub(crate) async fn roast_page(
|
|||
let bag_request = ListRequest::show_all(BagSortKey::RoastDate, SortDirection::Desc);
|
||||
let bags_page = state
|
||||
.bag_repo
|
||||
.list(BagFilter::for_roast(roast.id), &bag_request)
|
||||
.list(BagFilter::for_roast(roast.id), &bag_request, None)
|
||||
.await
|
||||
.map_err(|err| map_app_error(AppError::from(err)))?;
|
||||
|
||||
|
|
@ -137,7 +139,7 @@ pub(crate) async fn create_roast(
|
|||
Query(query): Query<ListQuery>,
|
||||
payload: FlexiblePayload<NewRoastSubmission>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let request = query.into_request::<RoastSortKey>();
|
||||
let (request, search) = query.into_request_and_search::<RoastSortKey>();
|
||||
let (submission, source) = payload.into_parts();
|
||||
let new_roast = submission.into_new_roast().map_err(ApiError::from)?;
|
||||
|
||||
|
|
@ -154,11 +156,12 @@ pub(crate) async fn create_roast(
|
|||
.map_err(AppError::from)?;
|
||||
|
||||
if is_datastar_request(&headers) {
|
||||
render_roast_list_fragment(state, request, true)
|
||||
render_roast_list_fragment(state, request, search, true)
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
} else if matches!(source, PayloadSource::Form) {
|
||||
let target = ListNavigator::new(ROAST_PAGE_PATH, ROAST_FRAGMENT_PATH, request).page_href(1);
|
||||
let target =
|
||||
ListNavigator::new(ROAST_PAGE_PATH, ROAST_FRAGMENT_PATH, request, search).page_href(1);
|
||||
Ok(Redirect::to(&target).into_response())
|
||||
} else {
|
||||
let enriched = state
|
||||
|
|
@ -343,9 +346,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).await?;
|
||||
let (roasts, navigator) = load_roast_page(&state, request, search.as_deref()).await?;
|
||||
|
||||
let template = RoastListTemplate {
|
||||
is_authenticated,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ pub(crate) struct ListQuery {
|
|||
sort_key: Option<String>,
|
||||
#[serde(default, rename = "dir")]
|
||||
sort_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
q: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -48,31 +50,42 @@ enum PageSizeParam {
|
|||
}
|
||||
|
||||
impl ListQuery {
|
||||
pub fn into_request<K: SortKey>(self) -> ListRequest<K> {
|
||||
self.into_request_with_default::<K>(DEFAULT_PAGE_SIZE)
|
||||
pub fn into_request_and_search<K: SortKey>(self) -> (ListRequest<K>, Option<String>) {
|
||||
self.into_request_and_search_with_default::<K>(DEFAULT_PAGE_SIZE)
|
||||
}
|
||||
|
||||
pub fn into_request_with_default<K: SortKey>(self, default_page_size: u32) -> ListRequest<K> {
|
||||
let page = self.page.unwrap_or(1);
|
||||
let page_size = match self.page_size {
|
||||
pub fn into_request_and_search_with_default<K: SortKey>(
|
||||
self,
|
||||
default_page_size: u32,
|
||||
) -> (ListRequest<K>, Option<String>) {
|
||||
let ListQuery {
|
||||
page,
|
||||
page_size,
|
||||
sort_key,
|
||||
sort_dir,
|
||||
q,
|
||||
} = self;
|
||||
|
||||
let search = q.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
|
||||
let page = page.unwrap_or(1);
|
||||
let page_size = match page_size {
|
||||
Some(PageSizeParam::Number(value)) => PageSize::limited(value),
|
||||
Some(PageSizeParam::Text(text)) => page_size_from_text(&text),
|
||||
None => PageSize::limited(default_page_size.max(1)),
|
||||
};
|
||||
|
||||
let sort_key = self
|
||||
.sort_key
|
||||
let sk = sort_key
|
||||
.as_deref()
|
||||
.and_then(K::from_query)
|
||||
.unwrap_or_else(K::default);
|
||||
|
||||
let sort_direction = self
|
||||
.sort_dir
|
||||
let sd = sort_dir
|
||||
.as_deref()
|
||||
.and_then(parse_direction)
|
||||
.unwrap_or_else(|| sort_key.default_direction());
|
||||
.unwrap_or_else(|| sk.default_direction());
|
||||
|
||||
ListRequest::new(page, page_size, sort_key, sort_direction)
|
||||
(ListRequest::new(page, page_size, sk, sd), search)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,13 +113,14 @@ pub fn build_page_view<K, T, V>(
|
|||
view_mapper: impl FnMut(T) -> V,
|
||||
base_path: &'static str,
|
||||
fragment_path: &'static str,
|
||||
search: Option<String>,
|
||||
) -> (Paginated<V>, ListNavigator<K>)
|
||||
where
|
||||
K: SortKey,
|
||||
{
|
||||
let normalized_request = normalize_request(request, &page);
|
||||
let view_page = Paginated::from_page(page, view_mapper);
|
||||
let navigator = ListNavigator::new(base_path, fragment_path, normalized_request);
|
||||
let navigator = ListNavigator::new(base_path, fragment_path, normalized_request, search);
|
||||
(view_page, navigator)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ async fn load_timeline_page(
|
|||
TIMELINE_PAGE_PATH,
|
||||
TIMELINE_FRAGMENT_PATH,
|
||||
normalized_request,
|
||||
None,
|
||||
);
|
||||
|
||||
Ok(TimelinePageData {
|
||||
|
|
|
|||
|
|
@ -108,11 +108,12 @@ impl<T> Paginated<T> {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ListNavigator<K: SortKey> {
|
||||
base_path: &'static str,
|
||||
fragment_path: &'static str,
|
||||
request: ListRequest<K>,
|
||||
search: Option<String>,
|
||||
}
|
||||
|
||||
impl<K: SortKey> ListNavigator<K> {
|
||||
|
|
@ -120,11 +121,13 @@ impl<K: SortKey> ListNavigator<K> {
|
|||
base_path: &'static str,
|
||||
fragment_path: &'static str,
|
||||
request: ListRequest<K>,
|
||||
search: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_path,
|
||||
fragment_path,
|
||||
request,
|
||||
search,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -197,27 +200,50 @@ impl<K: SortKey> ListNavigator<K> {
|
|||
}
|
||||
|
||||
pub fn query(&self) -> String {
|
||||
Self::query_string(self.request)
|
||||
self.build_query_string(self.request)
|
||||
}
|
||||
|
||||
pub fn query_for_page(&self, page: u32) -> String {
|
||||
Self::query_string(self.request.with_page(page))
|
||||
self.build_query_string(self.request.with_page(page))
|
||||
}
|
||||
|
||||
pub fn query_for_rows(&self, value: &str) -> String {
|
||||
Self::query_string(Self::request_for_rows(self.request, value))
|
||||
self.build_query_string(Self::request_for_rows(self.request, value))
|
||||
}
|
||||
|
||||
pub fn query_for_sort(&self, key: &str) -> String {
|
||||
Self::query_string(Self::request_for_sort(self.request, key))
|
||||
self.build_query_string(Self::request_for_sort(self.request, key))
|
||||
}
|
||||
|
||||
pub fn search_value(&self) -> &str {
|
||||
self.search.as_deref().unwrap_or("")
|
||||
}
|
||||
|
||||
pub fn has_search(&self) -> bool {
|
||||
self.search.is_some()
|
||||
}
|
||||
|
||||
/// Returns the base path (e.g., "/roasters") without query or fragment.
|
||||
pub fn path(&self) -> &str {
|
||||
self.base_path
|
||||
}
|
||||
|
||||
/// Returns query params for search actions (page reset to 1, preserves `sort/page_size`).
|
||||
/// Does NOT include the `q` parameter — the template appends it dynamically from JS.
|
||||
pub fn search_query_base(&self) -> String {
|
||||
format!(
|
||||
"page=1&page_size={}&sort={}&dir={}",
|
||||
self.request.page_size().to_query_value(),
|
||||
self.request.sort_key().query_value(),
|
||||
self.request.sort_direction().as_str()
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::unused_self)] // Keeps consistent method interface
|
||||
fn build_href(&self, path: &str, request: ListRequest<K>) -> String {
|
||||
if let Some((base, fragment)) = path.split_once('#') {
|
||||
format!("{}?{}#{}", base, Self::query_string(request), fragment)
|
||||
format!("{}?{}#{}", base, self.build_query_string(request), fragment)
|
||||
} else {
|
||||
format!("{}?{}", path, Self::query_string(request))
|
||||
format!("{}?{}", path, self.build_query_string(request))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -231,17 +257,38 @@ impl<K: SortKey> ListNavigator<K> {
|
|||
request.with_page(1).with_sort(sort_key)
|
||||
}
|
||||
|
||||
fn query_string(request: ListRequest<K>) -> String {
|
||||
format!(
|
||||
fn build_query_string(&self, request: ListRequest<K>) -> String {
|
||||
let mut qs = format!(
|
||||
"page={}&page_size={}&sort={}&dir={}",
|
||||
request.page(),
|
||||
request.page_size().to_query_value(),
|
||||
request.sort_key().query_value(),
|
||||
request.sort_direction().as_str()
|
||||
)
|
||||
);
|
||||
if let Some(ref q) = self.search {
|
||||
qs.push_str("&q=");
|
||||
qs.push_str(&encode_uri_component(q));
|
||||
}
|
||||
qs
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_uri_component(s: &str) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut result = String::with_capacity(s.len() * 3);
|
||||
for byte in s.as_bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
result.push(*byte as char);
|
||||
}
|
||||
_ => {
|
||||
let _ = write!(result, "%{byte:02X}");
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn page_size_from_text(value: &str) -> PageSize {
|
||||
if value.eq_ignore_ascii_case("all") {
|
||||
PageSize::All
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ fn list_all_request<K: brewlog::domain::listing::SortKey>() -> ListRequest<K> {
|
|||
}
|
||||
|
||||
async fn list_all_roasters(repo: &dyn RoasterRepository) -> Vec<Roaster> {
|
||||
repo.list(&list_all_request::<RoasterSortKey>())
|
||||
repo.list(&list_all_request::<RoasterSortKey>(), None)
|
||||
.await
|
||||
.expect("failed to list roasters")
|
||||
.items
|
||||
|
|
@ -66,7 +66,7 @@ async fn list_all_roasters(repo: &dyn RoasterRepository) -> Vec<Roaster> {
|
|||
|
||||
async fn list_all_roasts(repo: &dyn RoastRepository) -> Vec<Roast> {
|
||||
let page = repo
|
||||
.list(&list_all_request::<RoastSortKey>())
|
||||
.list(&list_all_request::<RoastSortKey>(), None)
|
||||
.await
|
||||
.expect("failed to list roasts");
|
||||
page.items.into_iter().map(|rwr| rwr.roast).collect()
|
||||
|
|
@ -74,14 +74,14 @@ async fn list_all_roasts(repo: &dyn RoastRepository) -> Vec<Roast> {
|
|||
|
||||
async fn list_all_bags(repo: &dyn BagRepository) -> Vec<Bag> {
|
||||
let page = repo
|
||||
.list(BagFilter::all(), &list_all_request::<BagSortKey>())
|
||||
.list(BagFilter::all(), &list_all_request::<BagSortKey>(), None)
|
||||
.await
|
||||
.expect("failed to list bags");
|
||||
page.items.into_iter().map(|bwr| bwr.bag).collect()
|
||||
}
|
||||
|
||||
async fn list_all_gear(repo: &dyn GearRepository) -> Vec<Gear> {
|
||||
repo.list(GearFilter::all(), &list_all_request::<GearSortKey>())
|
||||
repo.list(GearFilter::all(), &list_all_request::<GearSortKey>(), None)
|
||||
.await
|
||||
.expect("failed to list gear")
|
||||
.items
|
||||
|
|
@ -89,7 +89,7 @@ async fn list_all_gear(repo: &dyn GearRepository) -> Vec<Gear> {
|
|||
|
||||
async fn list_all_brews(repo: &dyn BrewRepository) -> Vec<Brew> {
|
||||
let page = repo
|
||||
.list(BrewFilter::all(), &list_all_request::<BrewSortKey>())
|
||||
.list(BrewFilter::all(), &list_all_request::<BrewSortKey>(), None)
|
||||
.await
|
||||
.expect("failed to list brews");
|
||||
page.items.into_iter().map(|bwd| bwd.brew).collect()
|
||||
|
|
|
|||
Loading…
Reference in a new issue