Skip to content

[Woo POS] Fix POS menu item visibility issues with unit tests #12808

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
May 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 6 additions & 19 deletions WooCommerce/Classes/Model/BetaFeature.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Combine
import Storage
import protocol WooFoundation.WooAnalyticsEventPropertyType

Expand Down Expand Up @@ -52,21 +53,6 @@ extension BetaFeature {
}
}

var isAvailable: Bool {
switch self {
case .inAppPurchases:
return ServiceLocator.featureFlagService.isFeatureFlagEnabled(.inAppPurchasesDebugMenu)
case .pointOfSale:
return POSEligibilityChecker().isEligible()
default:
return true
}
}

static var availableFeatures: [Self] {
allCases.filter(\.isAvailable)
}

func analyticsProperties(toggleState enabled: Bool) -> [String: WooAnalyticsEventPropertyType] {
var properties = ["state": enabled ? "on" : "off"]
if analyticsStat == .settingsBetaFeatureToggled {
Expand All @@ -78,10 +64,11 @@ extension BetaFeature {

extension GeneralAppSettingsStorage {
func betaFeatureEnabled(_ feature: BetaFeature) -> Bool {
guard feature.isAvailable else {
return false
}
return value(for: feature.settingsKey)
value(for: feature.settingsKey)
}

func betaFeatureEnabledPublisher(_ feature: BetaFeature) -> AnyPublisher<Bool, Never> {
publisher(for: feature.settingsKey)
}

func betaFeatureEnabledBinding(_ feature: BetaFeature) -> Binding<Bool> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Yosemite
final class BetaFeaturesConfigurationViewController: UIHostingController<BetaFeaturesConfiguration> {

init() {
super.init(rootView: BetaFeaturesConfiguration())
super.init(rootView: BetaFeaturesConfiguration(viewModel: .init()))
}

required dynamic init?(coder aDecoder: NSCoder) {
Expand All @@ -13,13 +13,17 @@ final class BetaFeaturesConfigurationViewController: UIHostingController<BetaFea
}

struct BetaFeaturesConfiguration: View {
let appSettings = ServiceLocator.generalAppSettings
@StateObject private var viewModel: BetaFeaturesConfigurationViewModel

init(viewModel: BetaFeaturesConfigurationViewModel) {
self._viewModel = .init(wrappedValue: viewModel)
}

var body: some View {
List {
ForEach(BetaFeature.availableFeatures) { feature in
ForEach(viewModel.availableFeatures) { feature in
Section(footer: Text(feature.description)) {
TitleAndToggleRow(title: feature.title, isOn: appSettings.betaFeatureEnabledBinding(feature))
TitleAndToggleRow(title: feature.title, isOn: viewModel.isOn(feature: feature))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What benefit do we get from moving this to the viewModel?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question - as I had to create a view model to observe POS eligibility check (from the onboarding state observation), I thought moving the appSettings dependency to the view model would enable unit testing in the future even though we don't have unit tests on it right now.

}
}
}
Expand All @@ -36,7 +40,7 @@ private enum Localization {
struct BetaFeaturesConfiguration_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
BetaFeaturesConfiguration()
BetaFeaturesConfiguration(viewModel: .init())
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import SwiftUI
import protocol Experiments.FeatureFlagService
import struct Storage.GeneralAppSettingsStorage

final class BetaFeaturesConfigurationViewModel: ObservableObject {
@Published private(set) var availableFeatures: [BetaFeature] = []
private let appSettings: GeneralAppSettingsStorage
private let featureFlagService: FeatureFlagService
private let posEligibilityChecker: POSEligibilityCheckerProtocol

init(appSettings: GeneralAppSettingsStorage = ServiceLocator.generalAppSettings,
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService,
posEligibilityChecker: POSEligibilityCheckerProtocol = POSEligibilityChecker(
cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCase(),
siteSettings: ServiceLocator.selectedSiteSettings,
currencySettings: ServiceLocator.currencySettings,
featureFlagService: ServiceLocator.featureFlagService
)) {
self.appSettings = appSettings
self.featureFlagService = featureFlagService
self.posEligibilityChecker = posEligibilityChecker
observePOSEligibilityForAvailableFeatures()
}

func isOn(feature: BetaFeature) -> Binding<Bool> {
appSettings.betaFeatureEnabledBinding(feature)
}
}

private extension BetaFeaturesConfigurationViewModel {
func observePOSEligibilityForAvailableFeatures() {
posEligibilityChecker.isEligible
.map { [weak self] isEligibleForPOS in
guard let self else {
return []
}
return BetaFeature.allCases.filter { betaFeature in
switch betaFeature {
case .viewAddOns:
return true
case .inAppPurchases:
return self.featureFlagService.isFeatureFlagEnabled(.inAppPurchasesDebugMenu)
case .pointOfSale:
return isEligibleForPOS
}
}
}
.assign(to: &$availableFeatures)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ final class CardPresentPaymentsOnboardingUseCase: CardPresentPaymentsOnboardingU
let storageManager: StorageManagerType
let stores: StoresManager
let configurationLoader: CardPresentConfigurationLoader
let featureFlagService: FeatureFlagService
private let cardPresentPluginsDataProvider: CardPresentPluginsDataProvider
private let cardPresentPaymentOnboardingStateCache: CardPresentPaymentOnboardingStateCache
private let analytics: Analytics
Expand All @@ -72,15 +71,13 @@ final class CardPresentPaymentsOnboardingUseCase: CardPresentPaymentsOnboardingU
init(
storageManager: StorageManagerType = ServiceLocator.storageManager,
stores: StoresManager = ServiceLocator.stores,
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService,
cardPresentPaymentOnboardingStateCache: CardPresentPaymentOnboardingStateCache = CardPresentPaymentOnboardingStateCache.shared,
analytics: Analytics = ServiceLocator.analytics
) {
self.storageManager = storageManager
self.stores = stores
self.configurationLoader = .init(stores: stores)
self.cardPresentPluginsDataProvider = .init(storageManager: storageManager, stores: stores, configuration: configurationLoader.configuration)
self.featureFlagService = featureFlagService
self.cardPresentPaymentOnboardingStateCache = cardPresentPaymentOnboardingStateCache
self.analytics = analytics

Expand Down
Original file line number Diff line number Diff line change
@@ -1,44 +1,70 @@
import Combine
import Foundation
import UIKit
import class WooFoundation.CurrencySettings
import enum WooFoundation.CountryCode
import protocol Experiments.FeatureFlagService
import struct Yosemite.SiteSetting

protocol POSEligibilityCheckerProtocol {
/// As POS eligibility can change from site settings and card payment onboarding state, it's recommended to observe the eligibility value.
var isEligible: AnyPublisher<Bool, Never> { get }
}

/// Determines whether the POS entry point can be shown based on the selected store and feature gates.
final class POSEligibilityChecker {
final class POSEligibilityChecker: POSEligibilityCheckerProtocol {
var isEligible: AnyPublisher<Bool, Never> {
$isEligibleValue.eraseToAnyPublisher()
}

@Published private var isEligibleValue: Bool = false

private let userInterfaceIdiom: UIUserInterfaceIdiom
private let cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol
private let siteSettings: [SiteSetting]
private let siteSettings: SelectedSiteSettings
private let currencySettings: CurrencySettings
private let featureFlagService: FeatureFlagService

init(cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol = CardPresentPaymentsOnboardingUseCase(),
siteSettings: [SiteSetting] = ServiceLocator.selectedSiteSettings.siteSettings,
init(userInterfaceIdiom: UIUserInterfaceIdiom = UIDevice.current.userInterfaceIdiom,
cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol = CardPresentPaymentsOnboardingUseCase(),
siteSettings: SelectedSiteSettings = ServiceLocator.selectedSiteSettings,
currencySettings: CurrencySettings = ServiceLocator.currencySettings,
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) {
self.userInterfaceIdiom = userInterfaceIdiom
self.siteSettings = siteSettings
self.currencySettings = currencySettings
self.cardPresentPaymentsOnboarding = cardPresentPaymentsOnboarding
self.featureFlagService = featureFlagService
observeOnboardingStateForEligibilityCheck()
}
}

private extension POSEligibilityChecker {
/// Returns whether the selected store is eligible for POS.
func isEligible() -> Bool {
// Always checks the main POS feature flag before any other checks.
guard featureFlagService.isFeatureFlagEnabled(.displayPointOfSaleToggle) else {
return false
func observeOnboardingStateForEligibilityCheck() {
// Conditions that are fixed for its lifetime.
let isTablet = userInterfaceIdiom == .pad
let isFeatureFlagEnabled = featureFlagService.isFeatureFlagEnabled(.displayPointOfSaleToggle)
guard isTablet && isFeatureFlagEnabled else {
isEligibleValue = false
return
}

let isCountryCodeUS = SiteAddress(siteSettings: siteSettings).countryCode == CountryCode.US
let isCurrencyUSD = currencySettings.currencyCode == .USD
cardPresentPaymentsOnboarding.statePublisher
.filter { [weak self] _ in
self?.isEligibleFromSiteChecks() ?? false
}
.map { onboardingState in
// Woo Payments plugin enabled and user setup complete
onboardingState == .completed(plugin: .wcPayOnly) || onboardingState == .completed(plugin: .wcPayPreferred)
}
.assign(to: &$isEligibleValue)
}

// Tablet device
return UIDevice.current.userInterfaceIdiom == .pad
// Woo Payments plugin enabled and user setup complete
&& (cardPresentPaymentsOnboarding.state == .completed(plugin: .wcPayOnly) || cardPresentPaymentsOnboarding.state == .completed(plugin: .wcPayPreferred))
// USD currency
&& isCurrencyUSD
// US store location
&& isCountryCodeUS
func isEligibleFromSiteChecks() -> Bool {
// Conditions that can change if site settings are synced during the lifetime.
let isCountryCodeUS = SiteAddress(siteSettings: siteSettings.siteSettings).countryCode == .US
let isCurrencyUSD = currencySettings.currencyCode == .USD
return isCountryCodeUS && isCurrencyUSD
}
}
6 changes: 3 additions & 3 deletions WooCommerce/Classes/ViewRelated/Hub Menu/HubMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ struct HubMenu: View {
.navigationDestination(isPresented: $viewModel.showingCoupons) {
couponListView
}
}
.onAppear {
viewModel.setupMenuElements()
.onAppear {
viewModel.setupMenuElements()
}
}
}

Expand Down
27 changes: 17 additions & 10 deletions WooCommerce/Classes/ViewRelated/Hub Menu/HubMenuViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ final class HubMenuViewModel: ObservableObject {
private let stores: StoresManager
private let featureFlagService: FeatureFlagService
private let generalAppSettings: GeneralAppSettingsStorage
private let cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCaseProtocol
private let posEligibilityChecker: POSEligibilityCheckerProtocol

private(set) var productReviewFromNoteParcel: ProductReviewFromNoteParcel?

Expand Down Expand Up @@ -114,6 +116,11 @@ final class HubMenuViewModel: ObservableObject {
self.generalAppSettings = generalAppSettings
self.switchStoreEnabled = stores.isAuthenticatedWithoutWPCom == false
self.blazeEligibilityChecker = blazeEligibilityChecker
self.cardPresentPaymentsOnboarding = CardPresentPaymentsOnboardingUseCase()
self.posEligibilityChecker = POSEligibilityChecker(cardPresentPaymentsOnboarding: cardPresentPaymentsOnboarding,
siteSettings: ServiceLocator.selectedSiteSettings,
currencySettings: ServiceLocator.currencySettings,
featureFlagService: featureFlagService)
observeSiteForUIUpdates()
observePlanName()
tapToPayBadgePromotionChecker.$shouldShowTapToPayBadges.share().assign(to: &$shouldShowNewFeatureBadgeOnPayments)
Expand All @@ -138,16 +145,16 @@ final class HubMenuViewModel: ObservableObject {
}

private func setupPOSElement() {
let isBetaFeatureEnabled = generalAppSettings.betaFeatureEnabled(.pointOfSale)
let eligibilityChecker = POSEligibilityChecker(cardPresentPaymentsOnboarding: CardPresentPaymentsOnboardingUseCase(),
siteSettings: ServiceLocator.selectedSiteSettings.siteSettings,
currencySettings: ServiceLocator.currencySettings,
featureFlagService: featureFlagService)
if isBetaFeatureEnabled && eligibilityChecker.isEligible() {
posElement = PointOfSaleEntryPoint()
} else {
posElement = nil
}
cardPresentPaymentsOnboarding.refreshIfNecessary()
Publishers.CombineLatest(generalAppSettings.betaFeatureEnabledPublisher(.pointOfSale), posEligibilityChecker.isEligible)
.map { isBetaFeatureEnabled, isEligibleForPOS in
if isBetaFeatureEnabled && isEligibleForPOS {
return PointOfSaleEntryPoint()
} else {
return nil
}
}
Comment on lines +150 to +156
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: it might be clearer to use a filter then a map:

Suggested change
.map { isBetaFeatureEnabled, isEligibleForPOS in
if isBetaFeatureEnabled && isEligibleForPOS {
return PointOfSaleEntryPoint()
} else {
return nil
}
}
.filter { isBetaFeatureEnabled, isEligibleForPOS in
isBetaFeatureEnabled && isEligibleForPOS
}
.map { _ in
PointOfSaleEntryPoint()
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the downstream value is nil from isBetaFeatureEnabled && isEligibleForPOS = false => PointOfSaleEntryPoint from isBetaFeatureEnabled && isEligibleForPOS = true, then when isBetaFeatureEnabled && isEligibleForPOS = false, would filter + map set the value back to nil? I'll do a quick experiment and update.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested in Playground today, if we apply the filter, the view model doesn't get reset back to nil when the entry point is not eligible anymore.

.assign(to: &$posElement)
}

private func setupSettingsElements() {
Expand Down
Loading