Skip to content
New issue

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

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

Already on GitHub? # to your account

[MBL-2110] [MBL-2113] Fixes for PPO analytics events #2289

Merged
merged 10 commits into from
Feb 19, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ final class PPOProjectCardViewModel: PPOProjectCardViewModelType {

func handle3DSState(_ state: PPOActionState) {
switch state {
case .processing:
case .processing, .confirmed:
self.buttonState = .loading
case .succeeded:
self.buttonState = .disabled
Expand All @@ -102,6 +102,7 @@ final class PPOProjectCardViewModel: PPOProjectCardViewModelType {

public enum PPOActionState {
case processing
case confirmed
case succeeded
case cancelled
case failed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,13 @@ public class PPOContainerViewController: PagedContainerViewController<PPOContain
self?.fixPayment(projectId: projectId, backingId: backingId)
case let .fix3DSChallenge(clientSecret, onProgress):
self?.handle3DSChallenge(clientSecret: clientSecret, onProgress: onProgress)
case let .confirmAddress(backingId, addressId, address):
self?.confirmAddress(backingId: backingId, addressId: addressId, address: address)
case let .confirmAddress(backingId, addressId, address, onProgress):
self?.confirmAddress(
backingId: backingId,
addressId: addressId,
address: address,
onProgress: onProgress
)
}
}.store(in: &self.subscriptions)

Expand Down Expand Up @@ -170,18 +175,29 @@ public class PPOContainerViewController: PagedContainerViewController<PPOContain
self.present(nav, animated: true, completion: nil)
}

private func confirmAddress(backingId: String, addressId: String, address: String) {
private func confirmAddress(
backingId: String,
addressId: String,
address: String,
onProgress: @escaping (PPOActionState) -> Void
) {
onProgress(.processing)

let alert = UIAlertController(
title: Strings.Confirm_your_address(),
message: address,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: Strings.Cancel(), style: .cancel))
alert.addAction(UIAlertAction(
title: Strings.Cancel(),
style: .cancel,
handler: { _ in onProgress(.cancelled) }
))
alert.addAction(UIAlertAction(
title: Strings.Confirm(),
style: .default,
handler: { [weak self] _ in
self?.viewModel.confirmAddress(addressId: addressId, backingId: backingId)
self?.viewModel.confirmAddress(addressId: addressId, backingId: backingId, onProgress: onProgress)
}
))
self.present(alert, animated: true, completion: nil)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ final class PPOContainerViewModel: PPOContainerViewModelInputs, PPOContainerView
)
case .failed:
return (.error, Strings.Something_went_wrong_please_try_again())
case .processing, .cancelled:
case .processing, .cancelled, .confirmed:
return nil
}
}
Expand All @@ -87,11 +87,17 @@ final class PPOContainerViewModel: PPOContainerViewModelInputs, PPOContainerView

// Confirm address call with result banners.
self.confirmAddressSubject
.handleEvents(receiveOutput: { data in
data.onProgress(.confirmed)
})
.flatMap { data in
AppEnvironment.current.apiService.confirmBackingAddress(
backingId: data.backingId,
addressId: data.addressId
)
.handleEvents(receiveOutput: { _ in
data.onProgress(.succeeded)
})
.catch { _ in Just(false) }
}
.compactMap { success -> MessageBannerConfiguration in
Expand Down Expand Up @@ -125,8 +131,8 @@ final class PPOContainerViewModel: PPOContainerViewModelInputs, PPOContainerView
self.process3DSAuthenticationState.send(state)
}

