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:
Jon Seager 2026-02-03 12:16:57 +00:00
parent 1e151123a0
commit 2edf102b41
No known key found for this signature in database
10 changed files with 168 additions and 83 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -165,6 +165,7 @@ async fn load_timeline_page(
TIMELINE_PAGE_PATH,
TIMELINE_FRAGMENT_PATH,
normalized_request,
None,
);
Ok(TimelinePageData {

View file

@ -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,15 +257,36 @@ 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 {

View file

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