Skip to content

Commit

Permalink
Merge pull request #10389 from woocommerce/feat/10376-features-view
Browse files Browse the repository at this point in the history
[Profiler] Features question screen UI
  • Loading branch information
selanthiraiyan authored Aug 3, 2023
2 parents 200b97a + f56ec68 commit 3e73ec0
Show file tree
Hide file tree
Showing 7 changed files with 296 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ extension WooAnalyticsEvent.StoreCreation {
case profilerSellingPlatformsQuestion = "store_profiler_ecommerce_platforms"
case profilerCountryQuestion = "store_profiler_country"
case profilerChallengesQuestion = "store_profiler_challenges"
case profilerFeaturesQuestion = "store_profiler_features"
case domainPicker = "domain_picker"
case storeSummary = "store_summary"
case planPurchase = "plan_purchase"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Foundation

extension StoreCreationFeaturesQuestionViewModel {
// TODO: 10386 Align with Android and send these via tracks
enum Feature: String, CaseIterable {
case salesAndAnalyticsReports = "sales-and-analytics-reports"
case productManagementAndInventoryTracking = "product-management-and-inventory-tracking"
case flexibleAndSecurePaymentOptions = "flexible-and-secure-payment-options"
case inPersonPayment = "in-person-payment"
case abilityToScaleAsBusinessGrows = "ability-to-scale-as-business-grows"
case customizationOptionForStoreDesign = "customization-options-for-my-store-design"
case wideRangeOfPluginsAndExtensions = "wide-range-of-plugins-and-extensions"
case others = "cthers"
}

var features: [Feature] {
Feature.allCases
}
}

extension StoreCreationFeaturesQuestionViewModel.Feature {
var name: String {
switch self {
case .salesAndAnalyticsReports:
return NSLocalizedString("Comprehensive sales and analytics reports", comment: "Feature option in the store creation features question.")
case .productManagementAndInventoryTracking:
return NSLocalizedString("Easy product management and inventory tracking", comment: "Feature option in the store creation features question.")
case .flexibleAndSecurePaymentOptions:
return NSLocalizedString("Flexible and secure payment options", comment: "Feature option in the store creation features question.")
case .inPersonPayment:
return NSLocalizedString("In-person payment", comment: "Feature option in the store creation features question.")
case .abilityToScaleAsBusinessGrows:
return NSLocalizedString("Ability to scale as my business grows", comment: "Feature option in the store creation features question.")
case .customizationOptionForStoreDesign:
return NSLocalizedString("Customization options for my store design", comment: "Feature option in the store creation features question.")
case .wideRangeOfPluginsAndExtensions:
return NSLocalizedString("Access to a wide range of plugins and extensions", comment: "Feature option in the store creation features question.")
case .others:
return NSLocalizedString("Others", comment: "Feature option in the store creation features question.")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import SwiftUI

/// Hosting controller that wraps the `StoreCreationFeaturesQuestionView`.
final class StoreCreationFeaturesQuestionHostingController: UIHostingController<StoreCreationFeaturesQuestionView> {
init(viewModel: StoreCreationFeaturesQuestionViewModel) {
super.init(rootView: StoreCreationFeaturesQuestionView(viewModel: viewModel))
}

@available(*, unavailable)
required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func viewDidLoad() {
super.viewDidLoad()

configureTransparentNavigationBar()
}
}

/// Shows the store features question in the store creation flow.
struct StoreCreationFeaturesQuestionView: View {
@ObservedObject private var viewModel: StoreCreationFeaturesQuestionViewModel

init(viewModel: StoreCreationFeaturesQuestionViewModel) {
self.viewModel = viewModel
}

var body: some View {
OptionalStoreCreationProfilerQuestionView(viewModel: viewModel) {
VStack(alignment: .leading, spacing: 16) {
ForEach(viewModel.features, id: \.self) { feature in
Button(action: {
viewModel.didTapFeature(feature)
}, label: {
HStack {
Text(feature.name)
Spacer()
}
})
.buttonStyle(SelectableSecondaryButtonStyle(isSelected: viewModel.selectedFeatures.contains(where: { $0 == feature })))
}
}
}
}
}

struct StoreCreationFeaturesQuestionView_Previews: PreviewProvider {
static var previews: some View {
StoreCreationFeaturesQuestionView(viewModel: .init(onContinue: { _ in },
onSkip: {}))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Combine
import Foundation

/// Necessary data from the answer of the store creation features question.
struct StoreCreationFeaturesAnswer: Equatable {
/// Display name of the selected feature.
let name: String
/// Raw value of the feature to be sent to the backend.
let value: String
}

/// View model for `StoreCreationFeaturesQuestionView`, an optional profiler question about features in the store creation flow.
@MainActor
final class StoreCreationFeaturesQuestionViewModel: StoreCreationProfilerQuestionViewModel, ObservableObject {
typealias Answer = StoreCreationFeaturesAnswer

let topHeader = Localization.topHeader

let title = Localization.title

let subtitle = Localization.subtitle

@Published private(set) var selectedFeatures: [Feature] = []

private let onContinue: ([Answer]) -> Void
private let onSkip: () -> Void

init(onContinue: @escaping ([Answer]) -> Void,
onSkip: @escaping () -> Void) {
self.onContinue = onContinue
self.onSkip = onSkip
}
}

extension StoreCreationFeaturesQuestionViewModel: OptionalStoreCreationProfilerQuestionViewModel {
func continueButtonTapped() async {
guard selectedFeatures.isNotEmpty else {
return onSkip()
}

onContinue(selectedFeatures.map { .init(name: $0.name, value: $0.rawValue) })
}

func skipButtonTapped() {
onSkip()
}
}

extension StoreCreationFeaturesQuestionViewModel {
func didTapFeature(_ feature: Feature) {
if let alreadySelectedIndex = selectedFeatures.firstIndex(of: feature) {
selectedFeatures.remove(at: alreadySelectedIndex)
} else {
selectedFeatures.append(feature)
}
}
}

private extension StoreCreationFeaturesQuestionViewModel {
enum Localization {
static let topHeader = NSLocalizedString(
"About you",
comment: "Top header text of the store creation profiler question about the features."
)
static let title = NSLocalizedString(
"Which features are you most interested in?",
comment: "Title of the store creation profiler question about the features."
)
static let subtitle = NSLocalizedString(
"Let us know what you are looking forward to using in our app.",
comment: "Subtitle of the store creation profiler question about the features."
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,20 @@ private extension StoreCreationCoordinator {
analytics.track(event: .StoreCreation.siteCreationStep(step: .profilerChallengesQuestion))
}

@MainActor
func showFeaturesQuestion(from navigationController: UINavigationController) {
let questionController = StoreCreationFeaturesQuestionHostingController(viewModel:
.init { _ in
// TODO: 10376 - Navigate to [progress view / my store tab] and pass the selected features
} onSkip: { [weak self] in
guard let self else { return }
self.analytics.track(event: .StoreCreation.siteCreationProfilerQuestionSkipped(step: .profilerFeaturesQuestion))
// TODO: 10376 - Navigate to [progress view / my store tab]
})
navigationController.pushViewController(questionController, animated: true)
analytics.track(event: .StoreCreation.siteCreationStep(step: .profilerFeaturesQuestion))
}

@MainActor
func showCategoryQuestion(from navigationController: UINavigationController,
storeName: String) {
Expand Down
24 changes: 24 additions & 0 deletions WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2280,6 +2280,10 @@
EE57C11F297E742200BC31E7 /* WooAnalyticsEvent+ApplicationPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE57C11E297E742200BC31E7 /* WooAnalyticsEvent+ApplicationPassword.swift */; };
EE57C121297E76E000BC31E7 /* TrackEventRequestNotificationHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE57C120297E76E000BC31E7 /* TrackEventRequestNotificationHandlerTests.swift */; };
EE5A0A1C2A6908A800DA5926 /* WooAnalyticsEvent+LocalNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5A0A1B2A6908A800DA5926 /* WooAnalyticsEvent+LocalNotification.swift */; };
EE6A7BA92A7B7BE600D9A028 /* StoreCreationFeaturesQuestionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6A7BA82A7B7BE600D9A028 /* StoreCreationFeaturesQuestionViewModel.swift */; };
EE6A7BAB2A7B7C0100D9A028 /* StoreCreationFeaturesQuestionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6A7BAA2A7B7C0100D9A028 /* StoreCreationFeaturesQuestionView.swift */; };
EE6A7BAD2A7B7C1D00D9A028 /* StoreCreationFeaturesQuestionOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6A7BAC2A7B7C1D00D9A028 /* StoreCreationFeaturesQuestionOptions.swift */; };
EE6A7BAF2A7B811700D9A028 /* StoreCreationFeaturesQuestionViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6A7BAE2A7B811700D9A028 /* StoreCreationFeaturesQuestionViewModelTests.swift */; };
EE6B2AD129DC522300048A8F /* StoreCreationProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6B2AD029DC522300048A8F /* StoreCreationProgressView.swift */; };
EE6B2AD329DD285A00048A8F /* StoreCreationProgressViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6B2AD229DD285900048A8F /* StoreCreationProgressViewModel.swift */; };
EE6F08662A718DFB00AA9B88 /* FreeTrialSurveyViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE6F08652A718DFB00AA9B88 /* FreeTrialSurveyViewModelTests.swift */; };
Expand Down Expand Up @@ -4709,6 +4713,10 @@
EE57C11E297E742200BC31E7 /* WooAnalyticsEvent+ApplicationPassword.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooAnalyticsEvent+ApplicationPassword.swift"; sourceTree = "<group>"; };
EE57C120297E76E000BC31E7 /* TrackEventRequestNotificationHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackEventRequestNotificationHandlerTests.swift; sourceTree = "<group>"; };
EE5A0A1B2A6908A800DA5926 /* WooAnalyticsEvent+LocalNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WooAnalyticsEvent+LocalNotification.swift"; sourceTree = "<group>"; };
EE6A7BA82A7B7BE600D9A028 /* StoreCreationFeaturesQuestionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreationFeaturesQuestionViewModel.swift; sourceTree = "<group>"; };
EE6A7BAA2A7B7C0100D9A028 /* StoreCreationFeaturesQuestionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreationFeaturesQuestionView.swift; sourceTree = "<group>"; };
EE6A7BAC2A7B7C1D00D9A028 /* StoreCreationFeaturesQuestionOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreationFeaturesQuestionOptions.swift; sourceTree = "<group>"; };
EE6A7BAE2A7B811700D9A028 /* StoreCreationFeaturesQuestionViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreationFeaturesQuestionViewModelTests.swift; sourceTree = "<group>"; };
EE6B2AD029DC522300048A8F /* StoreCreationProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreationProgressView.swift; sourceTree = "<group>"; };
EE6B2AD229DD285900048A8F /* StoreCreationProgressViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreCreationProgressViewModel.swift; sourceTree = "<group>"; };
EE6F08652A718DFB00AA9B88 /* FreeTrialSurveyViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FreeTrialSurveyViewModelTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4879,6 +4887,7 @@
0201E42E2946F9F400C793C7 /* Category */,
026D4655295D7A380037F59A /* Country */,
EED028622A7AB4C800C5DE03 /* Challenges */,
EE6A7BA72A7B7BC900D9A028 /* Features */,
);
path = Profiler;
sourceTree = "<group>";
Expand All @@ -4901,6 +4910,7 @@
026D464F295C08CA0037F59A /* StoreCreationSellingPlatformsQuestionViewModelTests.swift */,
022F2FA9295E8241003A0A46 /* StoreCreationCountryQuestionViewModelTests.swift */,
EED028692A7B640300C5DE03 /* StoreCreationChallengesQuestionViewModelTests.swift */,
EE6A7BAE2A7B811700D9A028 /* StoreCreationFeaturesQuestionViewModelTests.swift */,
);
path = Profiler;
sourceTree = "<group>";
Expand Down Expand Up @@ -10647,6 +10657,16 @@
path = StoreDetails;
sourceTree = "<group>";
};
EE6A7BA72A7B7BC900D9A028 /* Features */ = {
isa = PBXGroup;
children = (
EE6A7BA82A7B7BE600D9A028 /* StoreCreationFeaturesQuestionViewModel.swift */,
EE6A7BAA2A7B7C0100D9A028 /* StoreCreationFeaturesQuestionView.swift */,
EE6A7BAC2A7B7C1D00D9A028 /* StoreCreationFeaturesQuestionOptions.swift */,
);
path = Features;
sourceTree = "<group>";
};
EE6B2ACD29DC488700048A8F /* Progress */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -11990,6 +12010,7 @@
45EF7984244F26BB00B22BA2 /* Array+IndexPath.swift in Sources */,
02E6B97823853D81000A36F0 /* TitleAndValueTableViewCell.swift in Sources */,
AE2E5F6629685CF8009262D3 /* ProductsListViewModel.swift in Sources */,
EE6A7BAB2A7B7C0100D9A028 /* StoreCreationFeaturesQuestionView.swift in Sources */,
CC770C8A27B1497700CE6ABC /* SearchHeader.swift in Sources */,
02BAB02724D13A6400F8B06E /* ProductVariationFormActionsFactory.swift in Sources */,
45CDAFAE2434CFCA00F83C22 /* ProductCatalogVisibilityViewController.swift in Sources */,
Expand Down Expand Up @@ -12471,6 +12492,7 @@
D81D9228222E7F0800FFA585 /* OrderStatusListViewController.swift in Sources */,
CEE006082077D14C0079161F /* OrderDetailsViewController.swift in Sources */,
AEB73C0C25CD734200A8454A /* AttributePickerViewModel.swift in Sources */,
EE6A7BAD2A7B7C1D00D9A028 /* StoreCreationFeaturesQuestionOptions.swift in Sources */,
D8752EF7265E60F4008ACC80 /* PaymentCaptureCelebration.swift in Sources */,
EE6B2AD129DC522300048A8F /* StoreCreationProgressView.swift in Sources */,
B58B4AB62108F11C00076FDD /* Notice.swift in Sources */,
Expand Down Expand Up @@ -12800,6 +12822,7 @@
0396CFAD2981476900E91436 /* CardPresentModalBuiltInConnectingFailed.swift in Sources */,
02C1853B27FF0D9C00ABD764 /* RefundSubmissionUseCase.swift in Sources */,
26C98F9B29C18ACE00F96503 /* StorePlanBanner.swift in Sources */,
EE6A7BA92A7B7BE600D9A028 /* StoreCreationFeaturesQuestionViewModel.swift in Sources */,
E10BD16D27CF890800CE6449 /* InPersonPaymentsCountryNotSupportedStripe.swift in Sources */,
68E674AB2A4DAB8C0034BA1E /* CompletedUpgradeView.swift in Sources */,
26F94E26267A559300DB6CCF /* ProductAddOn.swift in Sources */,
Expand Down Expand Up @@ -13417,6 +13440,7 @@
262AF38A2713B67600E39AFF /* SimplePaymentsAmountViewModelTests.swift in Sources */,
93FA787221CD2A1A00B663E5 /* CurrencySettingsTests.swift in Sources */,
45FBDF2D238BF8BF00127F77 /* AddProductImageCollectionViewCellTests.swift in Sources */,
EE6A7BAF2A7B811700D9A028 /* StoreCreationFeaturesQuestionViewModelTests.swift in Sources */,
578195FC25AD1D7C004A5C12 /* OrderFulfillmentUseCaseTests.swift in Sources */,
094C161227B0604700B25F51 /* ProductVariationFormViewModelTests.swift in Sources */,
EEAA45FD293073FE0047D125 /* JetpackInstallStepTests.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import XCTest
@testable import WooCommerce

@MainActor
final class StoreCreationFeaturesQuestionViewModelTests: XCTestCase {
func test_didTapFeature_adds_feature_to_selectedFeatures() throws {
// Given
let viewModel = StoreCreationFeaturesQuestionViewModel(onContinue: { _ in },
onSkip: {})

// When
viewModel.didTapFeature(.productManagementAndInventoryTracking)
viewModel.didTapFeature(.abilityToScaleAsBusinessGrows)

// Then
XCTAssertEqual(viewModel.selectedFeatures, [.productManagementAndInventoryTracking, .abilityToScaleAsBusinessGrows])
}

func test_didTapFeature_removes_feature_from_selectedFeatures_if_already_selected() throws {
// Given
let viewModel = StoreCreationFeaturesQuestionViewModel(onContinue: { _ in },
onSkip: {})

// When
viewModel.didTapFeature(.productManagementAndInventoryTracking)
viewModel.didTapFeature(.abilityToScaleAsBusinessGrows)

// Then
XCTAssertEqual(viewModel.selectedFeatures, [.productManagementAndInventoryTracking, .abilityToScaleAsBusinessGrows])

// When
viewModel.didTapFeature(.productManagementAndInventoryTracking)

// Then
XCTAssertEqual(viewModel.selectedFeatures, [.abilityToScaleAsBusinessGrows])
}

func test_continueButtonTapped_invokes_onContinue_after_selecting_features() throws {
let answer = waitFor { promise in
// Given
let viewModel = StoreCreationFeaturesQuestionViewModel(onContinue: { answer in
promise(answer)
},
onSkip: {})
// When
viewModel.didTapFeature(.productManagementAndInventoryTracking)
viewModel.didTapFeature(.abilityToScaleAsBusinessGrows)

Task { @MainActor in
await viewModel.continueButtonTapped()
}
}

// Then
XCTAssertEqual(answer, [.init(name: StoreCreationFeaturesQuestionViewModel.Feature.productManagementAndInventoryTracking.name,
value: "product-management-and-inventory-tracking"),
.init(name: StoreCreationFeaturesQuestionViewModel.Feature.abilityToScaleAsBusinessGrows.name,
value: "ability-to-scale-as-business-grows")])
}

func test_continueButtonTapped_invokes_onSkip_without_selecting_a_feature() throws {
waitFor { promise in
// Given
let viewModel = StoreCreationFeaturesQuestionViewModel( onContinue: { _ in },
onSkip: {
// Then
promise(())
})
// When
Task { @MainActor in
await viewModel.continueButtonTapped()
}
}
}

func test_skipButtonTapped_invokes_onSkip() throws {
waitFor { promise in
// Given
let viewModel = StoreCreationFeaturesQuestionViewModel( onContinue: { _ in },
onSkip: {
// Then
promise(())
})
// When
viewModel.skipButtonTapped()
}
}
}

0 comments on commit 3e73ec0

Please sign in to comment.