Skip to content

Commit c35ef3b

Browse files
authored
Merge pull request #7980 from woocommerce/issue/7976-products-onboarding-banner-ui
Products Onboarding: Add visual UI for banner to My Store dashboard
2 parents a7e296c + 9b2b8ee commit c35ef3b

File tree

8 files changed

+250
-69
lines changed

8 files changed

+250
-69
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import Foundation
2+
import UIKit
3+
4+
struct ProductsOnboardingAnnouncementCardViewModel: AnnouncementCardViewModelProtocol {
5+
var showDividers: Bool = false
6+
7+
var badgeType: BadgeView.BadgeType = .tip
8+
9+
var title: String = Localization.title
10+
11+
var message: String = Localization.message
12+
13+
var buttonTitle: String? = Localization.buttonTitle
14+
15+
var image: UIImage = .emptyProductsImage
16+
17+
func onAppear() {
18+
// No-op
19+
}
20+
21+
func ctaTapped() {
22+
// No-op
23+
}
24+
25+
// MARK: Dismiss confirmation alert (disabled)
26+
27+
var showDismissConfirmation: Bool = false
28+
29+
var dismissAlertTitle: String = ""
30+
31+
var dismissAlertMessage: String = ""
32+
33+
func dontShowAgainTapped() {
34+
// No-op
35+
}
36+
37+
func remindLaterTapped() {
38+
// No-op
39+
}
40+
}
41+
42+
private extension ProductsOnboardingAnnouncementCardViewModel {
43+
enum Localization {
44+
static let title = NSLocalizedString("Add products to sell", comment: "Title for the Products onboarding banner")
45+
static let message = NSLocalizedString("Build your catalog by adding what you want to sell.", comment: "Message for the Products onboarding banner")
46+
static let buttonTitle = NSLocalizedString("Add a Product", comment: "Title for the button on the Products onboarding banner")
47+
}
48+
}

WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewController.swift

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,6 @@ final class DashboardViewController: UIViewController {
7474
})
7575
}()
7676

77-
private var hasAnnouncementFeatureFlag: Bool { ServiceLocator.featureFlagService.isFeatureFlagEnabled(.justInTimeMessagesOnDashboard)
78-
}
79-
8077
private var announcementViewHostingController: ConstraintsUpdatingHostingController<AnnouncementCardWrapper>?
8178

8279
private var announcementView: UIView?
@@ -124,11 +121,8 @@ final class DashboardViewController: UIViewController {
124121
observeBottomJetpackBenefitsBannerVisibilityUpdates()
125122
observeNavigationBarHeightForStoreNameLabelVisibility()
126123
observeStatsVersionForDashboardUIUpdates()
127-
trackProductsOnboardingEligibility()
128124
observeAnnouncements()
129-
if hasAnnouncementFeatureFlag {
130-
viewModel.syncAnnouncements(for: siteID)
131-
}
125+
viewModel.syncAnnouncements(for: siteID)
132126
Task { @MainActor in
133127
await reloadDashboardUIStatsVersion(forced: true)
134128
}
@@ -269,23 +263,6 @@ private extension DashboardViewController {
269263
}
270264
}
271265

272-
/// Tracks if the store is eligible for products onboarding (if the store has no existing products)
273-
///
274-
func trackProductsOnboardingEligibility() {
275-
let action = ProductAction.checkForProducts(siteID: siteID) { result in
276-
switch result {
277-
case .success(let hasProducts):
278-
// Store is eligible for onboarding if it has no products
279-
if !hasProducts {
280-
ServiceLocator.analytics.track(.productsOnboardingEligible)
281-
}
282-
case .failure(let error):
283-
DDLogError("⛔️ Dashboard — Error checking products onboarding eligibility: \(error)")
284-
}
285-
}
286-
ServiceLocator.stores.dispatch(action)
287-
}
288-
289266
func reloadDashboardUIStatsVersion(forced: Bool) async {
290267
await storeStatsAndTopPerformersViewController.reloadData(forced: forced)
291268
}

WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewModel.swift

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Yosemite
22
import enum Networking.DotcomError
33
import enum Storage.StatsVersion
4+
import protocol Experiments.FeatureFlagService
45

