diff --git a/src/application/routes/bags.rs b/src/application/routes/bags.rs index 4edb19c..a9049d3 100644 --- a/src/application/routes/bags.rs +++ b/src/application/routes/bags.rs @@ -33,11 +33,12 @@ struct BagPageData { async fn load_bag_page( state: &AppState, request: ListRequest, + search: Option<&str>, ) -> Result { 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, ) -> Result { - let request = query.into_request::(); + let (request, search) = query.into_request_and_search::(); 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, payload: FlexiblePayload, ) -> Result { - let request = query.into_request::(); + let (request, search) = query.into_request_and_search::(); 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, payload: Option>, ) -> Result { - let request = query.into_request::(); + let (request, search) = query.into_request_and_search::(); 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, + search: Option, is_authenticated: bool, ) -> Result { 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, diff --git a/src/application/routes/brews.rs b/src/application/routes/brews.rs index 66f55df..15a3732 100644 --- a/src/application/routes/brews.rs +++ b/src/application/routes/brews.rs @@ -35,10 +35,11 @@ struct BrewPageData { async fn load_brew_page( state: &AppState, request: ListRequest, + search: Option<&str>, ) -> Result { 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, ) -> Result { - let request = query.into_request::(); + let (request, search) = query.into_request_and_search::(); 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 = 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, payload: FlexiblePayload, ) -> Result { - let request = query.into_request::(); + let (request, search) = query.into_request_and_search::(); 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, + search: Option, is_authenticated: bool, ) -> Result { - 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, diff --git a/src/application/routes/gear.rs b/src/application/routes/gear.rs index 622bc82..369971c 100644 --- a/src/application/routes/gear.rs +++ b/src/application/routes/gear.rs @@ -28,10 +28,11 @@ const GEAR_FRAGMENT_PATH: &str = "/gear#gear-list"; async fn load_gear_page( state: &AppState, request: ListRequest, + search: Option<&str>, ) -> Result, 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, ) -> Result { - let request = query.into_request::(); + let (request, search) = query.into_request_and_search::(); 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, payload: FlexiblePayload, ) -> Result { - let request = query.into_request::(); + let (request, search) = query.into_request_and_search::(); 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, payload: Json, ) -> Result { - let request = query.into_request::(); + let (request, search) = query.into_request_and_search::(); 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, + search: Option, is_authenticated: bool, ) -> Result { - 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, diff --git a/src/application/routes/macros.rs b/src/application/routes/macros.rs index 20674d5..fd7b098 100644 --- a/src/application/routes/macros.rs +++ b/src/application/routes/macros.rs @@ -88,7 +88,7 @@ macro_rules! define_delete_handler { crate::application::routes::support::ListQuery, >, ) -> Result { - 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 { diff --git a/src/application/routes/roasters.rs b/src/application/routes/roasters.rs index 82dd6e4..350d640 100644 --- a/src/application/routes/roasters.rs +++ b/src/application/routes/roasters.rs @@ -26,10 +26,11 @@ const ROASTER_FRAGMENT_PATH: &str = "/roasters#roaster-list"; async fn load_roaster_page( state: &AppState, request: ListRequest, + search: Option<&str>, ) -> Result<(Paginated, ListNavigator), 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, ) -> Result { - let request = query.into_request::(); + let (request, search) = query.into_request_and_search::(); 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, payload: FlexiblePayload, ) -> Result { - let request = query.into_request::(); + let (request, search) = query.into_request_and_search::(); 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, + search: Option, is_authenticated: bool, ) -> Result { - 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, diff --git a/src/application/routes/roasts.rs b/src/application/routes/roasts.rs index e9fd9da..32255ff 100644 --- a/src/application/routes/roasts.rs +++ b/src/application/routes/roasts.rs @@ -29,10 +29,11 @@ const ROAST_FRAGMENT_PATH: &str = "/roasts#roast-list"; async fn load_roast_page( state: &AppState, request: ListRequest, + search: Option<&str>, ) -> Result<(Paginated, ListNavigator), 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, ) -> Result { - let request = query.into_request::(); + let (request, search) = query.into_request_and_search::(); 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, payload: FlexiblePayload, ) -> Result { - let request = query.into_request::(); + let (request, search) = query.into_request_and_search::(); 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, + search: Option, is_authenticated: bool, ) -> Result { - 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, diff --git a/src/application/routes/support.rs b/src/application/routes/support.rs index c202092..552e08e 100644 --- a/src/application/routes/support.rs +++ b/src/application/routes/support.rs @@ -38,6 +38,8 @@ pub(crate) struct ListQuery { sort_key: Option, #[serde(default, rename = "dir")] sort_dir: Option, + #[serde(default)] + q: Option, } #[derive(Debug, Deserialize)] @@ -48,31 +50,42 @@ enum PageSizeParam { } impl ListQuery { - pub fn into_request(self) -> ListRequest { - self.into_request_with_default::(DEFAULT_PAGE_SIZE) + pub fn into_request_and_search(self) -> (ListRequest, Option) { + self.into_request_and_search_with_default::(DEFAULT_PAGE_SIZE) } - pub fn into_request_with_default(self, default_page_size: u32) -> ListRequest { - let page = self.page.unwrap_or(1); - let page_size = match self.page_size { + pub fn into_request_and_search_with_default( + self, + default_page_size: u32, + ) -> (ListRequest, Option) { + 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( view_mapper: impl FnMut(T) -> V, base_path: &'static str, fragment_path: &'static str, + search: Option, ) -> (Paginated, ListNavigator) 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) } diff --git a/src/application/routes/timeline.rs b/src/application/routes/timeline.rs index 367c5a9..5a8985b 100644 --- a/src/application/routes/timeline.rs +++ b/src/application/routes/timeline.rs @@ -165,6 +165,7 @@ async fn load_timeline_page( TIMELINE_PAGE_PATH, TIMELINE_FRAGMENT_PATH, normalized_request, + None, ); Ok(TimelinePageData { diff --git a/src/presentation/web/views.rs b/src/presentation/web/views.rs index 1ce1830..afffffc 100644 --- a/src/presentation/web/views.rs +++ b/src/presentation/web/views.rs @@ -108,11 +108,12 @@ impl Paginated { } } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct ListNavigator { base_path: &'static str, fragment_path: &'static str, request: ListRequest, + search: Option, } impl ListNavigator { @@ -120,11 +121,13 @@ impl ListNavigator { base_path: &'static str, fragment_path: &'static str, request: ListRequest, + search: Option, ) -> Self { Self { base_path, fragment_path, request, + search, } } @@ -197,27 +200,50 @@ impl ListNavigator { } 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) -> 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 ListNavigator { request.with_page(1).with_sort(sort_key) } - fn query_string(request: ListRequest) -> String { - format!( + fn build_query_string(&self, request: ListRequest) -> 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 diff --git a/tests/server/backup.rs b/tests/server/backup.rs index b446e6c..b92045f 100644 --- a/tests/server/backup.rs +++ b/tests/server/backup.rs @@ -58,7 +58,7 @@ fn list_all_request() -> ListRequest { } async fn list_all_roasters(repo: &dyn RoasterRepository) -> Vec { - repo.list(&list_all_request::()) + repo.list(&list_all_request::(), None) .await .expect("failed to list roasters") .items @@ -66,7 +66,7 @@ async fn list_all_roasters(repo: &dyn RoasterRepository) -> Vec { async fn list_all_roasts(repo: &dyn RoastRepository) -> Vec { let page = repo - .list(&list_all_request::()) + .list(&list_all_request::(), 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 { async fn list_all_bags(repo: &dyn BagRepository) -> Vec { let page = repo - .list(BagFilter::all(), &list_all_request::()) + .list(BagFilter::all(), &list_all_request::(), 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 { - repo.list(GearFilter::all(), &list_all_request::()) + repo.list(GearFilter::all(), &list_all_request::(), None) .await .expect("failed to list gear") .items @@ -89,7 +89,7 @@ async fn list_all_gear(repo: &dyn GearRepository) -> Vec { async fn list_all_brews(repo: &dyn BrewRepository) -> Vec { let page = repo - .list(BrewFilter::all(), &list_all_request::()) + .list(BrewFilter::all(), &list_all_request::(), None) .await .expect("failed to list brews"); page.items.into_iter().map(|bwd| bwd.brew).collect()