Skip to content

Commit 401163e

Browse files
authored
Merge pull request #7781 from woocommerce/feat/3157-search-sku-cell-and-analytics
Search products by SKU: show SKU to search result cell, empty query optimization, analytics
2 parents a75994f + 3c494a2 commit 401163e

File tree

6 files changed

+135
-6
lines changed

6 files changed

+135
-6
lines changed

Experiments/Experiments/DefaultFeatureFlagService.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
3434
case .promptToEnableCodInIppOnboarding:
3535
return true
3636
case .searchProductsBySKU:
37-
return buildConfig == .localDeveloper || buildConfig == .alpha
37+
return true
3838
case .orderCreationSearchCustomers:
3939
return buildConfig == .localDeveloper || buildConfig == .alpha
4040
default:

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
10.6
44
-----
55

6+
- [**] Products tab: products search now has an option to search products by SKU. Stores with WC version 6.6+ support partial SKU search, otherwise the product(s) with the exact SKU match is returned. [https://github.com/woocommerce/woocommerce-ios/pull/7781]
67
- [*] Fixed a rare crash when selecting a store in the store picker. [https://github.com/woocommerce/woocommerce-ios/pull/7765]
78
- [*] Help center: Added help center web page with FAQs for "Not a WooCommerce site" and "Wrong WordPress.com account" error screens. [https://github.com/woocommerce/woocommerce-ios/pull/7767, https://github.com/woocommerce/woocommerce-ios/pull/7769]
89

WooCommerce/Classes/ViewRelated/Products/View Models/ProductsTabProductViewModel.swift

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,15 @@ struct ProductsTabProductViewModel {
3131
productVariation: ProductVariation? = nil,
3232
isSelected: Bool = false,
3333
isDraggable: Bool = false,
34+
isSKUShown: Bool = false,
3435
imageService: ImageService = ServiceLocator.imageService) {
3536

3637
imageUrl = product.images.first?.src
3738
name = product.name.isEmpty ? Localization.noTitle : product.name
3839
self.productVariation = productVariation
3940
self.isSelected = isSelected
4041
self.isDraggable = isDraggable
41-
detailsAttributedString = EditableProductModel(product: product).createDetailsAttributedString()
42+
detailsAttributedString = EditableProductModel(product: product).createDetailsAttributedString(isSKUShown: isSKUShown)
4243

4344
self.imageService = imageService
4445
}
@@ -57,16 +58,18 @@ struct ProductsTabProductViewModel {
5758
}
5859

5960
private extension EditableProductModel {
60-
func createDetailsAttributedString() -> NSAttributedString {
61+
func createDetailsAttributedString(isSKUShown: Bool) -> NSAttributedString {
6162
let statusText = createStatusText()
6263
let stockText = createStockText()
6364
let variationsText = createVariationsText()
6465

6566
let detailsText = [statusText, stockText, variationsText]
6667
.compactMap({ $0 })
6768
.joined(separator: "")
69+
let skuText = isSKUShown ? createSKUText(): nil
70+
let text = [detailsText, skuText].compactMap { $0 }.joined(separator: "\n")
6871

69-
let attributedString = NSMutableAttributedString(string: detailsText,
72+
let attributedString = NSMutableAttributedString(string: text,
7073
attributes: [
7174
.foregroundColor: UIColor.textSubtle,
7275
.font: StyleManager.footerLabelFont
@@ -97,6 +100,13 @@ private extension EditableProductModel {
97100
plural: Localization.VariationCount.plural)
98101
return String.localizedStringWithFormat(format, numberOfVariations)
99102
}
103+
104+
func createSKUText() -> String? {
105+
guard let sku = product.sku, sku.isNotEmpty else {
106+
return nil
107+
}
108+
return String.localizedStringWithFormat(Localization.skuFormat, sku)
109+
}
100110
}
101111

102112
// MARK: Localization
@@ -109,6 +119,7 @@ private extension EditableProductModel {
109119
static let plural = NSLocalizedString("%1$ld variations",
110120
comment: "Label about number of variations shown on Products tab. Reads, `2 variations`")
111121
}
122+
static let skuFormat = NSLocalizedString("SKU: %1$@", comment: "Label about the SKU of a product in the product list. Reads, `SKU: productSku`")
112123
}
113124
}
114125

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,16 @@ final class ProductSearchUICommand: SearchUICommand {
1919

2020
private let siteID: Int64
2121
private let stores: StoresManager
22+
private let analytics: Analytics
2223
private let isSearchProductsBySKUEnabled: Bool
2324

2425
init(siteID: Int64,
2526
stores: StoresManager = ServiceLocator.stores,
27+
analytics: Analytics = ServiceLocator.analytics,
2628
isSearchProductsBySKUEnabled: Bool = ServiceLocator.featureFlagService.isFeatureFlagEnabled(.searchProductsBySKU)) {
2729
self.siteID = siteID
2830
self.stores = stores
31+
self.analytics = analytics
2932
self.isSearchProductsBySKUEnabled = isSearchProductsBySKUEnabled
3033
}
3134

@@ -86,7 +89,7 @@ final class ProductSearchUICommand: SearchUICommand {
8689
}
8790

8891
func createCellViewModel(model: Product) -> ProductsTabProductViewModel {
89-
return ProductsTabProductViewModel(product: model)
92+
ProductsTabProductViewModel(product: model, isSKUShown: true)
9093
}
9194

9295
/// Synchronizes the Products matching a given Keyword
@@ -101,6 +104,11 @@ final class ProductSearchUICommand: SearchUICommand {
101104
onCompletion?(true)
102105
return
103106
}
107+
// Skips the product search API request if the keyword is empty.
108+
guard keyword.isNotEmpty else {
109+
onCompletion?(true)
110+
return
111+
}
104112
lastSearchQueryByFilter[filter] = keyword
105113
}
106114

@@ -118,7 +126,7 @@ final class ProductSearchUICommand: SearchUICommand {
118126

119127
stores.dispatch(action)
120128

121-
ServiceLocator.analytics.track(.productListSearched)
129+
analytics.track(.productListSearched, withProperties: isSearchProductsBySKUEnabled ? ["filter": filter.analyticsValue]: nil)
122130
}
123131

124132
func didSelectSearchResult(model: Product, from viewController: UIViewController, reloadData: () -> Void, updateActionButton: () -> Void) {
@@ -158,4 +166,14 @@ private extension ProductSearchFilter {
158166
return NSLocalizedString("SKU", comment: "Title of the product search filter to search for products that match the SKU.")
159167
}
160168
}
169+
170+
/// The value that is set in the analytics event property.
171+
var analyticsValue: String {
172+
switch self {
173+
case .all:
174+
return "all"
175+
case .sku:
176+
return "sku"
177+
}
178+
}
161179
}

WooCommerce/WooCommerceTests/ViewRelated/Products/ProductsTabProductViewModelTests.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,33 @@ final class ProductsTabProductViewModelTests: XCTestCase {
9393
XCTAssertTrue(detailsText.contains(expectedStockDetail))
9494
}
9595

96+
func test_details_contain_sku_when_isSKUShown_is_true() {
97+
// Given
98+
let sku = "pear"
99+
let product = productMock(name: "Yay").copy(sku: sku)
100+
101+
// When
102+
let viewModel = ProductsTabProductViewModel(product: product, isSKUShown: true)
103+
let detailsText = viewModel.detailsAttributedString.string
104+
105+
// Then
106+
let expectedSKU = String.localizedStringWithFormat(Localization.skuFormat, sku)
107+
XCTAssertTrue(detailsText.contains(expectedSKU))
108+
}
109+
110+
func test_details_do_not_contain_sku_when_isSKUShown_is_false() {
111+
// Given
112+
let sku = "pear"
113+
let product = productMock(name: "Yay").copy(sku: sku)
114+
115+
// When
116+
let viewModel = ProductsTabProductViewModel(product: product, isSKUShown: false)
117+
let detailsText = viewModel.detailsAttributedString.string
118+
119+
// Then
120+
let skuText = String.localizedStringWithFormat(Localization.skuFormat, sku)
121+
XCTAssertFalse(detailsText.contains(skuText))
122+
}
96123
}
97124

98125
extension ProductsTabProductViewModelTests {
@@ -109,3 +136,9 @@ extension ProductsTabProductViewModelTests {
109136
variations: variations)
110137
}
111138
}
139+
140+
private extension ProductsTabProductViewModelTests {
141+
enum Localization {
142+
static let skuFormat = NSLocalizedString("SKU: %1$@", comment: "Label about the SKU of a product in the product list. Reads, `SKU: productSku`")
143+
}
144+
}

WooCommerce/WooCommerceTests/ViewRelated/Search/Product/ProductSearchUICommandTests.swift

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ import Yosemite
55

66
final class ProductSearchUICommandTests: XCTestCase {
77
private let sampleSiteID: Int64 = 134
8+
private var analyticsProvider: MockAnalyticsProvider!
9+
private var analytics: WooAnalytics!
10+
11+
override func setUp() {
12+
super.setUp()
13+
analyticsProvider = MockAnalyticsProvider()
14+
analytics = WooAnalytics(analyticsProvider: analyticsProvider)
15+
}
16+
17+
override func tearDown() {
18+
analytics = nil
19+
analyticsProvider = nil
20+
super.tearDown()
21+
}
822

923
// MARK: - `createHeaderView`
1024

@@ -112,4 +126,56 @@ final class ProductSearchUICommandTests: XCTestCase {
112126
}
113127
XCTAssertEqual(invocationCount, 2)
114128
}
129+
130+
func test_synchronizeModels_does_not_dispatch_search_action_when_keyword_is_empty() {
131+
// Given
132+
let stores = MockStoresManager(sessionManager: .testingInstance)
133+
let command = ProductSearchUICommand(siteID: sampleSiteID, isSearchProductsBySKUEnabled: true)
134+
135+
var invocationCount = 0
136+
stores.whenReceivingAction(ofType: ProductAction.self) { action in
137+
guard case let .searchProducts(_, _, _, _, _, _, _, _, _, _, onCompletion) = action else {
138+
return XCTFail("Unexpected action: \(action)")
139+
}
140+
invocationCount += 1
141+
onCompletion(.success(()))
142+
}
143+
144+
// When
145+
waitFor { promise in
146+
command.synchronizeModels(siteID: self.sampleSiteID, keyword: "", pageNumber: 1, pageSize: 10) { _ in
147+
promise(())
148+
}
149+
}
150+
151+
// Then
152+
XCTAssertEqual(invocationCount, 0)
153+
}
154+
155+
// MARK: - Analytics
156+
157+
func test_productListSearched_is_tracked_when_synchronizing_models() throws {
158+
// Given
159+
let stores = MockStoresManager(sessionManager: .testingInstance)
160+
let command = ProductSearchUICommand(siteID: sampleSiteID, stores: stores, analytics: analytics, isSearchProductsBySKUEnabled: true)
161+
stores.whenReceivingAction(ofType: ProductAction.self) { action in
162+
guard case let .searchProducts(_, _, _, _, _, _, _, _, _, _, onCompletion) = action else {
163+
return XCTFail("Unexpected action: \(action)")
164+
}
165+
onCompletion(.success(()))
166+
}
167+
168+
// When
169+
waitFor { promise in
170+
command.synchronizeModels(siteID: self.sampleSiteID, keyword: "coffee", pageNumber: 1, pageSize: 10) { _ in
171+
promise(())
172+
}
173+
}
174+
175+
// Then
176+
let event = try XCTUnwrap(analyticsProvider.receivedEvents.first)
177+
XCTAssertEqual(event, "product_list_searched")
178+
let eventProperties = try XCTUnwrap(analyticsProvider.receivedProperties.first)
179+
XCTAssertEqual(eventProperties["filter"] as? String, "all")
180+
}
115181
}

0 commit comments

Comments
 (0)