Skip to content

Commit 9f51030

Browse files
committed
Add a filter segmented control to product search.
1 parent 594ced4 commit 9f51030

File tree

12 files changed

+237
-32
lines changed

12 files changed

+237
-32
lines changed

Storage/Storage/Tools/StorageType+Extensions.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,8 +345,8 @@ public extension StorageType {
345345

346346
/// Retrieves the Stored ProductSearchResults Lookup.
347347
///
348-
func loadProductSearchResults(keyword: String) -> ProductSearchResults? {
349-
let predicate = \ProductSearchResults.keyword == keyword
348+
func loadProductSearchResults(keyword: String, filterKey: String) -> ProductSearchResults? {
349+
let predicate = \ProductSearchResults.keyword == keyword && \ProductSearchResults.filterKey == filterKey
350350
return firstObject(ofType: ProductSearchResults.self, matching: predicate)
351351
}
352352

WooCommerce/Classes/ViewRelated/Coupons/CouponSearchUICommand.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ final class CouponSearchUICommand: SearchUICommand {
1515

1616
let cancelButtonAccessibilityIdentifier = "coupon-search-screen-cancel-button"
1717

18+
var resynchronizeModels: (() -> Void) = {}
19+
1820
private let siteID: Int64
1921

2022
init(siteID: Int64) {
@@ -80,6 +82,10 @@ final class CouponSearchUICommand: SearchUICommand {
8082

8183
viewController.configure(.simple(message: message, image: .emptySearchResultsImage))
8284
}
85+
86+
func searchResultsPredicate(keyword: String) -> NSPredicate {
87+
NSPredicate(format: "ANY searchResults.keyword = %@", keyword)
88+
}
8389
}
8490

8591
// MARK: - Subtypes

WooCommerce/Classes/ViewRelated/Products/Edit Product/Linked Products List Selector/ProductListSelector/ProductListMultiSelectorSearchUICommand.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ final class ProductListMultiSelectorSearchUICommand: NSObject, SearchUICommand {
1212

1313
let cancelButtonAccessibilityIdentifier = "product-search-screen-cancel-button"
1414

15+
var resynchronizeModels: (() -> Void) = {}
16+
1517
private let siteID: Int64
1618

1719
private let excludedProductIDs: [Int64]
@@ -90,6 +92,10 @@ final class ProductListMultiSelectorSearchUICommand: NSObject, SearchUICommand {
9092
reloadData()
9193
updateActionButton()
9294
}
95+
96+
func searchResultsPredicate(keyword: String) -> NSPredicate {
97+
NSPredicate(format: "ANY searchResults.keyword = %@", keyword)
98+
}
9399
}
94100

95101
extension ProductListMultiSelectorSearchUICommand {

WooCommerce/Classes/ViewRelated/Search/Order/OrderSearchUICommand.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ final class OrderSearchUICommand: SearchUICommand {
1414

1515
let cancelButtonAccessibilityIdentifier = "order-search-screen-cancel-button"
1616

17+
var resynchronizeModels: (() -> Void) = {}
18+
1719
private lazy var statusResultsController: ResultsController<StorageOrderStatus> = {
1820
let storageManager = ServiceLocator.storageManager
1921
let predicate = NSPredicate(format: "siteID == %lld", siteID)
@@ -91,6 +93,10 @@ final class OrderSearchUICommand: SearchUICommand {
9193
}
9294
return keyword
9395
}
96+
97+
func searchResultsPredicate(keyword: String) -> NSPredicate {
98+
NSPredicate(format: "ANY searchResults.keyword = %@", keyword)
99+
}
94100
}
95101

96102
private extension OrderSearchUICommand {

WooCommerce/Classes/ViewRelated/Search/Product/ProductSearchUICommand.swift

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,18 @@ final class ProductSearchUICommand: SearchUICommand {
1212

1313
let cancelButtonAccessibilityIdentifier = "product-search-screen-cancel-button"
1414

15+
var resynchronizeModels: (() -> Void) = {}
16+
17+
private var lastSearchQueryByFilter: [ProductSearchFilter: String] = [:]
18+
private var filter: ProductSearchFilter = .all
19+
1520
private let siteID: Int64
21+
private let isSearchProductsBySKUEnabled: Bool
1622

17-
init(siteID: Int64) {
23+
init(siteID: Int64,
24+
isSearchProductsBySKUEnabled: Bool = ServiceLocator.featureFlagService.isFeatureFlagEnabled(.searchProductsBySKU)) {
1825
self.siteID = siteID
26+
self.isSearchProductsBySKUEnabled = isSearchProductsBySKUEnabled
1927
}
2028

2129
func createResultsController() -> ResultsController<ResultsControllerModel> {
@@ -30,6 +38,37 @@ final class ProductSearchUICommand: SearchUICommand {
3038
nil
3139
}
3240

41+
func createHeaderView() -> UIView? {
42+
guard isSearchProductsBySKUEnabled else {
43+
return nil
44+
}
45+
let segmentedControl: UISegmentedControl = {
46+
let segmentedControl = UISegmentedControl()
47+
48+
let filters: [ProductSearchFilter] = [.all, .sku]
49+
for (index, filter) in filters.enumerated() {
50+
segmentedControl.insertSegment(withTitle: filter.title, at: index, animated: false)
51+
if filter == self.filter {
52+
segmentedControl.selectedSegmentIndex = index
53+
}
54+
}
55+
segmentedControl.on(.valueChanged) { [weak self] sender in
56+
let index = sender.selectedSegmentIndex
57+
guard let filter = filters[safe: index] else {
58+
return
59+
}
60+
self?.showResults(filter: filter)
61+
}
62+
return segmentedControl
63+
}()
64+
65+
let containerView = UIView(frame: .zero)
66+
containerView.addSubview(segmentedControl)
67+
segmentedControl.translatesAutoresizingMaskIntoConstraints = false
68+
containerView.pinSubviewToAllEdges(segmentedControl, insets: .init(top: 8, left: 16, bottom: 16, right: 16))
69+
return containerView
70+
}
71+
3372
func configureEmptyStateViewControllerBeforeDisplay(viewController: EmptyStateViewController,
3473
searchKeyword: String) {
3574
let boldSearchKeyword = NSAttributedString(string: searchKeyword,
@@ -50,8 +89,18 @@ final class ProductSearchUICommand: SearchUICommand {
5089
/// Synchronizes the Products matching a given Keyword
5190
///
5291
func synchronizeModels(siteID: Int64, keyword: String, pageNumber: Int, pageSize: Int, onCompletion: ((Bool) -> Void)?) {
92+
if isSearchProductsBySKUEnabled {
93+
// Returns early if the search query is the same for the given filter to avoid duplicate API requests when switching filter tabs.
94+
if let lastFilterSearchQuery = lastSearchQueryByFilter[filter], lastFilterSearchQuery == keyword {
95+
onCompletion?(true)
96+
return
97+
}
98+
lastSearchQueryByFilter[filter] = keyword
99+
}
100+
53101
let action = ProductAction.searchProducts(siteID: siteID,
54102
keyword: keyword,
103+
filter: filter,
55104
pageNumber: pageNumber,
56105
pageSize: pageSize) { result in
57106
if case let .failure(error) = result {
@@ -71,4 +120,33 @@ final class ProductSearchUICommand: SearchUICommand {
71120
viewController?.navigationController?.pushViewController(vc, animated: true)
72121
}
73122
}
123+
124+
func searchResultsPredicate(keyword: String) -> NSPredicate {
125+
guard isSearchProductsBySKUEnabled else {
126+
return NSPredicate(format: "ANY searchResults.keyword = %@", keyword)
127+
}
128+
return NSPredicate(format: "SUBQUERY(searchResults, $result, $result.keyword = %@ AND $result.filterKey = %@).@count > 0",
129+
keyword, filter.rawValue)
130+
}
131+
}
132+
133+
private extension ProductSearchUICommand {
134+
func showResults(filter: ProductSearchFilter) {
135+
guard filter != self.filter else {
136+
return
137+
}
138+
self.filter = filter
139+
resynchronizeModels()
140+
}
141+
}
142+
143+
private extension ProductSearchFilter {
144+
var title: String {
145+
switch self {
146+
case .all:
147+
return NSLocalizedString("All Products", comment: "Title of the product search filter to search for all products.")
148+
case .sku:
149+
return NSLocalizedString("SKU", comment: "Title of the product search filter to search for products that match the SKU.")
150+
}
151+
}
74152
}

WooCommerce/Classes/ViewRelated/Search/SearchUICommand.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ protocol SearchUICommand {
1010
/// The placeholder of the search bar.
1111
var searchBarPlaceholder: String { get }
1212

13+
/// A closure to resynchronize models if the data source might change (e.g. when the filter changes in products search).
14+
var resynchronizeModels: (() -> Void) { get set }
15+
1316
associatedtype ResultsControllerModel: ResultsControllerMutableType where ResultsControllerModel.ReadOnlyType == Model
1417
/// Creates a results controller for the search results. The result model's readonly type matches the search result model.
1518
func createResultsController() -> ResultsController<ResultsControllerModel>
@@ -51,6 +54,10 @@ protocol SearchUICommand {
5154
func configureEmptyStateViewControllerBeforeDisplay(viewController: EmptyStateViewControllerType,
5255
searchKeyword: String)
5356

57+
/// The optional view to show between the search bar and search results table view.
58+
/// If `nil`, the search bar is right above the search results.
59+
func createHeaderView() -> UIView?
60+
5461
/// Optionally configures the action button that dismisses the search UI.
5562
/// - Parameters:
5663
/// - button: the button in the navigation bar that dismisses the search UI. Shows "Cancel" by default.
@@ -90,6 +97,12 @@ protocol SearchUICommand {
9097
/// - Parameter keyword: user-entered search keyword.
9198
/// - Returns: sanitized search keyword.
9299
func sanitizeKeyword(_ keyword: String) -> String
100+
101+
/// The predicate to fetch product search results based on the keyword.
102+
/// Called when a search API request is made for the keyword.
103+
/// - Parameter keyword: search query.
104+
/// - Returns: predicate that is based on the search keyword.
105+
func searchResultsPredicate(keyword: String) -> NSPredicate
93106
}
94107

95108
// MARK: - Default implementation
@@ -102,6 +115,11 @@ extension SearchUICommand {
102115
// If not implemented, returns the keyword as entered
103116
return keyword
104117
}
118+
119+
func createHeaderView() -> UIView? {
120+
// If not implemented, returns `nil` to not show the header.
121+
nil
122+
}
105123
}
106124

107125
// MARK: - SearchUICommand using EmptySearchResultsViewController

WooCommerce/Classes/ViewRelated/Search/SearchViewController.swift

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ where Cell.SearchModel == Command.CellViewModel {
2121
///
2222
@IBOutlet private var searchBar: UISearchBar!
2323

24+
/// Optional header view between the search bar and table view.
25+
@IBOutlet private weak var headerView: UIView!
26+
2427
/// TableView
2528
///
2629
@IBOutlet private var tableView: UITableView!
@@ -92,7 +95,7 @@ where Cell.SearchModel == Command.CellViewModel {
9295
return keyboardFrameObserver
9396
}()
9497

95-
private let searchUICommand: Command
98+
private var searchUICommand: Command
9699
private let tableViewSeparatorStyle: UITableViewCell.SeparatorStyle
97100

98101

@@ -131,9 +134,11 @@ where Cell.SearchModel == Command.CellViewModel {
131134
configureMainView()
132135
configureSearchBar()
133136
configureSearchBarBordersView()
137+
configureHeaderView()
134138
configureTableView()
135139
configureResultsController()
136140
configureStarterViewController()
141+
configureSearchResync()
137142

138143
startListeningToNotifications()
139144

@@ -290,6 +295,19 @@ private extension SearchViewController {
290295
cancelButton.accessibilityIdentifier = searchUICommand.cancelButtonAccessibilityIdentifier
291296
}
292297

298+
func configureHeaderView() {
299+
if let searchHeaderView = searchUICommand.createHeaderView() {
300+
headerView.addSubview(searchHeaderView)
301+
searchHeaderView.translatesAutoresizingMaskIntoConstraints = false
302+
headerView.pinSubviewToSafeArea(searchHeaderView)
303+
} else {
304+
headerView.isHidden = true
305+
NSLayoutConstraint.activate([
306+
headerView.heightAnchor.constraint(equalToConstant: 0)
307+
])
308+
}
309+
}
310+
293311
/// Setup: Actions
294312
///
295313
func configureActions() {
@@ -368,6 +386,13 @@ private extension SearchViewController {
368386
self?.synchronizeSearchResults(with: query)
369387
}
370388
}
389+
390+
func configureSearchResync() {
391+
searchUICommand.resynchronizeModels = { [weak self] in
392+
guard let self = self else { return }
393+
self.synchronizeSearchResults(with: self.searchQuery)
394+
}
395+
}
371396
}
372397

373398

@@ -378,6 +403,7 @@ extension SearchViewController: SyncingCoordinatorDelegate {
378403
/// Synchronizes the models for the Default Store (if any).
379404
///
380405
func sync(pageNumber: Int, pageSize: Int, reason: String?, onCompletion: ((Bool) -> Void)? = nil) {
406+
transitionToSyncingState()
381407
let keyword = searchUICommand.sanitizeKeyword(searchQuery)
382408
searchUICommand.synchronizeModels(siteID: storeID,
383409
keyword: keyword,
@@ -391,7 +417,6 @@ extension SearchViewController: SyncingCoordinatorDelegate {
391417
}
392418
onCompletion?(isCompleted)
393419
})
394-
transitionToSyncingState()
395420
}
396421
}
397422

@@ -405,7 +430,7 @@ private extension SearchViewController {
405430
func synchronizeSearchResults(with keyword: String) {
406431
// When the search query changes, also includes the original results predicate in addition to the search keyword.
407432
let keyword = searchUICommand.sanitizeKeyword(keyword)
408-
let searchResultsPredicate = NSPredicate(format: "ANY searchResults.keyword = %@", keyword)
433+
let searchResultsPredicate = searchUICommand.searchResultsPredicate(keyword: keyword)
409434
let subpredicates = [resultsPredicate].compactMap { $0 } + [searchResultsPredicate]
410435
resultsController.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
411436

@@ -485,7 +510,7 @@ private extension SearchViewController {
485510
NSLayoutConstraint.activate([
486511
childView.leadingAnchor.constraint(equalTo: tableView.leadingAnchor),
487512
childView.trailingAnchor.constraint(equalTo: tableView.trailingAnchor),
488-
childView.topAnchor.constraint(equalTo: searchBar.bottomAnchor),
513+
childView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
489514
childView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
490515
])
491516

WooCommerce/Classes/ViewRelated/Search/SearchViewController.xib

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
2+
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
33
<device id="retina4_7" orientation="portrait" appearance="light"/>
44
<dependencies>
5-
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
5+
<deployment identifier="iOS"/>
6+
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
67
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
8+
<capability name="System colors in document resources" minToolsVersion="11.0"/>
79
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
810
</dependencies>
911
<objects>
1012
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="SearchViewController" customModule="WooCommerce" customModuleProvider="target">
1113
<connections>
1214
<outlet property="bordersView" destination="Ncr-u2-zVd" id="S5W-h1-qR2"/>
1315
<outlet property="cancelButton" destination="YBC-g3-0LP" id="rmu-WT-3G1"/>
16+
<outlet property="headerView" destination="3D3-Fa-C7n" id="WP4-6d-ICd"/>
1417
<outlet property="searchBar" destination="cOC-iR-MJr" id="usQ-SP-NSs"/>
1518
<outlet property="tableView" destination="y9n-QI-xEB" id="WSo-bN-Vnv"/>
1619
<outlet property="view" destination="chb-2b-70h" id="wNB-f6-JWl"/>
@@ -43,7 +46,11 @@
4346
<action selector="dismissWasPressed" destination="-1" eventType="touchUpInside" id="xwh-lf-NJz"/>
4447
</connections>
4548
</button>
46-
<tableView clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" translatesAutoresizingMaskIntoConstraints="NO" id="y9n-QI-xEB">
49+
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="3D3-Fa-C7n">
50+
<rect key="frame" x="0.0" y="10" width="375" height="41"/>
51+
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
52+
</view>
53+
<tableView clipsSubviews="YES" contentMode="scaleToFill" ambiguous="YES" alwaysBounceVertical="YES" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" translatesAutoresizingMaskIntoConstraints="NO" id="y9n-QI-xEB">
4754
<rect key="frame" x="0.0" y="51" width="375" height="616"/>
4855
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
4956
<connections>
@@ -55,13 +62,16 @@
5562
<viewLayoutGuide key="safeArea" id="l4O-t2-oPf"/>
5663
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
5764
<constraints>
65+
<constraint firstItem="3D3-Fa-C7n" firstAttribute="top" secondItem="cOC-iR-MJr" secondAttribute="bottom" id="1cN-Hx-OP0"/>
5866
<constraint firstItem="Ncr-u2-zVd" firstAttribute="leading" secondItem="chb-2b-70h" secondAttribute="leading" id="3GU-ol-Uc5"/>
67+
<constraint firstItem="l4O-t2-oPf" firstAttribute="trailing" secondItem="3D3-Fa-C7n" secondAttribute="trailing" id="74H-a0-TJ8"/>
5968
<constraint firstItem="cOC-iR-MJr" firstAttribute="bottom" secondItem="Ncr-u2-zVd" secondAttribute="bottom" id="A3E-fX-OKH"/>
6069
<constraint firstItem="y9n-QI-xEB" firstAttribute="leading" secondItem="chb-2b-70h" secondAttribute="leading" id="B3k-rC-VuB"/>
6170
<constraint firstItem="l4O-t2-oPf" firstAttribute="trailing" secondItem="YBC-g3-0LP" secondAttribute="trailing" constant="16" id="Dvf-RD-FUS"/>
6271
<constraint firstItem="YBC-g3-0LP" firstAttribute="centerY" secondItem="cOC-iR-MJr" secondAttribute="centerY" constant="-1" id="JLs-22-kWd"/>
63-
<constraint firstItem="y9n-QI-xEB" firstAttribute="top" secondItem="cOC-iR-MJr" secondAttribute="bottom" id="QMH-4a-wFC"/>
6472
<constraint firstItem="cOC-iR-MJr" firstAttribute="top" secondItem="l4O-t2-oPf" secondAttribute="top" id="c7h-ay-W2D"/>
73+
<constraint firstItem="3D3-Fa-C7n" firstAttribute="bottom" secondItem="y9n-QI-xEB" secondAttribute="top" id="eKa-Nm-JUM"/>
74+
<constraint firstItem="3D3-Fa-C7n" firstAttribute="leading" secondItem="l4O-t2-oPf" secondAttribute="leading" id="elN-rT-CdI"/>
6575
<constraint firstItem="YBC-g3-0LP" firstAttribute="leading" secondItem="cOC-iR-MJr" secondAttribute="trailing" constant="3" id="nSK-Od-9bp"/>
6676
<constraint firstAttribute="trailing" secondItem="y9n-QI-xEB" secondAttribute="trailing" id="qbt-la-21b"/>
6777
<constraint firstItem="l4O-t2-oPf" firstAttribute="bottom" secondItem="y9n-QI-xEB" secondAttribute="bottom" id="rig-yz-o3K"/>
@@ -73,4 +83,9 @@
7383
<point key="canvasLocation" x="108" y="139.880059970015"/>
7484
</view>
7585
</objects>
86+
<resources>
87+
<systemColor name="systemBackgroundColor">
88+
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
89+
</systemColor>
90+
</resources>
7691
</document>

0 commit comments

Comments
 (0)