diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Blaze/BlazeCampaignDashboardViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Blaze/BlazeCampaignDashboardViewModel.swift index c3c11ca90e2..805d402c028 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Blaze/BlazeCampaignDashboardViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Blaze/BlazeCampaignDashboardViewModel.swift @@ -73,7 +73,7 @@ final class BlazeCampaignDashboardViewModel: ObservableObject { private let storageManager: StorageManagerType private let analytics: Analytics - private var isSiteEligibleForBlaze = false + @Published private var isSiteEligibleForBlaze = false private let blazeEligibilityChecker: BlazeEligibilityCheckerProtocol /// Blaze campaign ResultsController. @@ -103,12 +103,13 @@ final class BlazeCampaignDashboardViewModel: ObservableObject { ) }() - private(set) var latestPublishedProduct: BlazeCampaignProduct? + @Published private(set) var latestPublishedProduct: BlazeCampaignProduct? private var subscriptions: Set = [] @Published private var syncingError: Error? + private var cancellables = Set() init(siteID: Int64, stores: StoresManager = ServiceLocator.stores, @@ -122,15 +123,42 @@ final class BlazeCampaignDashboardViewModel: ObservableObject { self.blazeEligibilityChecker = blazeEligibilityChecker self.state = .loading + observeIsSiteEligibleForBlaze() observeSectionVisibility() configureResultsController() } + func observeIsSiteEligibleForBlaze() { + stores.site + .removeDuplicates() + .sink { site in + Task { [weak self] in + guard + let self, + let site + else { + return + } + + await updateIsSiteEligibleForBlaze(site) + updateAvailability() + } + } + .store(in: &cancellables) + } + + func updateIsSiteEligibleForBlaze() async { + guard let site = stores.sessionManager.defaultSite else { + return + } + + await updateIsSiteEligibleForBlaze(site) + } + @MainActor func checkAvailability() async { - isSiteEligibleForBlaze = await checkSiteEligibility() + await updateIsSiteEligibleForBlaze() try? await synchronizePublishedProducts() - updateAvailability() } @MainActor @@ -140,7 +168,7 @@ final class BlazeCampaignDashboardViewModel: ObservableObject { analytics.track(event: .DynamicDashboard.cardLoadingStarted(type: .blaze)) - isSiteEligibleForBlaze = await checkSiteEligibility() + await updateIsSiteEligibleForBlaze() guard isSiteEligibleForBlaze else { update(state: .empty) @@ -221,13 +249,14 @@ private extension BlazeCampaignDashboardViewModel { }) } - func checkSiteEligibility() async -> Bool { - guard let site = stores.sessionManager.defaultSite else { - return false - } + func checkSiteEligibility(_ site: Site) async -> Bool { return await blazeEligibilityChecker.isSiteEligible(site) } + func updateIsSiteEligibleForBlaze(_ site: Site) async { + isSiteEligibleForBlaze = await checkSiteEligibility(site) + } + @MainActor func synchronizeBlazeCampaigns() async throws { try await withCheckedThrowingContinuation({ continuation in diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewModel.swift index df7015b71f7..5ffe12aace6 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewModel.swift @@ -69,6 +69,8 @@ final class DashboardViewModel: ObservableObject { @Published private(set) var isEligibleForInbox = false + @Published private(set) var isEligibleForStock = false + @Published var showingCustomization = false @Published private(set) var showNewCardsNotice = false @@ -83,6 +85,7 @@ final class DashboardViewModel: ObservableObject { private let userDefaults: UserDefaults private let storageManager: StorageManagerType private let inboxEligibilityChecker: InboxEligibilityChecker + private let siteIsCIABEligibilityChecker: CIABEligibilityCheckerProtocol private let usageTracksEventEmitter: StoreStatsUsageTracksEventEmitter private let blazeLocalNotificationScheduler: BlazeLocalNotificationScheduler private let tapToPayAwarenessMomentDeterminer: TapToPayAwarenessMomentDetermining @@ -118,6 +121,7 @@ final class DashboardViewModel: ObservableObject { blazeEligibilityChecker: BlazeEligibilityCheckerProtocol = BlazeEligibilityChecker(), inboxEligibilityChecker: InboxEligibilityChecker = InboxEligibilityUseCase(), googleAdsEligibilityChecker: GoogleAdsEligibilityChecker = DefaultGoogleAdsEligibilityChecker(), + siteIsCIABEligibilityChecker: CIABEligibilityCheckerProtocol = CIABEligibilityChecker(), localNotificationScheduler: BlazeLocalNotificationScheduler? = nil, tapToPayAwarenessMomentDeterminer: TapToPayAwarenessMomentDetermining = TapToPayAwarenessMomentDeterminer()) { self.siteID = siteID @@ -147,6 +151,7 @@ final class DashboardViewModel: ObservableObject { ) self.inboxEligibilityChecker = inboxEligibilityChecker + self.siteIsCIABEligibilityChecker = siteIsCIABEligibilityChecker self.usageTracksEventEmitter = usageTracksEventEmitter self.blazeLocalNotificationScheduler = localNotificationScheduler ?? DefaultBlazeLocalNotificationScheduler(siteID: siteID, @@ -164,6 +169,7 @@ final class DashboardViewModel: ObservableObject { self?.onInAppFeedbackCardAction() } + observeStockEligibility() configureOrdersResultController() setupDashboardCards() observeWPCOMSiteSuspendedState() @@ -187,6 +193,7 @@ final class DashboardViewModel: ObservableObject { canShowBlaze: blazeCampaignDashboardViewModel.canShowInDashboard, canShowGoogle: googleAdsDashboardCardViewModel.canShowOnDashboard, canShowInbox: isEligibleForInbox, + canShowStock: isEligibleForStock, hasOrders: hasOrders) await reloadCardsWithBackgroundUpdateSupportIfNeeded() @@ -487,18 +494,20 @@ private extension DashboardViewModel { private extension DashboardViewModel { func observeValuesForDashboardCards() { storeOnboardingViewModel.$canShowInDashboard - .combineLatest(blazeCampaignDashboardViewModel.$canShowInDashboard) + .combineLatest(blazeCampaignDashboardViewModel.$canShowInDashboard, + $isEligibleForStock) .combineLatest(googleAdsDashboardCardViewModel.$canShowOnDashboard, $hasOrders, $isEligibleForInbox) .receive(on: DispatchQueue.main) .sink { [weak self] combinedResult in guard let self else { return } - let ((canShowOnboarding, canShowBlaze), canShowGoogle, hasOrders, isEligibleForInbox) = combinedResult + let ((canShowOnboarding, canShowBlaze, canShowStock), canShowGoogle, hasOrders, isEligibleForInbox) = combinedResult updateDashboardCards(canShowOnboarding: canShowOnboarding, canShowBlaze: canShowBlaze, canShowGoogle: canShowGoogle, canShowInbox: isEligibleForInbox, + canShowStock: canShowStock, hasOrders: hasOrders) } .store(in: &subscriptions) @@ -531,6 +540,26 @@ private extension DashboardViewModel { isEligibleForInbox = inboxEligibilityChecker.isEligibleForInbox(siteID: siteID) } + func observeStockEligibility() { + stores.site + .removeDuplicates() + .map { [weak self] in + guard + let self, + let site = $0 + else { + return false + } + + return siteIsCIABEligibilityChecker + .isFeatureSupported( + .productsStockDashboardCard, + for: site + ) + } + .assign(to: &$isEligibleForStock) + } + func configureOrdersResultController() { func refreshHasOrders() { /// Upon logging out, `CoreDataManager` clears the storage triggering data change. @@ -586,6 +615,7 @@ private extension DashboardViewModel { canShowGoogle: Bool, canShowAnalytics: Bool, canShowLastOrders: Bool, + canShowStock: Bool, canShowInbox: Bool) -> [DashboardCard] { var cards = [DashboardCard]() @@ -616,7 +646,14 @@ private extension DashboardViewModel { enabled: false)) cards.append(DashboardCard(type: .reviews, availability: .show, enabled: false)) cards.append(DashboardCard(type: .coupons, availability: .show, enabled: false)) - cards.append(DashboardCard(type: .stock, availability: .show, enabled: false)) + + cards.append( + DashboardCard( + type: .stock, + availability: canShowStock ? .show : .hide, + enabled: false + ) + ) // When not available, Last orders cards need to be hidden from Dashboard, but appear on Customize as "Unavailable" cards.append(DashboardCard(type: .lastOrders, @@ -634,6 +671,7 @@ private extension DashboardViewModel { canShowBlaze: Bool, canShowGoogle: Bool, canShowInbox: Bool, + canShowStock: Bool, hasOrders: Bool) { let canShowAnalytics = hasOrders @@ -645,6 +683,7 @@ private extension DashboardViewModel { canShowGoogle: canShowGoogle, canShowAnalytics: canShowAnalytics, canShowLastOrders: canShowLastOrders, + canShowStock: canShowStock, canShowInbox: canShowInbox) // Next, get saved cards and preserve existing enabled state for all available cards. diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Blaze/BlazeCampaignDashboardViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Blaze/BlazeCampaignDashboardViewModelTests.swift index 0ed80d09a79..0dd6286081c 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Blaze/BlazeCampaignDashboardViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/Blaze/BlazeCampaignDashboardViewModelTests.swift @@ -105,6 +105,93 @@ final class BlazeCampaignDashboardViewModelTests: XCTestCase { XCTAssertFalse(sut.canShowInDashboard) } + @MainActor + func test_canShowInDashboard_returns_false_if_store_is_ciab_and_other_requirements_met() async { + // Given + + let site = Site.fake().copy( + siteID: sampleSiteID, + isJetpackThePluginInstalled: true, + isJetpackConnected: true, + canBlaze: true, + isAdmin: true, + ) + + stores.updateDefaultStore(site) + + let siteCIABChecker = MockCIABEligibilityChecker( + mockedIsCurrentSiteCIAB: true, + mockedCIABSites: [stores.sessionManager.defaultSite ?? .fake()] + ) + let blazeEligibilityChecker = BlazeEligibilityChecker( + stores: stores, + siteCIABEligibilityChecker: siteCIABChecker + ) + let sut = BlazeCampaignDashboardViewModel( + siteID: sampleSiteID, + stores: stores, + storageManager: storageManager, + blazeEligibilityChecker: blazeEligibilityChecker + ) + + mockSynchronizeProducts( + insertProductToStorage: .fake().copy( + siteID: sampleSiteID, + statusKey: (ProductStatus.published.rawValue) + ) + ) + + mockSynchronizeCampaignsList() + + // When + await sut.checkAvailability() + + // Then + XCTAssertFalse(sut.canShowInDashboard) + } + + @MainActor + func test_canShowInDashboard_returns_true_if_store_is_non_ciab_and_other_requirements_met() async { + // Given + let site = Site.fake().copy( + siteID: sampleSiteID, + isJetpackThePluginInstalled: true, + isJetpackConnected: true, + canBlaze: true, + isAdmin: true, + ) + + stores.updateDefaultStore(site) + + let siteCIABChecker = MockCIABEligibilityChecker(mockedIsCurrentSiteCIAB: false) + let blazeEligibilityChecker = BlazeEligibilityChecker( + stores: stores, + siteCIABEligibilityChecker: siteCIABChecker + ) + + let sut = BlazeCampaignDashboardViewModel( + siteID: sampleSiteID, + stores: stores, + storageManager: storageManager, + blazeEligibilityChecker: blazeEligibilityChecker + ) + + mockSynchronizeProducts( + insertProductToStorage: .fake().copy( + siteID: sampleSiteID, + statusKey: (ProductStatus.published.rawValue) + ) + ) + + mockSynchronizeCampaignsList() + + // When + await sut.checkAvailability() + + // Then + XCTAssertTrue(sut.canShowInDashboard) + } + // MARK: Published product @MainActor diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/DashboardViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/DashboardViewModelTests.swift index 5d0ba0534a7..a17ee44143c 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/DashboardViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/DashboardViewModelTests.swift @@ -27,12 +27,19 @@ final class DashboardViewModelTests: XCTestCase { storageManager.viewStorage } + private lazy var site = Site.fake().copy( + siteID: sampleSiteID + ) + override func setUpWithError() throws { analyticsProvider = MockAnalyticsProvider() analytics = WooAnalytics(analyticsProvider: analyticsProvider) stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true)) userDefaults = try XCTUnwrap(UserDefaults(suiteName: "DashboardViewModelTests")) storageManager = MockStorageManager() + + stores.updateDefaultStore(storeID: sampleSiteID) + stores.updateDefaultStore(site) } @MainActor @@ -538,6 +545,57 @@ final class DashboardViewModelTests: XCTestCase { assertEqual([.performance, .googleAds], viewModel.showOnDashboardSecondColumn.map(\.type)) } + @MainActor + func test_dashboard_cards_contain_stock_card_when_store_is_eligible_and_non_ciab() async throws { + // Given + let siteCIABChecker = MockCIABEligibilityChecker(mockedIsCurrentSiteCIAB: false) + let userDefaults = try XCTUnwrap(UserDefaults(suiteName: UUID().uuidString)) + + let viewModel = DashboardViewModel(siteID: sampleSiteID, + stores: stores, + storageManager: storageManager, + userDefaults: userDefaults, + siteIsCIABEligibilityChecker: siteCIABChecker) + + mockReloadingData() + + // Stock card need to be set with availability: .show and enabled: true by default if available. + let expectedStockCard = DashboardCard(type: .stock, availability: .show, enabled: false) + + // When + await viewModel.reloadAllData() + + // Then + XCTAssertTrue(viewModel.dashboardCards.contains(expectedStockCard)) + } + + @MainActor + func test_dashboard_cards_does_not_contain_stock_card_when_store_is_eligible_and_ciab() async throws { + // Given + let siteCIABChecker = MockCIABEligibilityChecker( + mockedIsCurrentSiteCIAB: true, + mockedCIABSites: [site] + ) + let userDefaults = try XCTUnwrap(UserDefaults(suiteName: UUID().uuidString)) + + let viewModel = DashboardViewModel(siteID: sampleSiteID, + stores: stores, + storageManager: storageManager, + userDefaults: userDefaults, + siteIsCIABEligibilityChecker: siteCIABChecker) + + mockReloadingData() + + // Stock card need to be set with availability: .show and enabled: true by default if available. + let expectedStockCard = DashboardCard(type: .stock, availability: .show, enabled: false) + + // When + await viewModel.reloadAllData() + + // Then + XCTAssertFalse(viewModel.dashboardCards.contains(expectedStockCard)) + } + // MARK: Show New Cards Notice @MainActor