From ff02083b27e1c23be8a2b67523f7225e68169668 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Mon, 3 Nov 2025 14:47:37 +0700 Subject: [PATCH 1/4] Add search_fields param to product search --- .../Networking/Remote/ProductsRemote.swift | 21 ++++++++++++------- .../Model/Products/ProductSearchFilter.swift | 8 +++++++ .../Yosemite/Stores/ProductStore.swift | 9 +++++++- .../BookableProductListSyncable.swift | 1 + .../ProductSelector/ProductSelectorView.swift | 2 +- .../Product/ProductSearchUICommand.swift | 7 ++++++- 6 files changed, 38 insertions(+), 10 deletions(-) diff --git a/Modules/Sources/Networking/Remote/ProductsRemote.swift b/Modules/Sources/Networking/Remote/ProductsRemote.swift index e812e66039e..c14e2953031 100644 --- a/Modules/Sources/Networking/Remote/ProductsRemote.swift +++ b/Modules/Sources/Networking/Remote/ProductsRemote.swift @@ -23,6 +23,7 @@ public protocol ProductsRemoteProtocol { excludedProductIDs: [Int64]) async throws -> [Product] func searchProducts(for siteID: Int64, keyword: String, + searchFields: [ProductSearchField], pageNumber: Int, pageSize: Int, stockStatus: ProductStockStatus?, @@ -313,7 +314,11 @@ public final class ProductsRemote: Remote, ProductsRemoteProtocol { parameters.updateValue(query, forKey: ParameterKey.searchNameOrSKU) // Takes precedence over `search_name_or_sku` from WC 10.1+ and is combined with `search` value - parameters.updateValue([SearchField.name, SearchField.sku, SearchField.globalUniqueID], forKey: ParameterKey.searchFields) + parameters.updateValue([ + ProductSearchField.name.rawValue, + ProductSearchField.sku.rawValue, + ProductSearchField.globalUniqueID.rawValue + ], forKey: ParameterKey.searchFields) return try await makePagedPointOfSaleProductsRequest( for: siteID, @@ -429,6 +434,7 @@ public final class ProductsRemote: Remote, ProductsRemoteProtocol { /// public func searchProducts(for siteID: Int64, keyword: String, + searchFields: [ProductSearchField], pageNumber: Int, pageSize: Int, stockStatus: ProductStockStatus? = nil, @@ -447,10 +453,11 @@ public final class ProductsRemote: Remote, ProductsRemoteProtocol { ParameterKey.exclude: stringOfExcludedProductIDs ].filter({ $0.value.isEmpty == false }) - let parameters = [ + let parameters: [String: Any] = [ ParameterKey.page: String(pageNumber), ParameterKey.perPage: String(pageSize), ParameterKey.search: keyword, + ParameterKey.searchFields: searchFields.map { $0.rawValue }, ParameterKey.exclude: stringOfExcludedProductIDs, ParameterKey.contextKey: Default.context ].merging(filterParameters, uniquingKeysWith: { (first, _) in first }) @@ -774,12 +781,12 @@ public extension ProductsRemote { static let productSegment = "product" static let itemsSold = "items_sold" } +} - private enum SearchField { - static let name = "name" - static let sku = "sku" - static let globalUniqueID = "global_unique_id" - } +public enum ProductSearchField: String { + case name + case sku + case globalUniqueID = "global_unique_id" } private extension ProductsRemote { diff --git a/Modules/Sources/Yosemite/Model/Products/ProductSearchFilter.swift b/Modules/Sources/Yosemite/Model/Products/ProductSearchFilter.swift index 2c9d51887e2..60f1b196f3c 100644 --- a/Modules/Sources/Yosemite/Model/Products/ProductSearchFilter.swift +++ b/Modules/Sources/Yosemite/Model/Products/ProductSearchFilter.swift @@ -4,6 +4,14 @@ public enum ProductSearchFilter: String, Equatable, CaseIterable { /// Search for all products based on the keyword. case all + /// Search for products that match the name field. + case name /// Search for products that match the SKU field. case sku + + /// Options for searching in product selector view. + /// `name` is omitted as it's used for bookable product filters only so far. + public static var productSelectorOptions: [ProductSearchFilter] { + [.all, .sku] + } } diff --git a/Modules/Sources/Yosemite/Stores/ProductStore.swift b/Modules/Sources/Yosemite/Stores/ProductStore.swift index 3cc5bb59f08..a5d80c84bed 100644 --- a/Modules/Sources/Yosemite/Stores/ProductStore.swift +++ b/Modules/Sources/Yosemite/Stores/ProductStore.swift @@ -235,9 +235,16 @@ private extension ProductStore { do { let products: [Product] switch filter { - case .all: + case .all, .name: + let searchFields: [ProductSearchField] = { + if filter == .name { + return [.name] + } + return [] + }() products = try await remote.searchProducts(for: siteID, keyword: keyword, + searchFields: searchFields, pageNumber: pageNumber, pageSize: pageSize, stockStatus: stockStatus, diff --git a/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift b/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift index da64ea1a58a..0c06686fa73 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift @@ -60,6 +60,7 @@ struct BookableProductListSyncable: ListSyncable { ProductAction.searchProducts( siteID: siteID, keyword: keyword, + filter: .name, pageNumber: pageNumber, pageSize: pageSize, productType: .booking, diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductSelector/ProductSelectorView.swift b/WooCommerce/Classes/ViewRelated/Products/ProductSelector/ProductSelectorView.swift index e2158be1849..149c48130ad 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductSelector/ProductSelectorView.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductSelector/ProductSelectorView.swift @@ -336,7 +336,7 @@ private extension ProductSelectorView { .submitLabel(.done) .accessibilityIdentifier("product-selector-search-bar") Picker(selection: $viewModel.productSearchFilter, label: EmptyView()) { - ForEach(ProductSearchFilter.allCases, id: \.self) { option in Text(option.title) } + ForEach(ProductSearchFilter.productSelectorOptions, id: \.self) { option in Text(option.title) } } .if(geometry.size.width <= Constants.headerSearchRowWidth) { $0.pickerStyle(.menu) } .if(geometry.size.width > Constants.headerSearchRowWidth) { $0.pickerStyle(.segmented) } diff --git a/WooCommerce/Classes/ViewRelated/Search/Product/ProductSearchUICommand.swift b/WooCommerce/Classes/ViewRelated/Search/Product/ProductSearchUICommand.swift index cafc53072c6..3b407c3aa6a 100644 --- a/WooCommerce/Classes/ViewRelated/Search/Product/ProductSearchUICommand.swift +++ b/WooCommerce/Classes/ViewRelated/Search/Product/ProductSearchUICommand.swift @@ -200,22 +200,27 @@ private extension ProductSearchUICommand { } extension ProductSearchFilter { + /// The title of the option on the picker of product selector view. var title: String { switch self { case .all: return NSLocalizedString("All Products", comment: "Title of the product search filter to search for all products.") case .sku: return NSLocalizedString("SKU", comment: "Title of the product search filter to search for products that match the SKU.") + case .name: + fatalError("This option is not supported on the product selector UI") } } - /// The value that is set in the analytics event property. + /// The value that is set in the analytics event property when selecting the option on the product selector view. var analyticsValue: String { switch self { case .all: return "all" case .sku: return "sku" + case .name: + fatalError("This option is not supported on the product selector UI") } } } From b9d1b0f86d54ef1e59fc602bdbcc74a0cb293719 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Mon, 3 Nov 2025 15:08:10 +0700 Subject: [PATCH 2/4] Update tests --- .../Remote/ProductsRemoteTests.swift | 22 +++++++++++++++++++ .../Remote/MockProductsRemote.swift | 1 + 2 files changed, 23 insertions(+) diff --git a/Modules/Tests/NetworkingTests/Remote/ProductsRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/ProductsRemoteTests.swift index 9d237756d6d..c049d371529 100644 --- a/Modules/Tests/NetworkingTests/Remote/ProductsRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/ProductsRemoteTests.swift @@ -458,6 +458,7 @@ final class ProductsRemoteTests: XCTestCase { // When let products = try await remote.searchProducts(for: sampleSiteID, keyword: "photo", + searchFields: [], pageNumber: 0, pageSize: 100) @@ -475,6 +476,7 @@ final class ProductsRemoteTests: XCTestCase { do { _ = try await remote.searchProducts(for: sampleSiteID, keyword: String(), + searchFields: [], pageNumber: 0, pageSize: 100) XCTFail("Expected error to be thrown") @@ -483,6 +485,26 @@ final class ProductsRemoteTests: XCTestCase { } } + /// Verifies that searchProducts with name search field includes the search_fields param in network request. + /// + func test_searchProducts_with_name_search_field_includes_search_fields_param_in_network_request() async throws { + // Given + let remote = ProductsRemote(network: network) + network.simulateResponse(requestUrlSuffix: "products", filename: "products-search-photo") + + // When + _ = try await remote.searchProducts(for: sampleSiteID, + keyword: "test", + searchFields: [.name], + pageNumber: 0, + pageSize: 100) + + // Then + let queryParameters = try XCTUnwrap(network.queryParameters) + let expectedParam = "search_fields=[\"name\"]" + XCTAssertTrue(queryParameters.contains(expectedParam), "Expected to have param: \(expectedParam)") + } + // MARK: - Search Products by SKU func test_searchProductsBySKU_properly_returns_parsed_products() async throws { diff --git a/Modules/Tests/YosemiteTests/Mocks/Networking/Remote/MockProductsRemote.swift b/Modules/Tests/YosemiteTests/Mocks/Networking/Remote/MockProductsRemote.swift index f7e680ed280..259af02be6c 100644 --- a/Modules/Tests/YosemiteTests/Mocks/Networking/Remote/MockProductsRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/Networking/Remote/MockProductsRemote.swift @@ -286,6 +286,7 @@ extension MockProductsRemote: ProductsRemoteProtocol { func searchProducts(for siteID: Int64, keyword: String, + searchFields: [ProductSearchField], pageNumber: Int, pageSize: Int, stockStatus: ProductStockStatus?, From 26476a9f6634fef96ad62810851bcd382243011e Mon Sep 17 00:00:00 2001 From: Huong Do Date: Tue, 4 Nov 2025 18:13:10 +0700 Subject: [PATCH 3/4] Make code more readable for searchProductsByKeyword --- .../Yosemite/Stores/ProductStore.swift | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Modules/Sources/Yosemite/Stores/ProductStore.swift b/Modules/Sources/Yosemite/Stores/ProductStore.swift index a5d80c84bed..27c8f47f9e5 100644 --- a/Modules/Sources/Yosemite/Stores/ProductStore.swift +++ b/Modules/Sources/Yosemite/Stores/ProductStore.swift @@ -231,27 +231,27 @@ private extension ProductStore { productCategory: ProductCategory?, excludedProductIDs: [Int64], onCompletion: @escaping (Result) -> Void) { + /// internal helper search method + func searchProductsByKeyword(searchFields: [ProductSearchField]) async throws -> [Product] { + try await remote.searchProducts(for: siteID, + keyword: keyword, + searchFields: searchFields, + pageNumber: pageNumber, + pageSize: pageSize, + stockStatus: stockStatus, + productStatus: productStatus, + productType: productType, + productCategory: productCategory, + excludedProductIDs: excludedProductIDs) + } Task { @MainActor in do { let products: [Product] switch filter { - case .all, .name: - let searchFields: [ProductSearchField] = { - if filter == .name { - return [.name] - } - return [] - }() - products = try await remote.searchProducts(for: siteID, - keyword: keyword, - searchFields: searchFields, - pageNumber: pageNumber, - pageSize: pageSize, - stockStatus: stockStatus, - productStatus: productStatus, - productType: productType, - productCategory: productCategory, - excludedProductIDs: excludedProductIDs) + case .all: + products = try await searchProductsByKeyword(searchFields: []) + case .name: + products = try await searchProductsByKeyword(searchFields: [.name]) case .sku: products = try await remote.searchProductsBySKU(for: siteID, keyword: keyword, From 1414442a7e67de164896b4f731742507c54586c9 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Tue, 4 Nov 2025 18:16:57 +0700 Subject: [PATCH 4/4] Update documentation to match the code --- Modules/Sources/Networking/Remote/ProductsRemote.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/Networking/Remote/ProductsRemote.swift b/Modules/Sources/Networking/Remote/ProductsRemote.swift index c14e2953031..b6c76e22bb4 100644 --- a/Modules/Sources/Networking/Remote/ProductsRemote.swift +++ b/Modules/Sources/Networking/Remote/ProductsRemote.swift @@ -292,7 +292,7 @@ public final class ProductsRemote: Remote, ProductsRemoteProtocol { } /// Remote search of products for the Point of Sale. Simple and variable products are loaded for WC version 9.6+, otherwise only simple products are loaded. - /// `search` is used, which searches in `name`, `description`, `short_description` fields. + /// `search` is used, which searches in `name`, `sku`, `globalUniqueID` fields. /// We also send `search_name_or_sku`, which will be used in preference to `search` when implemented on a site (in future.) /// /// - Parameter siteID: Site for which we'll fetch remote products.