56
/// Syncs data for dashboard stats UI and determines the state of the dashboard UI based on stats version.
67
final class DashboardViewModel {
@@ -10,9 +11,12 @@ final class DashboardViewModel {
1011
@Published private(set) var announcementViewModel: AnnouncementCardViewModelProtocol? = nil
1112

1213
private let stores: StoresManager
14+
private let featureFlagService: FeatureFlagService
1315

14-
init(stores: StoresManager = ServiceLocator.stores) {
16+
init(stores: StoresManager = ServiceLocator.stores,
17+
featureFlags: FeatureFlagService = ServiceLocator.featureFlagService) {
1518
self.stores = stores
19+
self.featureFlagService = featureFlags
1620
}
1721

1822
/// Syncs store stats for dashboard UI.
@@ -100,23 +104,60 @@ final class DashboardViewModel {
100104
/// Checks for announcements to show on the dashboard
101105
///
102106
func syncAnnouncements(for siteID: Int64) {
103-
if ServiceLocator.featureFlagService.isFeatureFlagEnabled(.justInTimeMessagesOnDashboard) {
104-
let action = JustInTimeMessageAction.loadMessage(
105-
siteID: siteID,
106-
screen: Constants.dashboardScreenName,
107-
hook: .adminNotices) { result in
108-
switch result {
109-
case let .success(.some(message)):
110-
let viewModel = JustInTimeMessageAnnouncementCardViewModel(title: message.title,
111-
message: message.detail,
112-
buttonTitle: message.buttonTitle)
113-
self.announcementViewModel = viewModel
114-
default:
115-
break
107+
syncProductsOnboarding(for: siteID) { [weak self] in
108+
// For now, products onboarding takes precedence over Just In Time Messages, so we can stop if there is an onboarding announcement to display.
109+
// This should be revisited when either onboarding or JITMs are expanded. See: pe5pgL-11B-p2
110+
guard let self, self.announcementViewModel == nil else { return }
111+
112+
self.syncJustInTimeMessages(for: siteID)
113+
}
114+
}
115+
116+
/// Checks if a store is eligible for products onboarding and prepares the onboarding announcement if needed.
117+
///
118+
private func syncProductsOnboarding(for siteID: Int64, onCompletion: @escaping () -> Void) {
119+
let action = ProductAction.checkProductsOnboardingEligibility(siteID: siteID) { [weak self] result in
120+
switch result {
121+
case .success(let isEligible):
122+
if isEligible {
123+
ServiceLocator.analytics.track(.productsOnboardingEligible)
124+
125+
if self?.featureFlagService.isFeatureFlagEnabled(.productsOnboarding) == true {
126+
let viewModel = ProductsOnboardingAnnouncementCardViewModel()
127+
self?.announcementViewModel = viewModel
116128
}
117129
}
118-
stores.dispatch(action)
130+
onCompletion()
131+
case .failure(let error):
132+
DDLogError("⛔️ Dashboard — Error checking products onboarding eligibility: \(error)")
133+
onCompletion()
134+
}
119135
}
136+
stores.dispatch(action)
137+
}
138+
139+
/// Checks for Just In Time Messages and prepares the announcement if needed.
140+
///
141+
private func syncJustInTimeMessages(for siteID: Int64) {
142+
guard featureFlagService.isFeatureFlagEnabled(.justInTimeMessagesOnDashboard) else {
143+
return
144+
}
145+
146+
let action = JustInTimeMessageAction.loadMessage(
147+
siteID: siteID,
148+
screen: Constants.dashboardScreenName,
149+
hook: .adminNotices) { [weak self] result in
150+
switch result {
151+
case let .success(.some(message)):
152+
let viewModel = JustInTimeMessageAnnouncementCardViewModel(title: message.title,
153+
message: message.detail,
154+
buttonTitle: message.buttonTitle)
155+
self?.announcementViewModel = viewModel
156+
default:
157+
break
158+
}
159+
}
160+
stores.dispatch(action)
120161
}
121162
}
122163

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,6 +1419,7 @@
14191419
CCEC256A27B581E800EF9FA3 /* ProductVariationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCEC256927B581E800EF9FA3 /* ProductVariationFormatter.swift */; };
14201420
CCF27B35280EF69700B755E1 /* orders_3337_add_product.json in Resources */ = {isa = PBXBuildFile; fileRef = CCF27B33280EF69600B755E1 /* orders_3337_add_product.json */; };
14211421
CCF27B3A280EF98F00B755E1 /* orders_3337.json in Resources */ = {isa = PBXBuildFile; fileRef = CCF27B39280EF98F00B755E1 /* orders_3337.json */; };
1422+
CCF4346E290AE1F900B4475A /* ProductsOnboardingAnnouncementCardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF4346D290AE1F900B4475A /* ProductsOnboardingAnnouncementCardViewModel.swift */; };
14221423
CCF87BBE279047BC00461C43 /* InfiniteScrollList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF87BBD279047BC00461C43 /* InfiniteScrollList.swift */; };
14231424
CCF87BC02790582500461C43 /* ProductVariationSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF87BBF2790582400461C43 /* ProductVariationSelector.swift */; };
14241425
CCFC00B523E9BD1500157A78 /* ScreenshotCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCFC00B423E9BD1500157A78 /* ScreenshotCredentials.swift */; };
@@ -3353,6 +3354,7 @@
33533354
CCEC256927B581E800EF9FA3 /* ProductVariationFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationFormatter.swift; sourceTree = "<group>"; };
33543355
CCF27B33280EF69600B755E1 /* orders_3337_add_product.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = orders_3337_add_product.json; sourceTree = "<group>"; };
33553356
CCF27B39280EF98F00B755E1 /* orders_3337.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = orders_3337.json; sourceTree = "<group>"; };
3357+
CCF4346D290AE1F900B4475A /* ProductsOnboardingAnnouncementCardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsOnboardingAnnouncementCardViewModel.swift; sourceTree = "<group>"; };
33563358
CCF87BBD279047BC00461C43 /* InfiniteScrollList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfiniteScrollList.swift; sourceTree = "<group>"; };
33573359
CCF87BBF2790582400461C43 /* ProductVariationSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationSelector.swift; sourceTree = "<group>"; };
33583360
CCFC00B423E9BD1500157A78 /* ScreenshotCredentials.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScreenshotCredentials.swift; sourceTree = "<group>"; };
@@ -4890,6 +4892,7 @@
48904892
children = (
48914893
0371C3672875E47B00277E2C /* FeatureAnnouncementCardViewModel.swift */,
48924894
0366EAE02909A37800B51755 /* JustInTimeMessageAnnouncementCardViewModel.swift */,
4895+
CCF4346D290AE1F900B4475A /* ProductsOnboardingAnnouncementCardViewModel.swift */,
48934896
0371C36D2876E92D00277E2C /* UpsellCardReadersCampaign.swift */,
48944897
AEE085B42897C871007ACE20 /* LinkedProductsPromoCampaign.swift */,
48954898
);
@@ -9623,6 +9626,7 @@
96239626
CC13C0CB278E021300C0B5B5 /* ProductVariationSelectorViewModel.swift in Sources */,
96249627
B59D1EEA2190AE96009D1978 /* StorageNote+Woo.swift in Sources */,
96259628
024DF3072372C18D006658FE /* AztecUIConfigurator.swift in Sources */,
9629+
CCF4346E290AE1F900B4475A /* ProductsOnboardingAnnouncementCardViewModel.swift in Sources */,
96269630
0217399E2772FB7E0084CD89 /* StoreStatsAndTopPerformersPeriodViewController.swift in Sources */,
96279631
020BE74823B05CF2007FE54C /* ProductInventoryEditableData.swift in Sources */,
96289632
035C6DEB273EA12D00F70406 /* SoftwareUpdateTypeProperty.swift in Sources */,

