Skip to content

Commit b462ab4

Browse files
authored
Merge pull request #7528 from woocommerce/issue/7464-fix-search-filter-product
Product selector: Stop clearing product store and fix issue combining search term with filter
2 parents 36e0af5 + 66b3d24 commit b462ab4

File tree

3 files changed

+87
-43
lines changed

3 files changed

+87
-43
lines changed

WooCommerce/Classes/ViewRelated/Products/ProductSelector/ProductSelectorViewModel.swift

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -33,20 +33,7 @@ final class ProductSelectorViewModel: ObservableObject {
3333

3434
/// Selected filter for the product list
3535
///
36-
var filters: FilterProductListViewModel.Filters = FilterProductListViewModel.Filters() {
37-
didSet {
38-
let contentIsNotSyncedYet = syncingCoordinator.highestPageBeingSynced ?? 0 == 0
39-
if filters != oldValue || contentIsNotSyncedYet {
40-
updateFilterButtonTitle()
41-
productsResultsController.updatePredicate(siteID: siteID,
42-
stockStatus: filters.stockStatus,
43-
productStatus: filters.productStatus,
44-
productType: filters.productType)
45-
updateProductsResultsController()
46-
syncingCoordinator.resynchronize {}
47-
}
48-
}
49-
}
36+
@Published var filters = FilterProductListViewModel.Filters()
5037

5138
/// Title of the filter button, should be updated with number of active filters.
5239
///
@@ -110,9 +97,9 @@ final class ProductSelectorViewModel: ObservableObject {
11097

11198
/// Predicate for the results controller.
11299
///
113-
private lazy var resultsPredicate: NSPredicate? = {
100+
private var resultsPredicate: NSPredicate? {
114101
productsResultsController.predicate
115-
}()
102+
}
116103

117104
/// Current search term entered by the user.
118105
/// Each update will trigger a remote product search and sync.
@@ -407,6 +394,22 @@ private extension ProductSelectorViewModel {
407394
}
408395
}
409396

397+
func updatePredicate(searchTerm: String, filters: FilterProductListViewModel.Filters) {
398+
productsResultsController.updatePredicate(siteID: siteID,
399+
stockStatus: filters.stockStatus,
400+
productStatus: filters.productStatus,
401+
productType: filters.productType)
402+
if searchTerm.isNotEmpty {
403+
// When the search query changes, also includes the original results predicate in addition to the search keyword.
404+
let searchResultsPredicate = NSPredicate(format: "ANY searchResults.keyword = %@", searchTerm)
405+
let subpredicates = [resultsPredicate, searchResultsPredicate].compactMap { $0 }
406+
productsResultsController.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
407+
} else {
408+
// Resets the results to the full product list when there is no search query.
409+
productsResultsController.predicate = resultsPredicate
410+
}
411+
}
412+
410413
/// Setup: Syncing Coordinator
411414
///
412415
func configureSyncingCoordinator() {
@@ -428,28 +431,22 @@ private extension ProductSelectorViewModel {
428431
/// Updates the product results predicate & triggers a new sync when search term changes
429432
///
430433
func configureProductSearch() {
431-
$searchTerm
434+
let searchTermPublisher = $searchTerm
432435
.dropFirst() // Drop initial value
433436
.removeDuplicates()
434437
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
435-
.sink { [weak self] newSearchTerm in
436-
guard let self = self else { return }
437-
438-
if newSearchTerm.isNotEmpty {
439-
// When the search query changes, also includes the original results predicate in addition to the search keyword.
440-
let searchResultsPredicate = NSPredicate(format: "ANY searchResults.keyword = %@", newSearchTerm)
441-
let subpredicates = [self.resultsPredicate, searchResultsPredicate].compactMap { $0 }
442-
self.productsResultsController.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
443-
} else {
444-
// Resets the results to the full product list when there is no search query.
445-
self.productsResultsController.predicate = self.resultsPredicate
446-
}
447438

439+
searchTermPublisher.combineLatest($filters.removeDuplicates())
440+
.sink { [weak self] searchTerm, filters in
441+
guard let self = self else { return }
442+
self.updateFilterButtonTitle(with: filters)
443+
self.updatePredicate(searchTerm: searchTerm, filters: filters)
444+
self.updateProductsResultsController()
448445
self.syncingCoordinator.resynchronize()
449446
}.store(in: &subscriptions)
450447
}
451448

452-
func updateFilterButtonTitle() {
449+
func updateFilterButtonTitle(with filters: FilterProductListViewModel.Filters) {
453450
let activeFiltersCount = filters.numberOfActiveFilters
454451
if activeFiltersCount == 0 {
455452
filterButtonTitle = Localization.filterButtonWithoutActiveFilters

WooCommerce/WooCommerceTests/ViewRelated/Orders/Order Creation/ProductSelectorViewModelTests.swift

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ final class ProductSelectorViewModelTests: XCTestCase {
1111
storageManager.viewStorage
1212
}
1313
private let stores = MockStoresManager(sessionManager: .testingInstance)
14+
private let searchDebounceTime: UInt64 = 600_000_000 // 500 milliseconds with buffer
1415

1516
override func setUp() {
1617
super.setUp()
@@ -472,26 +473,29 @@ final class ProductSelectorViewModelTests: XCTestCase {
472473
XCTAssertEqual(selectedItems, [simpleProduct.productID, 12])
473474
}
474475

475-
func test_filter_button_title_shows_correct_number_of_active_filters() {
476+
func test_filter_button_title_shows_correct_number_of_active_filters() async throws {
476477
// Given
477478
let viewModel = ProductSelectorViewModel(siteID: sampleSiteID)
479+
let defaultTitle = NSLocalizedString("Filter", comment: "")
478480
// confidence check
479-
XCTAssertEqual(viewModel.filterButtonTitle, NSLocalizedString("Filter", comment: ""))
481+
XCTAssertEqual(viewModel.filterButtonTitle, defaultTitle)
480482

481483
// When
484+
viewModel.searchTerm = ""
482485
viewModel.filters = FilterProductListViewModel.Filters(
483486
stockStatus: ProductStockStatus.outOfStock,
484487
productStatus: ProductStatus.draft,
485488
productType: ProductType.simple,
486489
productCategory: nil,
487490
numberOfActiveFilters: 3
488491
)
492+
try await Task.sleep(nanoseconds: searchDebounceTime)
489493

490494
// Then
491495
XCTAssertEqual(viewModel.filterButtonTitle, String.localizedStringWithFormat(NSLocalizedString("Filter (%ld)", comment: ""), 3))
492496
}
493497

494-
func test_productRows_are_updated_correctly_when_filters_are_applied() {
498+
func test_productRows_are_updated_correctly_when_filters_are_applied() async throws {
495499
// Given
496500
let simpleProduct = Product.fake().copy(siteID: sampleSiteID, productID: 1, productTypeKey: ProductType.simple.rawValue, purchasable: true)
497501
let variableProduct = Product.fake().copy(siteID: sampleSiteID, productID: 10, productTypeKey: ProductType.variable.rawValue, purchasable: true)
@@ -507,6 +511,8 @@ final class ProductSelectorViewModelTests: XCTestCase {
507511
productCategory: nil,
508512
numberOfActiveFilters: 1
509513
)
514+
viewModel.searchTerm = ""
515+
try await Task.sleep(nanoseconds: searchDebounceTime)
510516

511517
// Then
512518
XCTAssertEqual(viewModel.productRows.count, 1)
@@ -552,7 +558,7 @@ final class ProductSelectorViewModelTests: XCTestCase {
552558
XCTAssertEqual(variableProductRow?.selectedState, .notSelected)
553559
}
554560

555-
func test_synchronizeProducts_are_triggered_with_correct_filters() {
561+
func test_synchronizeProducts_are_triggered_with_correct_filters() async throws {
556562
// Given
557563
let viewModel = ProductSelectorViewModel(siteID: sampleSiteID, stores: stores)
558564
var filteredStockStatus: ProductStockStatus?
@@ -581,6 +587,8 @@ final class ProductSelectorViewModelTests: XCTestCase {
581587

582588
// When
583589
viewModel.filters = filters
590+
viewModel.searchTerm = ""
591+
try await Task.sleep(nanoseconds: searchDebounceTime)
584592

585593
// Then
586594
assertEqual(filteredStockStatus, filters.stockStatus)
@@ -589,7 +597,7 @@ final class ProductSelectorViewModelTests: XCTestCase {
589597
assertEqual(filteredProductCategory, filters.productCategory)
590598
}
591599

592-
func test_searchProducts_are_triggered_with_correct_filters() {
600+
func test_searchProducts_are_triggered_with_correct_filters() async throws {
593601
// Given
594602
let viewModel = ProductSelectorViewModel(siteID: sampleSiteID, stores: stores)
595603
var filteredStockStatus: ProductStockStatus?
@@ -617,15 +625,60 @@ final class ProductSelectorViewModelTests: XCTestCase {
617625
}
618626

619627
// When
620-
viewModel.searchTerm = "hiii"
621628
viewModel.filters = filters
629+
viewModel.searchTerm = "hiii"
630+
try await Task.sleep(nanoseconds: searchDebounceTime)
622631

623632
// Then
624633
assertEqual(filteredStockStatus, filters.stockStatus)
625634
assertEqual(filteredProductType, filters.productType)
626635
assertEqual(filteredProductStatus, filters.productStatus)
627636
assertEqual(filteredProductCategory, filters.productCategory)
628637
}
638+
639+
func test_search_term_and_filters_are_combined_to_get_correct_results() {
640+
// Given
641+
let bolognese = Product.fake().copy(siteID: sampleSiteID, productID: 1, name: "Bolognese spaghetti", productTypeKey: ProductType.simple.rawValue)
642+
let carbonara = Product.fake().copy(siteID: sampleSiteID, productID: 23, name: "Carbonara spaghetti", productTypeKey: ProductType.simple.rawValue)
643+
let pizza = Product.fake().copy(siteID: sampleSiteID, productID: 11, name: "Pizza", productTypeKey: ProductType.variable.rawValue)
644+
insert(pizza)
645+
insert(bolognese, withSearchTerm: "spa")
646+
insert(carbonara, withSearchTerm: "spa")
647+
648+
let viewModel = ProductSelectorViewModel(siteID: sampleSiteID, storageManager: storageManager)
649+
XCTAssertEqual(viewModel.productRows.count, 3) // Confidence check
650+
651+
// When
652+
viewModel.searchTerm = "spa"
653+
waitUntil {
654+
viewModel.productRows.count != 3
655+
}
656+
657+
// Then
658+
XCTAssertEqual(viewModel.productRows.count, 2) // 2 spaghetti
659+
660+
// When
661+
let updatedFilters = FilterProductListViewModel.Filters(
662+
stockStatus: nil,
663+
productStatus: nil,
664+
productType: ProductType.variable,
665+
productCategory: nil,
666+
numberOfActiveFilters: 1
667+
)
668+
viewModel.filters = updatedFilters
669+
670+
// Then
671+
XCTAssertEqual(viewModel.productRows.count, 0) // no product matches the filter and search term
672+
673+
// When
674+
viewModel.searchTerm = ""
675+
waitUntil {
676+
viewModel.productRows.isNotEmpty
677+
}
678+
679+
// Then
680+
XCTAssertEqual(viewModel.productRows.count, 1) // only 1 variable product "Pizza"
681+
}
629682
}
630683

631684
// MARK: - Utils

Yosemite/Yosemite/Stores/ProductStore.swift

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -141,11 +141,9 @@ private extension ProductStore {
141141
excludedProductIDs: excludedProductIDs) { [weak self] result in
142142
switch result {
143143
case .success(let products):
144-
let shouldDeleteExistingProducts = pageNumber == Default.firstPageNumber
145144
self?.upsertSearchResultsInBackground(siteID: siteID,
146145
keyword: keyword,
147-
readOnlyProducts: products,
148-
shouldDeleteExistingProducts: shouldDeleteExistingProducts) {
146+
readOnlyProducts: products) {
149147
onCompletion(.success(()))
150148
}
151149
case .failure(let error):
@@ -673,13 +671,9 @@ private extension ProductStore {
673671
private func upsertSearchResultsInBackground(siteID: Int64,
674672
keyword: String,
675673
readOnlyProducts: [Networking.Product],
676-
shouldDeleteExistingProducts: Bool = false,
677674
onCompletion: @escaping () -> Void) {
678675
let derivedStorage = sharedDerivedStorage
679676
derivedStorage.perform { [weak self] in
680-
if shouldDeleteExistingProducts {
681-
derivedStorage.deleteProducts(siteID: siteID)
682-
}
683677
self?.upsertStoredProducts(readOnlyProducts: readOnlyProducts, in: derivedStorage)
684678
self?.upsertStoredResults(siteID: siteID, keyword: keyword, readOnlyProducts: readOnlyProducts, in: derivedStorage)
685679
}

0 commit comments

Comments
 (0)