func confirmAddress(addressId: String, backingId: String) {
self.confirmAddressSubject.send((addressId: addressId, backingId: backingId))
func confirmAddress(addressId: String, backingId: String, onProgress: @escaping (PPOActionState) -> Void) {
self.confirmAddressSubject.send((addressId: addressId, backingId: backingId, onProgress: onProgress))
}

// MARK: - Outputs
Expand Down Expand Up @@ -161,7 +167,10 @@ final class PPOContainerViewModel: PPOContainerViewModelInputs, PPOContainerView
private let showBannerSubject = PassthroughSubject<MessageBannerConfiguration, Never>()
private let process3DSAuthenticationState = PassthroughSubject<PPOActionState, Never>()
private let stripeConfigurationSubject = PassthroughSubject<PPOStripeConfiguration, Never>()
private let confirmAddressSubject = PassthroughSubject<(addressId: String, backingId: String), Never>()
private let confirmAddressSubject = PassthroughSubject<
(addressId: String, backingId: String, onProgress: (PPOActionState) -> Void),
Never
>()

private var cancellables: Set<AnyCancellable> = []
}
58 changes: 48 additions & 10 deletions Kickstarter-iOS/Features/PledgedProjectsOverview/PPOViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ enum PPONavigationEvent: Equatable {
case survey(url: String)
case backingDetails(url: String)
case editAddress(url: String)
case confirmAddress(backingId: String, addressId: String, address: String)
case confirmAddress(
backingId: String,
addressId: String,
address: String,
onProgress: (PPOActionState) -> Void
)
case contactCreator(messageSubject: MessageSubject)

static func == (lhs: PPONavigationEvent, rhs: PPONavigationEvent) -> Bool {
Expand All @@ -62,8 +67,8 @@ enum PPONavigationEvent: Equatable {
):
return lhsSecret == rhsSecret
case let (
.confirmAddress(lhsBackingId, lhsAddressId, lhsAddress),
.confirmAddress(rhsBackingId, rhsAddressId, rhsAddress)
.confirmAddress(lhsBackingId, lhsAddressId, lhsAddress, _),
.confirmAddress(rhsBackingId, rhsAddressId, rhsAddress, _)
):
return lhsBackingId == rhsBackingId && lhsAddressId == rhsAddressId && lhsAddress == rhsAddress
case let (
Expand Down Expand Up @@ -142,7 +147,10 @@ final class PPOViewModel: ObservableObject, PPOViewModelInputs, PPOViewModelOutp
PPONavigationEvent.confirmAddress(
backingId: viewModel.backingGraphId,
addressId: addressId,
address: address
address: address,
onProgress: { [weak self] state in
self?.confirmAddressProgressSubject.send((viewModel, state))
}
)
},
self.contactCreatorSubject.map { viewModel in
Expand Down Expand Up @@ -171,15 +179,15 @@ final class PPOViewModel: ObservableObject, PPOViewModelInputs, PPOViewModelOutp

// Analytics: When view appears, the next time it loads, send a PPO dashboard open
self.viewDidAppearSubject
.combineLatest(latestLoadedResults)
.withFirst(from: latestLoadedResults)
.sink { _, properties in
AppEnvironment.current.ksrAnalytics.trackPPODashboardOpens(properties: properties)
}
.store(in: &self.cancellables)

// Analytics: Tap messaging creator
self.contactCreatorSubject
.combineLatest(latestLoadedResults)
.withFirst(from: latestLoadedResults)
.sink { card, overallProperties in
AppEnvironment.current.ksrAnalytics.trackPPOMessagingCreator(
from: card.projectAnalytics,
Expand All @@ -190,7 +198,7 @@ final class PPOViewModel: ObservableObject, PPOViewModelInputs, PPOViewModelOutp

// Analytics: Fixing payment failure
self.fixPaymentMethodSubject
.combineLatest(latestLoadedResults)
.withFirst(from: latestLoadedResults)
.sink { card, overallProperties in
AppEnvironment.current.ksrAnalytics.trackPPOFixingPaymentFailure(
project: card.projectAnalytics,
Expand All @@ -201,7 +209,7 @@ final class PPOViewModel: ObservableObject, PPOViewModelInputs, PPOViewModelOutp

// Analytics: Opening survey
self.openSurveySubject
.combineLatest(latestLoadedResults)
.withFirst(from: latestLoadedResults)
.sink { card, overallProperties in
AppEnvironment.current.ksrAnalytics.trackPPOOpeningSurvey(
project: card.projectAnalytics,
Expand All @@ -212,7 +220,7 @@ final class PPOViewModel: ObservableObject, PPOViewModelInputs, PPOViewModelOutp

// Analytics: Initiate confirming address
self.confirmAddressSubject
.combineLatest(latestLoadedResults)
.withFirst(from: latestLoadedResults)
.sink { cardProperties, overallProperties in
let (card, _, _) = cardProperties
AppEnvironment.current.ksrAnalytics.trackPPOInitiateConfirmingAddress(
Expand All @@ -222,9 +230,22 @@ final class PPOViewModel: ObservableObject, PPOViewModelInputs, PPOViewModelOutp
}
.store(in: &self.cancellables)

// Analytics: Finish confirming address
self.confirmAddressProgressSubject
.filter { $0.1 == .confirmed }
.map { $0.0 } // we just need the card
.withFirst(from: latestLoadedResults)
.sink { card, overallProperties in
AppEnvironment.current.ksrAnalytics.trackPPOSubmitAddressConfirmation(
project: card.projectAnalytics,
properties: overallProperties
)
}
.store(in: &self.cancellables)

// Analytics: Edit address
self.editAddressSubject
.combineLatest(latestLoadedResults)
.withFirst(from: latestLoadedResults)
.sink { card, overallProperties in
AppEnvironment.current.ksrAnalytics.trackPPOEditAddress(
project: card.projectAnalytics,
Expand Down Expand Up @@ -313,6 +334,10 @@ final class PPOViewModel: ObservableObject, PPOViewModelInputs, PPOViewModelOutp
private let viewBackingDetailsSubject = PassthroughSubject<PPOProjectCardModel, Never>()
private let editAddressSubject = PassthroughSubject<PPOProjectCardModel, Never>()
private let confirmAddressSubject = PassthroughSubject<(PPOProjectCardModel, String, String), Never>()
private let confirmAddressProgressSubject = PassthroughSubject<
(PPOProjectCardModel, PPOActionState),
Never
>()
private let contactCreatorSubject = PassthroughSubject<PPOProjectCardModel, Never>()
private var navigationEventSubject = PassthroughSubject<PPONavigationEvent, Never>()

Expand Down Expand Up @@ -353,3 +378,16 @@ extension Sequence where Element == PPOProjectCardViewModel {
)
}
}

extension Publisher {
/// Combines this publisher with the first value emitted by another publisher.
/// - Warning: This is not a direct replacement for `withLatestFrom` from other ReactiveX libraries.
/// - Parameter other: The publisher to grab the first value from
/// - Returns: A publisher that emits tuples of values from this publisher paired with the first value from the other publisher
func withFirst<B>(from other: B) -> AnyPublisher<(Self.Output, B.Output), Self.Failure> where B: Publisher,
B.Failure == Self.Failure {
return self.flatMap { foo in
other.first().map { (foo, $0) }
}.eraseToAnyPublisher()
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Combine
@testable import Kickstarter_Framework
@testable import KsApi
@testable import Library
import XCTest

class PPOViewModelTests: XCTestCase {
Expand Down Expand Up @@ -270,7 +271,8 @@ class PPOViewModelTests: XCTestCase {
event: .confirmAddress(
backingId: template.backingGraphId,
addressId: addressId,
address: address
address: address,
onProgress: { _ in }
)
)
}
Expand Down Expand Up @@ -323,6 +325,73 @@ class PPOViewModelTests: XCTestCase {
)
}

func testAnalyticsEvents_NotTriggeredOnRefresh() async throws {
let appTrackingTransparency = MockAppTrackingTransparency()
appTrackingTransparency.requestAndSetAuthorizationStatusFlag = false
appTrackingTransparency.shouldRequestAuthStatus = true
appTrackingTransparency.updateAdvertisingIdentifier()

let mockTrackingClient = MockTrackingClient()
let analytics = KSRAnalytics(
segmentClient: mockTrackingClient,
appTrackingTransparency: appTrackingTransparency
)

let mockService = MockService(
fetchPledgedProjectsResult: Result.success(try self.pledgedProjectsData(cursors: 1...3))
)

let reloadedMockService = MockService(
fetchPledgedProjectsResult: Result.success(try self.pledgedProjectsData(cursors: 4...6))
)

let initialLoadExpectation = XCTestExpectation(description: "Initial load")
initialLoadExpectation.expectedFulfillmentCount = 3
let refreshExpectation = XCTestExpectation(description: "Refresh complete")
refreshExpectation.expectedFulfillmentCount = 5

var values: [PPOViewModelPaginator.Results] = []
self.viewModel.$results
.sink { value in
values.append(value)
initialLoadExpectation.fulfill()
refreshExpectation.fulfill()
}
.store(in: &self.cancellables)

await withEnvironment(
apiService: mockService,
appTrackingTransparency: appTrackingTransparency,
ksrAnalytics: analytics
) { () async in
self.viewModel.viewDidAppear()

// Trigger some actions that generate analytics
self.viewModel.openSurvey(from: PPOProjectCardModel.completeSurveyTemplate)
self.viewModel.fixPaymentMethod(from: PPOProjectCardModel.fixPaymentTemplate)
self.viewModel.contactCreator(from: PPOProjectCardModel.addressLockTemplate)

await fulfillment(of: [initialLoadExpectation], timeout: 0.1)

// Store analytics event counts before refresh
let trackCountBefore = mockTrackingClient.tracks.count
XCTAssertEqual(trackCountBefore, 4)

await withEnvironment(
apiService: reloadedMockService,
appTrackingTransparency: appTrackingTransparency,
ksrAnalytics: analytics
) { () async in
await self.viewModel.refresh()
}

await fulfillment(of: [refreshExpectation], timeout: 0.1)

// Verify analytics events weren't triggered again
XCTAssertEqual(mockTrackingClient.tracks.count, trackCountBefore)
}
}

// Setup the view model to monitor navigation events, then run the closure, then check to make sure only that one event fired
private func verifyNavigationEvent(_ closure: () -> Void, event: PPONavigationEvent) {
let beforeResults: PPOViewModelPaginator.Results = self.viewModel.results
Expand Down