WooCommerce/WooCommerceTests/ViewRelated/Dashboard/DashboardViewModelTests.swift

Lines changed: 115 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import XCTest
22
import enum Networking.DotcomError
33
import enum Yosemite.StatsActionV4
4+
import enum Yosemite.ProductAction
5+
import enum Yosemite.JustInTimeMessageAction
6+
import struct Yosemite.YosemiteJustInTimeMessage
47
@testable import WooCommerce
58

69
final class DashboardViewModelTests: XCTestCase {
10+
private let sampleSiteID: Int64 = 122
11+
712
func test_default_statsVersion_is_v4() {
813
// Given
914
let viewModel = DashboardViewModel()
@@ -24,7 +29,7 @@ final class DashboardViewModelTests: XCTestCase {
2429
XCTAssertEqual(viewModel.statsVersion, .v4)
2530

2631
// When
27-
viewModel.syncStats(for: 122, siteTimezone: .current, timeRange: .thisMonth, latestDateToInclude: .init(), forceRefresh: false)
32+
viewModel.syncStats(for: sampleSiteID, siteTimezone: .current, timeRange: .thisMonth, latestDateToInclude: .init(), forceRefresh: false)
2833

2934
// Then
3035
XCTAssertEqual(viewModel.statsVersion, .v3)
@@ -46,9 +51,9 @@ final class DashboardViewModelTests: XCTestCase {
4651
XCTAssertEqual(viewModel.statsVersion, .v4)
4752

4853
// When
49-
viewModel.syncStats(for: 122, siteTimezone: .current, timeRange: .thisMonth, latestDateToInclude: .init(), forceRefresh: false)
50-
viewModel.syncSiteVisitStats(for: 122, siteTimezone: .current, timeRange: .thisMonth, latestDateToInclude: .init())
51-
viewModel.syncTopEarnersStats(for: 122, siteTimezone: .current, timeRange: .thisMonth, latestDateToInclude: .init(), forceRefresh: false)
54+
viewModel.syncStats(for: sampleSiteID, siteTimezone: .current, timeRange: .thisMonth, latestDateToInclude: .init(), forceRefresh: false)
55+
viewModel.syncSiteVisitStats(for: sampleSiteID, siteTimezone: .current, timeRange: .thisMonth, latestDateToInclude: .init())
56+
viewModel.syncTopEarnersStats(for: sampleSiteID, siteTimezone: .current, timeRange: .thisMonth, latestDateToInclude: .init(), forceRefresh: false)
5257

5358
// Then
5459
XCTAssertEqual(viewModel.statsVersion, .v4)
@@ -65,14 +70,118 @@ final class DashboardViewModelTests: XCTestCase {
6570
}
6671
}
6772
let viewModel = DashboardViewModel(stores: stores)
68-
viewModel.syncStats(for: 122, siteTimezone: .current, timeRange: .thisMonth, latestDateToInclude: .init(), forceRefresh: false)
73+
viewModel.syncStats(for: sampleSiteID, siteTimezone: .current, timeRange: .thisMonth, latestDateToInclude: .init(), forceRefresh: false)
6974
XCTAssertEqual(viewModel.statsVersion, .v3)
7075

7176
// When
7277
storeStatsResult = .success(())
73-
viewModel.syncStats(for: 122, siteTimezone: .current, timeRange: .thisMonth, latestDateToInclude: .init(), forceRefresh: false)
78+
viewModel.syncStats(for: sampleSiteID, siteTimezone: .current, timeRange: .thisMonth, latestDateToInclude: .init(), forceRefresh: false)
7479

7580
// Then
7681
XCTAssertEqual(viewModel.statsVersion, .v4)
7782
}
83+
84+
func test_products_onboarding_announcements_take_precedence() {
85+
// Given
86+
let stores = MockStoresManager(sessionManager: .makeForTesting())
87+
stores.whenReceivingAction(ofType: ProductAction.self) { action in
88+
switch action {
89+
case let .checkProductsOnboardingEligibility(_, completion):
90+
completion(.success(true))
91+
default:
92+
XCTFail("Received unsupported action: \(action)")
93+
}
94+
}
95+
stores.whenReceivingAction(ofType: JustInTimeMessageAction.self) { action in
96+
switch action {
97+
case let .loadMessage(_, _, _, completion):
98+
completion(.success(YosemiteJustInTimeMessage.fake()))
99+
}
100+
}
101+
let viewModel = DashboardViewModel(stores: stores)
102+
103+
// When
104+
viewModel.syncAnnouncements(for: sampleSiteID)
105+
106+
// Then (check announcement image because it is unique and not localized)
107+
XCTAssertEqual(viewModel.announcementViewModel?.image, .emptyProductsImage)
108+
}
109+
110+
func test_view_model_syncs_just_in_time_messages_when_ineligible_for_products_onboarding() {
111+
// Given
112+
let stores = MockStoresManager(sessionManager: .makeForTesting())
113+
stores.whenReceivingAction(ofType: ProductAction.self) { action in
114+
switch action {
115+
case let .checkProductsOnboardingEligibility(_, completion):
116+
completion(.success(false))
117+
default:
118+
XCTFail("Received unsupported action: \(action)")
119+
}
120+
}
121+
stores.whenReceivingAction(ofType: JustInTimeMessageAction.self) { action in
122+
switch action {
123+
case let .loadMessage(_, _, _, completion):
124+
completion(.success(YosemiteJustInTimeMessage.fake().copy(title: "JITM Message")))
125+
}
126+
}
127+
let viewModel = DashboardViewModel(stores: stores)
128+
129+
// When
130+
viewModel.syncAnnouncements(for: sampleSiteID)
131+
132+
// Then
133+
XCTAssertEqual(viewModel.announcementViewModel?.title, "JITM Message")
134+
}
135+
136+
func test_no_announcement_to_display_when_no_announcements_are_synced() {
137+
// Given
138+
let stores = MockStoresManager(sessionManager: .makeForTesting())
139+
stores.whenReceivingAction(ofType: ProductAction.self) { action in
140+
switch action {
141+
case let .checkProductsOnboardingEligibility(_, completion):
142+
completion(.success(false))
143+
default:
144+
XCTFail("Received unsupported action: \(action)")
145+
}
146+
}
147+
stores.whenReceivingAction(ofType: JustInTimeMessageAction.self) { action in
148+
switch action {
149+
case let .loadMessage(_, _, _, completion):
150+
completion(.success(nil))
151+
}
152+
}
153+
let viewModel = DashboardViewModel(stores: stores)
154+
155+
// When
156+
viewModel.syncAnnouncements(for: sampleSiteID)
157+
158+
// Then
159+
XCTAssertNil(viewModel.announcementViewModel)
160+
}
161+
162+
func test_no_announcement_synced_when_feature_flags_disabled() {
163+
// Given
164+
let stores = MockStoresManager(sessionManager: .makeForTesting())
165+
stores.whenReceivingAction(ofType: ProductAction.self) { action in
166+
switch action {
167+
case let .checkProductsOnboardingEligibility(_, completion):
168+
completion(.success(true))
169+
default:
170+
XCTFail("Received unsupported action: \(action)")
171+
}
172+
}
173+
stores.whenReceivingAction(ofType: JustInTimeMessageAction.self) { action in
174+
switch action {
175+
case let .loadMessage(_, _, _, completion):
176+
completion(.success(YosemiteJustInTimeMessage.fake()))
177+
}
178+
}
179+
let viewModel = DashboardViewModel(stores: stores, featureFlags: MockFeatureFlagService())
180+
181+
// When
182+
viewModel.syncAnnouncements(for: sampleSiteID)
183+
184+
// Then
185+
XCTAssertNil(viewModel.announcementViewModel)
186+
}
78187
}

Yosemite/Yosemite/Actions/ProductAction.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,10 @@ public enum ProductAction: Action {
8383
///
8484
case replaceProductLocally(product: Product, onCompletion: () -> Void)
8585

86-
/// Checks if the store has at least one product
86+
/// Checks if the store is eligible for products onboarding.
87+
/// Returns `true` if the store has no products.
8788
///
88-
case checkForProducts(siteID: Int64, onCompletion: (Result<Bool, Error>) -> Void)
89+
case checkProductsOnboardingEligibility(siteID: Int64, onCompletion: (Result<Bool, Error>) -> Void)
8990

9091
/// Creates a product using the provided template type.
9192
///

0 commit comments

Comments
 (0)