Skip to content

Commit afc0982

Browse files
authored
Merge pull request #6151 from woocommerce/issue/6149-coupons-experimental-feature
Coupons: Add coupon management to experimental features
2 parents d9f74f0 + 67d04d6 commit afc0982

File tree

11 files changed

+239
-26
lines changed

11 files changed

+239
-26
lines changed

Storage/Storage/Model/Copiable/Models+Copiable.generated.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ extension GeneralAppSettings {
1313
isStripeInPersonPaymentsSwitchEnabled: CopiableProp<Bool> = .copy,
1414
isCanadaInPersonPaymentsSwitchEnabled: CopiableProp<Bool> = .copy,
1515
isProductSKUInputScannerSwitchEnabled: CopiableProp<Bool> = .copy,
16+
isCouponManagementSwitchEnabled: CopiableProp<Bool> = .copy,
1617
knownCardReaders: CopiableProp<[String]> = .copy,
1718
lastEligibilityErrorInfo: NullableCopiableProp<EligibilityErrorInfo> = .copy,
1819
lastJetpackBenefitsBannerDismissedTime: NullableCopiableProp<Date> = .copy
@@ -24,6 +25,7 @@ extension GeneralAppSettings {
2425
let isStripeInPersonPaymentsSwitchEnabled = isStripeInPersonPaymentsSwitchEnabled ?? self.isStripeInPersonPaymentsSwitchEnabled
2526
let isCanadaInPersonPaymentsSwitchEnabled = isCanadaInPersonPaymentsSwitchEnabled ?? self.isCanadaInPersonPaymentsSwitchEnabled
2627
let isProductSKUInputScannerSwitchEnabled = isProductSKUInputScannerSwitchEnabled ?? self.isProductSKUInputScannerSwitchEnabled
28+
let isCouponManagementSwitchEnabled = isCouponManagementSwitchEnabled ?? self.isCouponManagementSwitchEnabled
2729
let knownCardReaders = knownCardReaders ?? self.knownCardReaders
2830
let lastEligibilityErrorInfo = lastEligibilityErrorInfo ?? self.lastEligibilityErrorInfo
2931
let lastJetpackBenefitsBannerDismissedTime = lastJetpackBenefitsBannerDismissedTime ?? self.lastJetpackBenefitsBannerDismissedTime
@@ -36,6 +38,7 @@ extension GeneralAppSettings {
3638
isStripeInPersonPaymentsSwitchEnabled: isStripeInPersonPaymentsSwitchEnabled,
3739
isCanadaInPersonPaymentsSwitchEnabled: isCanadaInPersonPaymentsSwitchEnabled,
3840
isProductSKUInputScannerSwitchEnabled: isProductSKUInputScannerSwitchEnabled,
41+
isCouponManagementSwitchEnabled: isCouponManagementSwitchEnabled,
3942
knownCardReaders: knownCardReaders,
4043
lastEligibilityErrorInfo: lastEligibilityErrorInfo,
4144
lastJetpackBenefitsBannerDismissedTime: lastJetpackBenefitsBannerDismissedTime

Storage/Storage/Model/GeneralAppSettings.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable {
4040
///
4141
public let isProductSKUInputScannerSwitchEnabled: Bool
4242

43+
/// The state for the Coupon Management feature switch.
44+
///
45+
public let isCouponManagementSwitchEnabled: Bool
46+
4347
/// A list (possibly empty) of known card reader IDs - i.e. IDs of card readers that should be reconnected to automatically
4448
/// e.g. ["CHB204909005931"]
4549
///
@@ -59,6 +63,7 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable {
5963
isStripeInPersonPaymentsSwitchEnabled: Bool,
6064
isCanadaInPersonPaymentsSwitchEnabled: Bool,
6165
isProductSKUInputScannerSwitchEnabled: Bool,
66+
isCouponManagementSwitchEnabled: Bool,
6267
knownCardReaders: [String],
6368
lastEligibilityErrorInfo: EligibilityErrorInfo? = nil,
6469
lastJetpackBenefitsBannerDismissedTime: Date? = nil) {
@@ -69,6 +74,7 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable {
6974
self.isStripeInPersonPaymentsSwitchEnabled = isStripeInPersonPaymentsSwitchEnabled
7075
self.isCanadaInPersonPaymentsSwitchEnabled = isCanadaInPersonPaymentsSwitchEnabled
7176
self.isProductSKUInputScannerSwitchEnabled = isProductSKUInputScannerSwitchEnabled
77+
self.isCouponManagementSwitchEnabled = isCouponManagementSwitchEnabled
7278
self.knownCardReaders = knownCardReaders
7379
self.lastEligibilityErrorInfo = lastEligibilityErrorInfo
7480
self.lastJetpackBenefitsBannerDismissedTime = lastJetpackBenefitsBannerDismissedTime
@@ -99,6 +105,7 @@ public struct GeneralAppSettings: Codable, Equatable, GeneratedCopiable {
99105
isStripeInPersonPaymentsSwitchEnabled: isStripeInPersonPaymentsSwitchEnabled,
100106
isCanadaInPersonPaymentsSwitchEnabled: isCanadaInPersonPaymentsSwitchEnabled,
101107
isProductSKUInputScannerSwitchEnabled: isProductSKUInputScannerSwitchEnabled,
108+
isCouponManagementSwitchEnabled: isCouponManagementSwitchEnabled,
102109
knownCardReaders: knownCardReaders,
103110
lastEligibilityErrorInfo: lastEligibilityErrorInfo
104111
)
@@ -119,6 +126,7 @@ extension GeneralAppSettings {
119126
self.isStripeInPersonPaymentsSwitchEnabled = try container.decodeIfPresent(Bool.self, forKey: .isStripeInPersonPaymentsSwitchEnabled) ?? false
120127
self.isCanadaInPersonPaymentsSwitchEnabled = try container.decodeIfPresent(Bool.self, forKey: .isCanadaInPersonPaymentsSwitchEnabled) ?? false
121128
self.isProductSKUInputScannerSwitchEnabled = try container.decodeIfPresent(Bool.self, forKey: .isProductSKUInputScannerSwitchEnabled) ?? false
129+
self.isCouponManagementSwitchEnabled = try container.decodeIfPresent(Bool.self, forKey: .isCouponManagementSwitchEnabled) ?? false
122130
self.knownCardReaders = try container.decodeIfPresent([String].self, forKey: .knownCardReaders) ?? []
123131
self.lastEligibilityErrorInfo = try container.decodeIfPresent(EligibilityErrorInfo.self, forKey: .lastEligibilityErrorInfo)
124132
self.lastJetpackBenefitsBannerDismissedTime = try container.decodeIfPresent(Date.self, forKey: .lastJetpackBenefitsBannerDismissedTime)

Storage/StorageTests/Model/AppSettings/GeneralAppSettingsTests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ final class GeneralAppSettingsTests: XCTestCase {
6565
isStripeInPersonPaymentsSwitchEnabled: true,
6666
isCanadaInPersonPaymentsSwitchEnabled: true,
6767
isProductSKUInputScannerSwitchEnabled: true,
68+
isCouponManagementSwitchEnabled: true,
6869
knownCardReaders: readers,
6970
lastEligibilityErrorInfo: eligibilityInfo,
7071
lastJetpackBenefitsBannerDismissedTime: jetpackBannerDismissedDate)
@@ -87,6 +88,7 @@ final class GeneralAppSettingsTests: XCTestCase {
8788
assertEqual(newSettings.isStripeInPersonPaymentsSwitchEnabled, true)
8889
assertEqual(newSettings.isCanadaInPersonPaymentsSwitchEnabled, true)
8990
assertEqual(newSettings.isProductSKUInputScannerSwitchEnabled, true)
91+
assertEqual(newSettings.isCouponManagementSwitchEnabled, true)
9092
assertEqual(newSettings.lastJetpackBenefitsBannerDismissedTime, jetpackBannerDismissedDate)
9193
}
9294
}
@@ -99,6 +101,7 @@ private extension GeneralAppSettingsTests {
99101
isStripeInPersonPaymentsSwitchEnabled: Bool = false,
100102
isCanadaInPersonPaymentsSwitchEnabled: Bool = false,
101103
isProductSKUInputScannerSwitchEnabled: Bool = false,
104+
isCouponManagementSwitchEnabled: Bool = false,
102105
knownCardReaders: [String] = [],
103106
lastEligibilityErrorInfo: EligibilityErrorInfo? = nil,
104107
lastJetpackBenefitsBannerDismissedTime: Date? = nil) -> GeneralAppSettings {
@@ -109,6 +112,7 @@ private extension GeneralAppSettingsTests {
109112
isStripeInPersonPaymentsSwitchEnabled: isStripeInPersonPaymentsSwitchEnabled,
110113
isCanadaInPersonPaymentsSwitchEnabled: isCanadaInPersonPaymentsSwitchEnabled,
111114
isProductSKUInputScannerSwitchEnabled: isProductSKUInputScannerSwitchEnabled,
115+
isCouponManagementSwitchEnabled: isCouponManagementSwitchEnabled,
112116
knownCardReaders: knownCardReaders,
113117
lastEligibilityErrorInfo: lastEligibilityErrorInfo,
114118
lastJetpackBenefitsBannerDismissedTime: lastJetpackBenefitsBannerDismissedTime)

WooCommerce/Classes/ViewRelated/Dashboard/Settings/Beta features/BetaFeaturesViewController.swift

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ private extension BetaFeaturesViewController {
7676
productsSection(),
7777
orderCreationSection(),
7878
inPersonPaymentsSection(),
79-
productSKUInputScannerSection()
79+
productSKUInputScannerSection(),
80+
couponManagementSection()
8081
].compactMap { $0 }
8182
}
8283

@@ -117,6 +118,14 @@ private extension BetaFeaturesViewController {
117118
.productSKUInputScannerDescription])
118119
}
119120

121+
func couponManagementSection() -> Section? {
122+
guard ServiceLocator.featureFlagService.isFeatureFlagEnabled(.couponManagement) else {
123+
return nil
124+
}
125+
return Section(rows: [.couponManagement,
126+
.couponManagementDescription])
127+
}
128+
120129
/// Register table cells.
121130
///
122131
func registerTableViewCells() {
@@ -159,6 +168,10 @@ private extension BetaFeaturesViewController {
159168
configureProductSKUInputScannerSwitch(cell: cell)
160169
case let cell as BasicTableViewCell where row == .productSKUInputScannerDescription:
161170
configureProductSKUInputScannerDescription(cell: cell)
171+
case let cell as SwitchTableViewCell where row == .couponManagement:
172+
configureCouponManagementSwitch(cell: cell)
173+
case let cell as BasicTableViewCell where row == .couponManagementDescription:
174+
configureCouponManagementDescription(cell: cell)
162175
default:
163176
fatalError()
164177
}
@@ -322,6 +335,37 @@ private extension BetaFeaturesViewController {
322335
configureCommonStylesForDescriptionCell(cell)
323336
cell.textLabel?.text = Localization.productSKUInputScannerDescription
324337
}
338+
339+
func configureCouponManagementSwitch(cell: SwitchTableViewCell) {
340+
configureCommonStylesForSwitchCell(cell)
341+
cell.title = Localization.couponManagementTitle
342+
343+
// Fetch switch's state stored value.
344+
let action = AppSettingsAction.loadCouponManagementFeatureSwitchState { result in
345+
guard let isEnabled = try? result.get() else {
346+
return cell.isOn = false
347+
}
348+
cell.isOn = isEnabled
349+
}
350+
ServiceLocator.stores.dispatch(action)
351+
352+
// Change switch's state stored value
353+
cell.onChange = { isSwitchOn in
354+
let action = AppSettingsAction.setCouponManagementFeatureSwitchState(isEnabled: isSwitchOn, onCompletion: { result in
355+
// Roll back toggle if an error occurred
356+
if result.isFailure {
357+
cell.isOn.toggle()
358+
}
359+
})
360+
ServiceLocator.stores.dispatch(action)
361+
}
362+
cell.accessibilityIdentifier = "beta-features-coupon-management-cell"
363+
}
364+
365+
func configureCouponManagementDescription(cell: BasicTableViewCell) {
366+
configureCommonStylesForDescriptionCell(cell)
367+
cell.textLabel?.text = Localization.couponManagementDescription
368+
}
325369
}
326370

327371
// MARK: - Shared Configurations
@@ -402,12 +446,16 @@ private enum Row: CaseIterable {
402446
case productSKUInputScanner
403447
case productSKUInputScannerDescription
404448

449+
// Coupon management
450+
case couponManagement
451+
case couponManagementDescription
452+
405453
var type: UITableViewCell.Type {
406454
switch self {
407-
case .orderAddOns, .orderCreation, .stripeExtensionInPersonPayments, .canadaInPersonPayments, .productSKUInputScanner:
455+
case .orderAddOns, .orderCreation, .stripeExtensionInPersonPayments, .canadaInPersonPayments, .productSKUInputScanner, .couponManagement:
408456
return SwitchTableViewCell.self
409457
case .orderAddOnsDescription, .orderCreationDescription, .stripeExtensionInPersonPaymentsDescription, .canadaInPersonPaymentsDescription,
410-
.productSKUInputScannerDescription:
458+
.productSKUInputScannerDescription, .couponManagementDescription:
411459
return BasicTableViewCell.self
412460
}
413461
}
@@ -455,5 +503,11 @@ private extension BetaFeaturesViewController {
455503
static let productSKUInputScannerDescription = NSLocalizedString(
456504
"Test out scanning a barcode for a product SKU in the product inventory settings",
457505
comment: "Cell description on beta features screen to enable product SKU input scanner in inventory settings.")
506+
507+
static let couponManagementTitle = NSLocalizedString("Coupon Management", comment: "Cell title on beta features screen to enable coupon management")
508+
static let couponManagementDescription = NSLocalizedString(
509+
"Test out managing coupons as we get ready to launch",
510+
comment: "Cell description on beta features screen to enable coupon management"
511+
)
458512
}
459513
}

WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ struct HubMenu: View {
8080
}
8181
.navigationBarHidden(true)
8282
.background(Color(.listBackground).edgesIgnoringSafeArea(.all))
83+
.onAppear {
84+
viewModel.setupMenuElements()
85+
}
8386
}
8487

8588
func pushReviewDetailsView(using parcel: ProductReviewFromNoteParcel) {

WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,25 @@ final class HubMenuViewModel: ObservableObject {
1616
private(set) unowned var navigationController: UINavigationController?
1717

1818
var avatarURL: URL? {
19-
guard let urlString = ServiceLocator.stores.sessionManager.defaultAccount?.gravatarUrl, let url = URL(string: urlString) else {
19+
guard let urlString = stores.sessionManager.defaultAccount?.gravatarUrl, let url = URL(string: urlString) else {
2020
return nil
2121
}
2222
return url
2323
}
24+
2425
var storeTitle: String {
25-
ServiceLocator.stores.sessionManager.defaultSite?.name ?? Localization.myStore
26+
stores.sessionManager.defaultSite?.name ?? Localization.myStore
2627
}
28+
2729
var storeURL: URL {
28-
guard let urlString = ServiceLocator.stores.sessionManager.defaultSite?.url, let url = URL(string: urlString) else {
30+
guard let urlString = stores.sessionManager.defaultSite?.url, let url = URL(string: urlString) else {
2931
return WooConstants.URLs.blog.asURL()
3032
}
3133
return url
3234
}
35+
3336
var woocommerceAdminURL: URL {
34-
guard let urlString = ServiceLocator.stores.sessionManager.defaultSite?.adminURL, let url = URL(string: urlString) else {
37+
guard let urlString = stores.sessionManager.defaultSite?.adminURL, let url = URL(string: urlString) else {
3538
return WooConstants.URLs.blog.asURL()
3639
}
3740
return url
@@ -43,24 +46,47 @@ final class HubMenuViewModel: ObservableObject {
4346

4447
@Published var showingReviewDetail = false
4548

49+
private let stores: StoresManager
50+
private let featureFlagService: FeatureFlagService
51+
4652
private var productReviewFromNoteParcel: ProductReviewFromNoteParcel?
4753

4854
private var storePickerCoordinator: StorePickerCoordinator?
4955

5056
private var cancellables = Set<AnyCancellable>()
5157

52-
init(siteID: Int64, navigationController: UINavigationController? = nil, featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) {
58+
init(siteID: Int64,
59+
navigationController: UINavigationController? = nil,
60+
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService,
61+
stores: StoresManager = ServiceLocator.stores) {
5362
self.siteID = siteID
5463
self.navigationController = navigationController
64+
self.stores = stores
65+
self.featureFlagService = featureFlagService
66+
observeSiteForUIUpdates()
67+
}
68+
69+
/// Resets the menu elements displayed on the menu.
70+
///
71+
func setupMenuElements() {
5572
menuElements = [.woocommerceAdmin, .viewStore]
5673
if featureFlagService.isFeatureFlagEnabled(.inbox) {
5774
menuElements.append(.inbox)
5875
}
59-
if featureFlagService.isFeatureFlagEnabled(.couponManagement) {
60-
menuElements.append(.coupons)
61-
}
6276
menuElements.append(.reviews)
63-
observeSiteForUIUpdates()
77+
78+
let action = AppSettingsAction.loadCouponManagementFeatureSwitchState { [weak self] result in
79+
guard let self = self else { return }
80+
guard case let .success(enabled) = result, enabled else {
81+
return
82+
}
83+
if let index = self.menuElements.firstIndex(of: .reviews) {
84+
self.menuElements.insert(.coupons, at: index)
85+
} else {
86+
self.menuElements.append(.coupons)
87+
}
88+
}
89+
stores.dispatch(action)
6490
}
6591

6692
/// Present the `StorePickerViewController` using the `StorePickerCoordinator`, passing the navigation controller from the entry point.
@@ -87,7 +113,7 @@ final class HubMenuViewModel: ObservableObject {
87113
}
88114

89115
private func observeSiteForUIUpdates() {
90-
ServiceLocator.stores.site.sink { site in
116+
stores.site.sink { site in
91117
// This will be useful in the future for updating some info of the screen depending on the store site info
92118
}.store(in: &cancellables)
93119
}

WooCommerce/WooCommerceTests/ViewRelated/HubMenu/HubMenuViewModelTests.swift

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import XCTest
22

33
@testable import WooCommerce
4+
@testable import Yosemite
45

56
final class HubMenuViewModelTests: XCTestCase {
67
private let sampleSiteID: Int64 = 606
@@ -22,8 +23,51 @@ final class HubMenuViewModelTests: XCTestCase {
2223

2324
// When
2425
let viewModel = HubMenuViewModel(siteID: sampleSiteID, featureFlagService: featureFlagService)
26+
viewModel.setupMenuElements()
2527

2628
// Then
2729
XCTAssertEqual(viewModel.menuElements, [.woocommerceAdmin, .viewStore, .inbox, .reviews])
2830
}
31+
32+
func test_menuElements_include_coupons_when_couponManagement_is_enabled_in_app_settings() {
33+
// Given
34+
let stores = MockStoresManager(sessionManager: .makeForTesting())
35+
let featureFlagService = MockFeatureFlagService(isInboxOn: true)
36+
37+
// When
38+
stores.whenReceivingAction(ofType: AppSettingsAction.self) { action in
39+
switch action {
40+
case .loadCouponManagementFeatureSwitchState(let onCompletion):
41+
onCompletion(.success(true))
42+
default:
43+
break
44+
}
45+
}
46+
let viewModel = HubMenuViewModel(siteID: sampleSiteID, featureFlagService: featureFlagService, stores: stores)
47+
viewModel.setupMenuElements()
48+
49+
// Then
50+
XCTAssertEqual(viewModel.menuElements, [.woocommerceAdmin, .viewStore, .inbox, .coupons, .reviews])
51+
}
52+
53+
func test_menuElements_do_not_include_coupons_when_couponManagement_is_not_enabled_in_app_settings() {
54+
// Given
55+
let stores = MockStoresManager(sessionManager: .makeForTesting())
56+
let featureFlagService = MockFeatureFlagService(isInboxOn: false)
57+
58+
// When
59+
stores.whenReceivingAction(ofType: AppSettingsAction.self) { action in
60+
switch action {
61+
case .loadCouponManagementFeatureSwitchState(let onCompletion):
62+
onCompletion(.success(false))
63+
default:
64+
break
65+
}
66+
}
67+
let viewModel = HubMenuViewModel(siteID: sampleSiteID, featureFlagService: featureFlagService, stores: stores)
68+
viewModel.setupMenuElements()
69+
70+
// Then
71+
XCTAssertEqual(viewModel.menuElements, [.woocommerceAdmin, .viewStore, .reviews])
72+
}
2973
}

Yosemite/Yosemite/Actions/AppSettingsAction.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,14 @@ public enum AppSettingsAction: Action {
152152
///
153153
case loadProductSKUInputScannerFeatureSwitchState(onCompletion: (Result<Bool, Error>) -> Void)
154154

155+
/// Sets the state for the Coupon Management beta feature switch.
156+
///
157+
case setCouponManagementFeatureSwitchState(isEnabled: Bool, onCompletion: (Result<Void, Error>) -> Void)
158+
159+
/// Loads the most recent state for the Coupon Management beta feature switch
160+
///
161+
case loadCouponManagementFeatureSwitchState(onCompletion: (Result<Bool, Error>) -> Void)
162+
155163
/// Remember the given card reader (to support automatic reconnection)
156164
/// where `cardReaderID` is a String e.g. "CHB204909005931"
157165
///

0 commit comments

Comments
 (0)