Skip to content

Commit 7ef4a7c

Browse files
authored
Merge pull request #8010 from woocommerce/issue/7853-retrieval-and-view-jitm-analytics
[Just In Time Messages] GET request and view analytic events
2 parents 7a2fb0e + 2489199 commit 7ef4a7c

File tree

9 files changed

+158
-62
lines changed

9 files changed

+158
-62
lines changed

WooCommerce/Classes/Analytics/WooAnalyticsEvent.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,8 +625,39 @@ extension WooAnalyticsEvent {
625625
enum JustInTimeMessage {
626626
private enum Keys {
627627
static let source = "source"
628+
static let justInTimeMessage = "jitm"
628629
static let justInTimeMessageID = "jitm_id"
629630
static let justInTimeMessageGroup = "jitm_group"
631+
static let count = "count"
632+
}
633+
634+
static func fetchSuccess(source: String,
635+
messageID: String,
636+
count: Int64) -> WooAnalyticsEvent {
637+
WooAnalyticsEvent(statName: .justInTimeMessageFetchSuccess,
638+
properties: [
639+
Keys.source: source,
640+
Keys.justInTimeMessage: messageID,
641+
Keys.count: count
642+
])
643+
}
644+
645+
static func fetchFailure(source: String,
646+
error: Error) -> WooAnalyticsEvent {
647+
WooAnalyticsEvent(statName: .justInTimeMessageFetchFailure,
648+
properties: [Keys.source: source],
649+
error: error)
650+
}
651+
652+
static func messageDisplayed(source: String,
653+
messageID: String,
654+
featureClass: String) -> WooAnalyticsEvent {
655+
WooAnalyticsEvent(statName: .justInTimeMessageDisplayed,
656+
properties: [
657+
Keys.source: source,
658+
Keys.justInTimeMessageID: messageID,
659+
Keys.justInTimeMessageGroup: featureClass
660+
])
630661
}
631662

632663
static func callToActionTapped(source: String,

WooCommerce/Classes/Analytics/WooAnalyticsStat.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,9 @@ public enum WooAnalyticsStat: String {
635635
case justInTimeMessageDismissTapped = "jitm_dismissed"
636636
case justInTimeMessageDismissSuccess = "jitm_dismiss_success"
637637
case justInTimeMessageDismissFailure = "jitm_dismiss_failure"
638+
case justInTimeMessageFetchSuccess = "jitm_fetch_success"
639+
case justInTimeMessageFetchFailure = "jitm_fetch_failure"
640+
case justInTimeMessageDisplayed = "jitm_displayed"
638641

639642
// MARK: Simple Payments events
640643
//

WooCommerce/Classes/ViewModels/Feature Announcement Cards/JustInTimeMessageAnnouncementCardViewModel.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ final class JustInTimeMessageAnnouncementCardViewModel: AnnouncementCardViewMode
7171

7272
// MARK: - AnnouncementCardViewModelProtocol methods
7373
func onAppear() {
74-
// No-op
74+
analytics.track(event: .JustInTimeMessage.messageDisplayed(source: screenName,
75+
messageID: messageID,
76+
featureClass: featureClass))
7577
}
7678

7779
func ctaTapped() {

WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewModel.swift

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@ final class DashboardViewModel {
1515

1616
private let stores: StoresManager
1717
private let featureFlagService: FeatureFlagService
18+
private let analytics: Analytics
1819

1920
init(stores: StoresManager = ServiceLocator.stores,
20-
featureFlags: FeatureFlagService = ServiceLocator.featureFlagService) {
21+
featureFlags: FeatureFlagService = ServiceLocator.featureFlagService,
22+
analytics: Analytics = ServiceLocator.analytics) {
2123
self.stores = stores
2224
self.featureFlagService = featureFlags
25+
self.analytics = analytics
2326
}
2427

2528
/// Syncs store stats for dashboard UI.
@@ -155,15 +158,24 @@ final class DashboardViewModel {
155158
hook: .adminNotices) { [weak self] result in
156159
guard let self = self else { return }
157160
switch result {
158-
case let .success(.some(message)):
161+
case let .success(messages):
162+
guard let message = messages.first else {
163+
return
164+
}
165+
self.analytics.track(event:
166+
.JustInTimeMessage.fetchSuccess(source: Constants.dashboardScreenName,
167+
messageID: message.messageID,
168+
count: Int64(messages.count)))
159169
let viewModel = JustInTimeMessageAnnouncementCardViewModel(
160170
justInTimeMessage: message,
161171
screenName: Constants.dashboardScreenName,
162172
siteID: siteID)
163173
self.announcementViewModel = viewModel
164174
viewModel.$showWebViewSheet.assign(to: &self.$showWebViewSheet)
165-
default:
166-
break
175+
case let .failure(error):
176+
self.analytics.track(event:
177+
.JustInTimeMessage.fetchFailure(source: Constants.dashboardScreenName,
178+
error: error))
167179
}
168180
}
169181
stores.dispatch(action)

WooCommerce/WooCommerceTests/ViewModels/Feature Announcement Cards/JustInTimeMessageAnnouncementCardViewModelTests.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,17 @@ final class JustInTimeMessageAnnouncementCardViewModelTests: XCTestCase {
159159
assertAnalyticEventLogged(name: "jitm_dismiss_failure", message: message, error: expectedError)
160160
}
161161

162+
func test_onAppear_tracks_just_in_time_message_displayed_analytic_event() {
163+
let message = Yosemite.JustInTimeMessage.fake().copy(messageID: "test-message-id", featureClass: "test-feature-class")
164+
setUp(with: message)
165+
166+
// When
167+
sut.onAppear()
168+
169+
// Then
170+
assertAnalyticEventLogged(name: "jitm_displayed", message: message)
171+
}
172+
162173
private func assertAnalyticEventLogged(name: String, message: Yosemite.JustInTimeMessage) {
163174
let expectedProperties = ["jitm_id": message.messageID,
164175
"jitm_group": message.featureClass,

WooCommerce/WooCommerceTests/ViewRelated/Dashboard/DashboardViewModelTests.swift

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ import struct Yosemite.JustInTimeMessage
99
final class DashboardViewModelTests: XCTestCase {
1010
private let sampleSiteID: Int64 = 122
1111

12+
private var analytics: Analytics!
13+
private var analyticsProvider: MockAnalyticsProvider!
14+
private var stores: MockStoresManager!
15+
16+
override func setUp() {
17+
analyticsProvider = MockAnalyticsProvider()
18+
analytics = WooAnalytics(analyticsProvider: analyticsProvider)
19+
stores = MockStoresManager(sessionManager: .makeForTesting())
20+
}
21+
1222
func test_default_statsVersion_is_v4() {
1323
// Given
1424
let viewModel = DashboardViewModel()
@@ -19,7 +29,6 @@ final class DashboardViewModelTests: XCTestCase {
1929

2030
func test_statsVersion_changes_from_v4_to_v3_when_store_stats_sync_returns_noRestRoute_error() {
2131
// Given
22-
let stores = MockStoresManager(sessionManager: .makeForTesting())
2332
stores.whenReceivingAction(ofType: StatsActionV4.self) { action in
2433
if case let .retrieveStats(_, _, _, _, _, _, completion) = action {
2534
completion(.failure(DotcomError.noRestRoute))
@@ -37,7 +46,6 @@ final class DashboardViewModelTests: XCTestCase {
3746

3847
func test_statsVersion_remains_v4_when_non_store_stats_sync_returns_noRestRoute_error() {
3948
// Given
40-
let stores = MockStoresManager(sessionManager: .makeForTesting())
4149
stores.whenReceivingAction(ofType: StatsActionV4.self) { action in
4250
if case let .retrieveStats(_, _, _, _, _, _, completion) = action {
4351
completion(.failure(DotcomError.empty))
@@ -61,7 +69,6 @@ final class DashboardViewModelTests: XCTestCase {
6169

6270
func test_statsVersion_changes_from_v3_to_v4_when_store_stats_sync_returns_success() {
6371
// Given
64-
let stores = MockStoresManager(sessionManager: .makeForTesting())
6572
// `DotcomError.noRestRoute` error indicates the stats are unavailable.
6673
var storeStatsResult: Result<Void, Error> = .failure(DotcomError.noRestRoute)
6774
stores.whenReceivingAction(ofType: StatsActionV4.self) { action in
@@ -84,7 +91,6 @@ final class DashboardViewModelTests: XCTestCase {
8491
func test_products_onboarding_announcements_take_precedence() {
8592
// Given
8693
MockABTesting.setVariation(.treatment(nil), for: .productsOnboardingBanner)
87-
let stores = MockStoresManager(sessionManager: .makeForTesting())
8894
stores.whenReceivingAction(ofType: ProductAction.self) { action in
8995
switch action {
9096
case let .checkProductsOnboardingEligibility(_, completion):
@@ -96,7 +102,7 @@ final class DashboardViewModelTests: XCTestCase {
96102
stores.whenReceivingAction(ofType: JustInTimeMessageAction.self) { action in
97103
switch action {
98104
case let .loadMessage(_, _, _, completion):
99-
completion(.success(Yosemite.JustInTimeMessage.fake()))
105+
completion(.success([Yosemite.JustInTimeMessage.fake()]))
100106
default:
101107
XCTFail("Received unsupported action: \(action)")
102108
}
@@ -112,7 +118,18 @@ final class DashboardViewModelTests: XCTestCase {
112118

113119
func test_view_model_syncs_just_in_time_messages_when_ineligible_for_products_onboarding() {
114120
// Given
115-
let stores = MockStoresManager(sessionManager: .makeForTesting())
121+
let message = Yosemite.JustInTimeMessage.fake().copy(title: "JITM Message")
122+
prepareStoresToShowJustInTimeMessage(.success([message]))
123+
let viewModel = DashboardViewModel(stores: stores)
124+
125+
// When
126+
viewModel.syncAnnouncements(for: sampleSiteID)
127+
128+
// Then
129+
XCTAssertEqual(viewModel.announcementViewModel?.title, "JITM Message")
130+
}
131+
132+
func prepareStoresToShowJustInTimeMessage(_ response: Result<[Yosemite.JustInTimeMessage], Error>) {
116133
stores.whenReceivingAction(ofType: ProductAction.self) { action in
117134
switch action {
118135
case let .checkProductsOnboardingEligibility(_, completion):
@@ -124,23 +141,15 @@ final class DashboardViewModelTests: XCTestCase {
124141
stores.whenReceivingAction(ofType: JustInTimeMessageAction.self) { action in
125142
switch action {
126143
case let .loadMessage(_, _, _, completion):
127-
completion(.success(Yosemite.JustInTimeMessage.fake().copy(title: "JITM Message")))
144+
completion(response)
128145
default:
129146
XCTFail("Received unsupported action: \(action)")
130147
}
131148
}
132-
let viewModel = DashboardViewModel(stores: stores)
133-
134-
// When
135-
viewModel.syncAnnouncements(for: sampleSiteID)
136-
137-
// Then
138-
XCTAssertEqual(viewModel.announcementViewModel?.title, "JITM Message")
139149
}
140150

141151
func test_no_announcement_to_display_when_no_announcements_are_synced() {
142152
// Given
143-
let stores = MockStoresManager(sessionManager: .makeForTesting())
144153
stores.whenReceivingAction(ofType: ProductAction.self) { action in
145154
switch action {
146155
case let .checkProductsOnboardingEligibility(_, completion):
@@ -152,7 +161,7 @@ final class DashboardViewModelTests: XCTestCase {
152161
stores.whenReceivingAction(ofType: JustInTimeMessageAction.self) { action in
153162
switch action {
154163
case let .loadMessage(_, _, _, completion):
155-
completion(.success(nil))
164+
completion(.success([]))
156165
default:
157166
XCTFail("Received unsupported action: \(action)")
158167
}
@@ -165,4 +174,65 @@ final class DashboardViewModelTests: XCTestCase {
165174
// Then
166175
XCTAssertNil(viewModel.announcementViewModel)
167176
}
177+
178+
func test_fetch_success_analytics_logged_when_just_in_time_messages_retrieved() {
179+
// Given
180+
let message = Yosemite.JustInTimeMessage.fake().copy(messageID: "test-message-id",
181+
featureClass: "test-feature-class")
182+
183+
let secondMessage = Yosemite.JustInTimeMessage.fake().copy(messageID: "test-message-id-2",
184+
featureClass: "test-feature-class-2")
185+
prepareStoresToShowJustInTimeMessage(.success([message, secondMessage]))
186+
let viewModel = DashboardViewModel(stores: stores, analytics: analytics)
187+
188+
// When
189+
viewModel.syncAnnouncements(for: sampleSiteID)
190+
191+
// Then
192+
guard let eventIndex = analyticsProvider.receivedEvents.firstIndex(of: "jitm_fetch_success"),
193+
let properties = analyticsProvider.receivedProperties[eventIndex] as? [String: AnyHashable]
194+
else {
195+
return XCTFail("Expected event was not logged")
196+
}
197+
198+
assertEqual("my_store", properties["source"] as? String)
199+
assertEqual("test-message-id", properties["jitm"] as? String)
200+
assertEqual(2, properties["count"] as? Int64)
201+
}
202+
203+
func test_when_two_messages_are_received_only_the_first_is_displayed() {
204+
// Given
205+
let message = Yosemite.JustInTimeMessage.fake().copy(title: "Higher priority JITM")
206+
207+
let secondMessage = Yosemite.JustInTimeMessage.fake().copy(title: "Lower priority JITM")
208+
prepareStoresToShowJustInTimeMessage(.success([message, secondMessage]))
209+
let viewModel = DashboardViewModel(stores: stores, analytics: analytics)
210+
211+
// When
212+
viewModel.syncAnnouncements(for: sampleSiteID)
213+
214+
// Then
215+
XCTAssertEqual(viewModel.announcementViewModel?.title, "Higher priority JITM")
216+
}
217+
218+
func test_fetch_failure_analytics_logged_when_just_in_time_message_errors() {
219+
// Given
220+
let error = DotcomError.noRestRoute
221+
prepareStoresToShowJustInTimeMessage(.failure(error))
222+
let viewModel = DashboardViewModel(stores: stores, analytics: analytics)
223+
224+
// When
225+
viewModel.syncAnnouncements(for: sampleSiteID)
226+
227+
// Then
228+
guard let eventIndex = analyticsProvider.receivedEvents.firstIndex(of: "jitm_fetch_failure"),
229+
let properties = analyticsProvider.receivedProperties[eventIndex] as? [String: AnyHashable]
230+
else {
231+
return XCTFail("Expected event was not logged")
232+
}
233+
234+
assertEqual("my_store", properties["source"] as? String)
235+
assertEqual("Networking.DotcomError", properties["error_domain"] as? String)
236+
assertEqual("Dotcom Invalid REST Route", properties["error_description"] as? String)
237+
}
168238
}

Yosemite/Yosemite/Actions/JustInTimeMessageAction.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ public enum JustInTimeMessageAction: Action {
88
case loadMessage(siteID: Int64,
99
screen: String,
1010
hook: JustInTimeMessageHook,
11-
completion: (Result<JustInTimeMessage?, Error>) -> ())
11+
completion: (Result<[JustInTimeMessage], Error>) -> ())
1212

1313
/// Dismisses a `JustInTimeMessage` and others for the same `featureClass`
1414
///

Yosemite/Yosemite/Stores/JustInTimeMessageStore.swift

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,25 +44,22 @@ private extension JustInTimeMessageStore {
4444
func loadMessage(for siteID: Int64,
4545
screen: String,
4646
hook: JustInTimeMessageHook,
47-
completion: @escaping (Result<JustInTimeMessage?, Error>) -> ()) {
47+
completion: @escaping (Result<[JustInTimeMessage], Error>) -> ()) {
4848
Task {
4949
let result = await remote.loadAllJustInTimeMessages(
5050
for: siteID,
5151
messagePath: .init(app: .wooMobile,
5252
screen: screen,
5353
hook: hook))
54-
let displayResult = result.map(topDisplayMessage(_:))
54+
let displayResult = result.map(displayMessages(_:))
5555
await MainActor.run {
5656
completion(displayResult)
5757
}
5858
}
5959
}
6060

61-
func topDisplayMessage(_ messages: [Networking.JustInTimeMessage]) -> JustInTimeMessage? {
62-
guard let topMessage = messages.first else {
63-
return nil
64-
}
65-
return JustInTimeMessage(message: topMessage)
61+
func displayMessages(_ messages: [Networking.JustInTimeMessage]) -> [JustInTimeMessage] {
62+
return messages.map { JustInTimeMessage(message: $0) }
6663
}
6764

6865
func dismissMessage(_ message: JustInTimeMessage,

Yosemite/YosemiteTests/Stores/JustInTimeMessageStoreTests.swift

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ final class JustInTimeMessageStoreTests: XCTestCase {
2626
sut = JustInTimeMessageStore(dispatcher: Dispatcher(), storageManager: storageManager, network: network)
2727
}
2828

29-
func test_loadMessage_then_it_returns_nil_upon_successful_empty_response() throws {
29+
func test_loadMessage_then_it_returns_empty_array_upon_successful_empty_response() throws {
3030
// Given
3131
network.simulateResponse(requestUrlSuffix: "jetpack/v4/jitm", filename: "empty-data-array")
3232

@@ -43,7 +43,7 @@ final class JustInTimeMessageStoreTests: XCTestCase {
4343

4444
// Then
4545
XCTAssert(result.isSuccess)
46-
XCTAssertNil(try result.get())
46+
XCTAssert(try result.get().isEmpty)
4747
}
4848

4949
func test_loadMessage_then_it_returns_the_Just_In_Time_Message_upon_successful_response() throws {
@@ -72,37 +72,7 @@ final class JustInTimeMessageStoreTests: XCTestCase {
7272

7373
// Then
7474
XCTAssert(result.isSuccess)
75-
let recievedMessage = try XCTUnwrap(result.get())
76-
assertEqual(expectedJustInTimeMessage, recievedMessage)
77-
}
78-
79-
func test_loadMessage_then_it_returns_the_first_Just_In_Time_Message_upon_successful_response_with_multiple_jitms() throws {
80-
// Given
81-
network.simulateResponse(requestUrlSuffix: "jetpack/v4/jitm", filename: "just-in-time-message-list-multiple")
82-
83-
// When
84-
let result = waitFor { promise in
85-
let action = JustInTimeMessageAction.loadMessage(
86-
siteID: self.sampleSiteID,
87-
screen: "my_store",
88-
hook: .adminNotices) { result in
89-
promise(result)
90-
}
91-
self.sut.onAction(action)
92-
}
93-
94-
let expectedJustInTimeMessage = JustInTimeMessage(
95-
siteID: sampleSiteID,
96-
messageID: "woomobile_onboarding_add_product",
97-
featureClass: "woomobile_onboarding_products",
98-
title: "Add some products",
99-
detail: "Get started selling your products: add them easily using the Products tab",
100-
buttonTitle: "Add product",
101-
url: "woocommerce://products/add")
102-
103-
// Then
104-
XCTAssert(result.isSuccess)
105-
let recievedMessage = try XCTUnwrap(result.get())
75+
let recievedMessage = try XCTUnwrap(result.get().first)
10676
assertEqual(expectedJustInTimeMessage, recievedMessage)
10777
}
10878

0 commit comments

Comments
 (0)