diff --git a/Experiments/Experiments/DefaultFeatureFlagService.swift b/Experiments/Experiments/DefaultFeatureFlagService.swift index 2a319aca117..9c7c8bef2a2 100644 --- a/Experiments/Experiments/DefaultFeatureFlagService.swift +++ b/Experiments/Experiments/DefaultFeatureFlagService.swift @@ -37,6 +37,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService { return buildConfig == .localDeveloper || buildConfig == .alpha case .storeCreationMVP: return true + case .storeCreationM2: + return buildConfig == .localDeveloper || buildConfig == .alpha case .justInTimeMessagesOnDashboard: return true case .productsOnboarding: diff --git a/Experiments/Experiments/FeatureFlag.swift b/Experiments/Experiments/FeatureFlag.swift index 6fe1638265a..4f206985fd7 100644 --- a/Experiments/Experiments/FeatureFlag.swift +++ b/Experiments/Experiments/FeatureFlag.swift @@ -78,6 +78,10 @@ public enum FeatureFlag: Int { /// case storeCreationMVP + /// Store creation milestone 2. https://wp.me/pe5sF9-I3 + /// + case storeCreationM2 + /// Just In Time Messages on Dashboard /// case justInTimeMessagesOnDashboard diff --git a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift index 95f3cdf4b4d..754f058228d 100644 --- a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift +++ b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift @@ -1,6 +1,7 @@ import Combine import UIKit import Yosemite +import protocol Experiments.FeatureFlagService import protocol Storage.StorageManagerType /// Coordinates navigation for store creation flow, with the assumption that the app is already authenticated with a WPCOM user. @@ -22,12 +23,14 @@ final class StoreCreationCoordinator: Coordinator { private let source: Source private let storePickerViewModel: StorePickerViewModel private let switchStoreUseCase: SwitchStoreUseCaseProtocol + private let featureFlagService: FeatureFlagService init(source: Source, navigationController: UINavigationController, storageManager: StorageManagerType = ServiceLocator.storageManager, stores: StoresManager = ServiceLocator.stores, - analytics: Analytics = ServiceLocator.analytics) { + analytics: Analytics = ServiceLocator.analytics, + featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService) { self.source = source self.navigationController = navigationController // Passing the `standard` configuration to include sites without WooCommerce (`isWooCommerceActive = false`). @@ -37,9 +40,17 @@ final class StoreCreationCoordinator: Coordinator { analytics: analytics) self.switchStoreUseCase = SwitchStoreUseCase(stores: stores, storageManager: storageManager) self.analytics = analytics + self.featureFlagService = featureFlagService } func start() { + featureFlagService.isFeatureFlagEnabled(.storeCreationM2) ? + startStoreCreationM2(): startStoreCreationM1() + } +} + +private extension StoreCreationCoordinator { + func startStoreCreationM1() { observeSiteURLsFromStoreCreation() let viewModel = StoreCreationWebViewModel { [weak self] result in @@ -52,14 +63,29 @@ final class StoreCreationCoordinator: Coordinator { // Disables interactive dismissal of the store creation modal. webNavigationController.isModalInPresentation = true + presentStoreCreation(viewController: webNavigationController) + } + + func startStoreCreationM2() { + let domainSelector = DomainSelectorHostingController(viewModel: .init(), + onDomainSelection: { domain in + // TODO-8045: navigate to the next step of store creation. + }, onSkip: { + // TODO-8045: skip to the next step of store creation with an auto-generated domain. + }) + let storeCreationNavigationController = UINavigationController(rootViewController: domainSelector) + presentStoreCreation(viewController: storeCreationNavigationController) + } + + func presentStoreCreation(viewController: UIViewController) { // If the navigation controller is already presenting another view, the view needs to be dismissed before store // creation view can be presented. if navigationController.presentedViewController != nil { navigationController.dismiss(animated: true) { [weak self] in - self?.navigationController.present(webNavigationController, animated: true) + self?.navigationController.present(viewController, animated: true) } } else { - navigationController.present(webNavigationController, animated: true) + navigationController.present(viewController, animated: true) } } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainRowView.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainRowView.swift new file mode 100644 index 00000000000..022508baf90 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainRowView.swift @@ -0,0 +1,58 @@ +import SwiftUI + +/// View model for a row in a list of domain suggestions. +struct DomainRowViewModel { + /// The domain name is used for the selected state. + let name: String + /// Attributed name to be displayed in the row. + let attributedName: AttributedString + /// Whether the domain is selected. + let isSelected: Bool + + init(domainName: String, searchQuery: String, isSelected: Bool) { + self.name = domainName + self.isSelected = isSelected + self.attributedName = { + var attributedName = AttributedString(domainName) + attributedName.font = isSelected ? .body.bold(): .body + attributedName.foregroundColor = .init(.label) + + if let rangeOfSearchQuery = attributedName + .range(of: searchQuery + // Removes leading/trailing spaces in the search query. + .trimmingCharacters(in: .whitespacesAndNewlines) + // Removes spaces in the search query. + .split(separator: " ").joined() + .lowercased()) { + attributedName[rangeOfSearchQuery].font = .body + attributedName[rangeOfSearchQuery].foregroundColor = .init(.secondaryLabel) + } + return attributedName + }() + } +} + +/// A row that shows an attributed domain name with a checkmark if the domain is selected. +struct DomainRowView: View { + let viewModel: DomainRowViewModel + + var body: some View { + HStack { + Text(viewModel.attributedName) + if viewModel.isSelected { + Spacer() + Image(uiImage: .checkmarkImage) + .foregroundColor(Color(.brand)) + } + } + } +} + +struct DomainRowView_Previews: PreviewProvider { + static var previews: some View { + VStack(alignment: .leading) { + DomainRowView(viewModel: .init(domainName: "whitechristmastrees.mywc.mysite", searchQuery: "White Christmas Trees", isSelected: true)) + DomainRowView(viewModel: .init(domainName: "whitechristmastrees.mywc.mysite", searchQuery: "White Christmas", isSelected: false)) + } + } +} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift new file mode 100644 index 00000000000..3746d085418 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift @@ -0,0 +1,142 @@ +import SwiftUI + +/// Hosting controller that wraps the `DomainSelectorView` view. +final class DomainSelectorHostingController: UIHostingController { + private let viewModel: DomainSelectorViewModel + private let onDomainSelection: (String) -> Void + private let onSkip: () -> Void + + /// - Parameters: + /// - viewModel: View model for the domain selector. + /// - onDomainSelection: Called when the user continues with a selected domain name. + /// - onSkip: Called when the user taps to skip domain selection. + init(viewModel: DomainSelectorViewModel, + onDomainSelection: @escaping (String) -> Void, + onSkip: @escaping () -> Void) { + self.viewModel = viewModel + self.onDomainSelection = onDomainSelection + self.onSkip = onSkip + super.init(rootView: DomainSelectorView(viewModel: viewModel)) + + rootView.onDomainSelection = { [weak self] domain in + self?.onDomainSelection(domain) + } + } + + required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + configureSkipButton() + configureNavigationBarAppearance() + } +} + +private extension DomainSelectorHostingController { + func configureSkipButton() { + navigationItem.rightBarButtonItem = .init(title: Localization.skipButtonTitle, style: .plain, target: self, action: #selector(skipButtonTapped)) + } + + /// Shows a transparent navigation bar without a bottom border. + func configureNavigationBarAppearance() { + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.backgroundColor = UIColor.clear + + navigationItem.standardAppearance = appearance + navigationItem.scrollEdgeAppearance = appearance + navigationItem.compactAppearance = appearance + } +} + +private extension DomainSelectorHostingController { + @objc func skipButtonTapped() { + onSkip() + } +} + +private extension DomainSelectorHostingController { + enum Localization { + static let skipButtonTitle = NSLocalizedString("Skip", comment: "Navigation bar button on the domain selector screen to skip domain selection.") + } +} + +/// Allows the user to search for a domain and then select one to continue. +struct DomainSelectorView: View { + /// Set in the hosting controller. + var onDomainSelection: ((String) -> Void) = { _ in } + + /// View model to drive the view. + @ObservedObject var viewModel: DomainSelectorViewModel + + /// Currently selected domain name. + /// If this property is kept in the view model, a SwiftUI error appears `Publishing changes from within view updates` + /// when a domain row is selected. + @State var selectedDomainName: String? + + var body: some View { + ScrollableVStack(alignment: .leading) { + // Header labels. + VStack(alignment: .leading, spacing: Layout.spacingBetweenTitleAndSubtitle) { + Text(Localization.title) + .titleStyle() + Text(Localization.subtitle) + .foregroundColor(Color(.secondaryLabel)) + .bodyStyle() + } + .padding(.horizontal, Layout.defaultHorizontalPadding) + + SearchHeader(filterText: $viewModel.searchTerm, + filterPlaceholder: Localization.searchPlaceholder) + .padding(.horizontal, Layout.defaultHorizontalPadding) + + Text(Localization.suggestionsHeader) + .foregroundColor(Color(.secondaryLabel)) + .bodyStyle() + .padding(.horizontal, Layout.defaultHorizontalPadding) + + List(viewModel.domains, id: \.self) { domain in + Button { + selectedDomainName = domain + } label: { + DomainRowView(viewModel: .init(domainName: domain, + searchQuery: viewModel.searchTerm, + isSelected: domain == selectedDomainName)) + } + }.listStyle(.inset) + + if let selectedDomainName { + Button(Localization.continueButtonTitle) { + onDomainSelection(selectedDomainName) + } + .buttonStyle(PrimaryButtonStyle()) + } + } + } +} + +private extension DomainSelectorView { + enum Layout { + static let spacingBetweenTitleAndSubtitle: CGFloat = 16 + static let defaultHorizontalPadding: CGFloat = 16 + } + + enum Localization { + static let title = NSLocalizedString("Choose a domain", comment: "Title of the domain selector.") + static let subtitle = NSLocalizedString( + "This is where people will find you on the Internet. Don't worry, you can change it later.", + comment: "Subtitle of the domain selector.") + static let searchPlaceholder = NSLocalizedString("Type to get suggestions", comment: "Placeholder of the search text field on the domain selector.") + static let suggestionsHeader = NSLocalizedString("SUGGESTIONS", comment: "Header label of the domain suggestions on the domain selector.") + static let continueButtonTitle = NSLocalizedString("Continue", comment: "Title of the button to continue with a selected domain.") + } +} + +struct DomainSelectorView_Previews: PreviewProvider { + static var previews: some View { + DomainSelectorView(viewModel: .init()) + } +} diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift new file mode 100644 index 00000000000..bb263c61d30 --- /dev/null +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift @@ -0,0 +1,78 @@ +import Combine +import SwiftUI +import Yosemite + +/// View model for `DomainSelectorView`. +final class DomainSelectorViewModel: ObservableObject { + /// Current search term entered by the user. + /// Each update will trigger a remote call for domain suggestions. + @Published var searchTerm: String = "" + + /// Domain names after domain suggestions are loaded remotely. + @Published private(set) var domains: [String] = [] + + /// Subscription for search query changes for domain search. + private var searchQuerySubscription: AnyCancellable? + + private let stores: StoresManager + private let debounceDuration: Double + + init(stores: StoresManager = ServiceLocator.stores, + debounceDuration: Double = Constants.fieldDebounceDuration) { + self.stores = stores + self.debounceDuration = debounceDuration + observeDomainQuery() + } +} + +private extension DomainSelectorViewModel { + func observeDomainQuery() { + searchQuerySubscription = $searchTerm + .filter { $0.isNotEmpty } + .removeDuplicates() + .debounce(for: .seconds(debounceDuration), scheduler: DispatchQueue.main) + .sink { [weak self] searchTerm in + guard let self = self else { return } + Task { @MainActor in + let result = await self.loadFreeDomainSuggestions(query: searchTerm) + switch result { + case .success(let suggestions): + self.handleFreeDomainSuggestions(suggestions, query: searchTerm) + case .failure(let error): + self.handleError(error) + } + } + } + } + + @MainActor + func loadFreeDomainSuggestions(query: String) async -> Result<[FreeDomainSuggestion], Error> { + await withCheckedContinuation { continuation in + let action = DomainAction.loadFreeDomainSuggestions(query: searchTerm) { result in + continuation.resume(returning: result) + } + stores.dispatch(action) + } + } + + @MainActor + func handleFreeDomainSuggestions(_ suggestions: [FreeDomainSuggestion], query: String) { + domains = suggestions + .filter { $0.isFree } + .map { + $0.name + } + } + + @MainActor + func handleError(_ error: Error) { + // TODO-8045: error handling - maybe show an error message. + DDLogError("Cannot load domain suggestions for \(searchTerm)") + } +} + +private extension DomainSelectorViewModel { + enum Constants { + static let fieldDebounceDuration = 0.3 + } +} diff --git a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift index 7c0c8bb2258..95f109e1bb3 100644 --- a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift +++ b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift @@ -37,6 +37,7 @@ class AuthenticatedState: StoresManagerState { CouponStore(dispatcher: dispatcher, storageManager: storageManager, network: network), CustomerStore(dispatcher: dispatcher, storageManager: storageManager, network: network), DataStore(dispatcher: dispatcher, storageManager: storageManager, network: network), + DomainStore(dispatcher: dispatcher, storageManager: storageManager, network: network), InAppPurchaseStore(dispatcher: dispatcher, storageManager: storageManager, network: network), InboxNotesStore(dispatcher: dispatcher, storageManager: storageManager, network: network), JustInTimeMessageStore(dispatcher: dispatcher, storageManager: storageManager, network: network), diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index df86aa9a927..1d8bdf71b03 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -131,6 +131,8 @@ 0235BFD9246E959500778909 /* ProductFormActionsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0235BFD8246E959500778909 /* ProductFormActionsFactory.swift */; }; 0235BFDB246E99A700778909 /* ProductFormActionsFactory+NonEmptyBottomSheetActionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0235BFDA246E99A700778909 /* ProductFormActionsFactory+NonEmptyBottomSheetActionsTests.swift */; }; 0236BCA425087B660043EB43 /* ProductFormRemoteActionUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0236BCA325087B660043EB43 /* ProductFormRemoteActionUseCaseTests.swift */; }; + 023930612918F36400B2632F /* DomainSelectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023930602918F36400B2632F /* DomainSelectorView.swift */; }; + 02393069291A065000B2632F /* DomainRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02393068291A065000B2632F /* DomainRowView.swift */; }; 02396251239948470096F34C /* UIImage+TintColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02396250239948470096F34C /* UIImage+TintColor.swift */; }; 023A059A24135F2600E3FC99 /* ReviewsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023A059824135F2600E3FC99 /* ReviewsViewController.swift */; }; 023A059B24135F2600E3FC99 /* ReviewsViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 023A059924135F2600E3FC99 /* ReviewsViewController.xib */; }; @@ -386,6 +388,8 @@ 02CEBB8224C98861002EDF35 /* ProductFormDataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CEBB8124C98861002EDF35 /* ProductFormDataModel.swift */; }; 02CEBB8424C99A10002EDF35 /* Product+ShippingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CEBB8324C99A10002EDF35 /* Product+ShippingTests.swift */; }; 02D45647231CB1FB008CF0A9 /* UIImage+Dot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D45646231CB1FB008CF0A9 /* UIImage+Dot.swift */; }; + 02DAE7FC291B7B8B009342B7 /* DomainSelectorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DAE7FB291B7B8B009342B7 /* DomainSelectorViewModel.swift */; }; + 02DAE7FF291B8C8A009342B7 /* DomainSelectorViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DAE7FE291B8C8A009342B7 /* DomainSelectorViewModelTests.swift */; }; 02DC2ED2242061BF002F9676 /* ProductPriceSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DC2ED1242061BE002F9676 /* ProductPriceSettingsViewModel.swift */; }; 02DD81F9242CAA400060E50B /* WordPressMediaLibraryImagePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DD81F5242CAA3F0060E50B /* WordPressMediaLibraryImagePickerViewController.swift */; }; 02DD81FA242CAA400060E50B /* Media+WPMediaAsset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DD81F6242CAA3F0060E50B /* Media+WPMediaAsset.swift */; }; @@ -2086,6 +2090,8 @@ 0235BFD8246E959500778909 /* ProductFormActionsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductFormActionsFactory.swift; sourceTree = ""; }; 0235BFDA246E99A700778909 /* ProductFormActionsFactory+NonEmptyBottomSheetActionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProductFormActionsFactory+NonEmptyBottomSheetActionsTests.swift"; sourceTree = ""; }; 0236BCA325087B660043EB43 /* ProductFormRemoteActionUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductFormRemoteActionUseCaseTests.swift; sourceTree = ""; }; + 023930602918F36400B2632F /* DomainSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainSelectorView.swift; sourceTree = ""; }; + 02393068291A065000B2632F /* DomainRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainRowView.swift; sourceTree = ""; }; 02396250239948470096F34C /* UIImage+TintColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+TintColor.swift"; sourceTree = ""; }; 023A059824135F2600E3FC99 /* ReviewsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewsViewController.swift; sourceTree = ""; }; 023A059924135F2600E3FC99 /* ReviewsViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ReviewsViewController.xib; sourceTree = ""; }; @@ -2341,6 +2347,8 @@ 02CEBB8124C98861002EDF35 /* ProductFormDataModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductFormDataModel.swift; sourceTree = ""; }; 02CEBB8324C99A10002EDF35 /* Product+ShippingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Product+ShippingTests.swift"; sourceTree = ""; }; 02D45646231CB1FB008CF0A9 /* UIImage+Dot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Dot.swift"; sourceTree = ""; }; + 02DAE7FB291B7B8B009342B7 /* DomainSelectorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainSelectorViewModel.swift; sourceTree = ""; }; + 02DAE7FE291B8C8A009342B7 /* DomainSelectorViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainSelectorViewModelTests.swift; sourceTree = ""; }; 02DC2ED1242061BE002F9676 /* ProductPriceSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPriceSettingsViewModel.swift; sourceTree = ""; }; 02DD81F5242CAA3F0060E50B /* WordPressMediaLibraryImagePickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WordPressMediaLibraryImagePickerViewController.swift; sourceTree = ""; }; 02DD81F6242CAA3F0060E50B /* Media+WPMediaAsset.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Media+WPMediaAsset.swift"; sourceTree = ""; }; @@ -4237,6 +4245,16 @@ path = BottomSheetListSelector; sourceTree = ""; }; + 0239305F2918F35600B2632F /* Domains */ = { + isa = PBXGroup; + children = ( + 023930602918F36400B2632F /* DomainSelectorView.swift */, + 02393068291A065000B2632F /* DomainRowView.swift */, + 02DAE7FB291B7B8B009342B7 /* DomainSelectorViewModel.swift */, + ); + path = Domains; + sourceTree = ""; + }; 023D69BA2589BF2500F7DA72 /* Refund Shipping Label */ = { isa = PBXGroup; children = ( @@ -4835,6 +4853,14 @@ path = "Beta features"; sourceTree = ""; }; + 02DAE7FD291B8C7C009342B7 /* Domains */ = { + isa = PBXGroup; + children = ( + 02DAE7FE291B8C8A009342B7 /* DomainSelectorViewModelTests.swift */, + ); + path = Domains; + sourceTree = ""; + }; 02DFECE525EE33430070F212 /* Create Shipping Label Info */ = { isa = PBXGroup; children = ( @@ -6111,6 +6137,7 @@ isa = PBXGroup; children = ( 027111402913B9D400F5269A /* Authentication */, + 02DAE7FD291B8C7C009342B7 /* Domains */, D41C9F2F26D9A41F00993558 /* WhatsNew */, D8025469265517F9001B2CC1 /* CardPresentPayments */, 0371C36F2876ED3A00277E2C /* Feature Announcement Cards */, @@ -7992,6 +8019,7 @@ isa = PBXGroup; children = ( 02ACD2592852E11700EC928E /* RemoveAppleIDAccessCoordinator.swift */, + 0239305F2918F35600B2632F /* Domains */, BAF1B3B32736595A00BA11DC /* Settings */, DE8C9464264698E800C94823 /* Plugins */, 74C6FEA321C2F189009286B6 /* About */, @@ -9976,6 +10004,7 @@ 26DB7E3528636D2200506173 /* NonEditableOrderBanner.swift in Sources */, 314DC4BF268D183600444C9E /* CardReaderSettingsKnownReaderStorage.swift in Sources */, 2662D90826E15D6E00E25611 /* CountrySelectorCommand.swift in Sources */, + 02393069291A065000B2632F /* DomainRowView.swift in Sources */, 024A543422BA6F8F00F4F38E /* DeveloperEmailChecker.swift in Sources */, 02AB407C27827C9100929CF3 /* ChartPlaceholderView.swift in Sources */, 027B8BB823FE0CB30040944E /* DefaultProductUIImageLoader.swift in Sources */, @@ -10172,6 +10201,7 @@ 02820F3422C257B700DE0D37 /* UITableView+HeaderFooterHelpers.swift in Sources */, D8C2A291231BD0FD00F503E9 /* ReviewsDataSource.swift in Sources */, CE855366209BA6A700938BDC /* CustomerInfoTableViewCell.swift in Sources */, + 02DAE7FC291B7B8B009342B7 /* DomainSelectorViewModel.swift in Sources */, 4521396E27FEE55200964ED3 /* FullScreenTextView.swift in Sources */, DE126D0B26CA2331007F901D /* ValidationErrorRow.swift in Sources */, E1E649E92846188C0070B194 /* BetaFeature.swift in Sources */, @@ -10648,6 +10678,7 @@ D449C51D26DE6B5000D75B02 /* LargeTitle.swift in Sources */, 456396B625C82691001F1A26 /* ShippingLabelFormStepTableViewCell.swift in Sources */, 03FBDA9D263AD49200ACE257 /* CouponListViewController.swift in Sources */, + 023930612918F36400B2632F /* DomainSelectorView.swift in Sources */, 02C8876D24501FAC00E4470F /* FilterListViewController.swift in Sources */, 02B2828E27C35061004A332A /* RefreshableInfiniteScrollList.swift in Sources */, 021FB44C24A5E3B00090E144 /* ProductListMultiSelectorSearchUICommand.swift in Sources */, @@ -10733,6 +10764,7 @@ 265284092624ACE900F91BA1 /* AddOnCrossreferenceTests.swift in Sources */, 265D909D2446688C00D66F0F /* ProductCategoryViewModelBuilderTests.swift in Sources */, 03FBDAFD263EE4E800ACE257 /* CouponListViewModelTests.swift in Sources */, + 02DAE7FF291B8C8A009342B7 /* DomainSelectorViewModelTests.swift in Sources */, 036F6EA6281847D5006D84F8 /* PaymentCaptureOrchestratorTests.swift in Sources */, B555531321B57E8800449E71 /* MockUserNotificationsCenterAdapter.swift in Sources */, 682210ED2909666600814E14 /* CustomerSearchUICommandTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/Authentication/StoreCreationCoordinatorTests.swift b/WooCommerce/WooCommerceTests/Authentication/StoreCreationCoordinatorTests.swift index 3f5aef1c6ef..4c3569a2563 100644 --- a/WooCommerce/WooCommerceTests/Authentication/StoreCreationCoordinatorTests.swift +++ b/WooCommerce/WooCommerceTests/Authentication/StoreCreationCoordinatorTests.swift @@ -22,11 +22,14 @@ final class StoreCreationCoordinatorTests: XCTestCase { super.tearDown() } - // MARK: - Presentation in different states + // MARK: - Presentation in different states for store creation M1 - func test_AuthenticatedWebViewController_is_presented_when_navigationController_is_presenting_another_view() throws { + func test_AuthenticatedWebViewController_is_presented_when_navigationController_is_presenting_another_view_with_storeCreationM2_disabled() throws { // Given - let coordinator = StoreCreationCoordinator(source: .storePicker, navigationController: navigationController) + let featureFlagService = MockFeatureFlagService(isStoreCreationM2Enabled: false) + let coordinator = StoreCreationCoordinator(source: .storePicker, + navigationController: navigationController, + featureFlagService: featureFlagService) waitFor { promise in self.navigationController.present(.init(), animated: false) { promise(()) @@ -45,10 +48,13 @@ final class StoreCreationCoordinatorTests: XCTestCase { assertThat(storeCreationNavigationController.topViewController, isAnInstanceOf: AuthenticatedWebViewController.self) } - func test_AuthenticatedWebViewController_is_presented_when_navigationController_is_showing_another_view() throws { + func test_AuthenticatedWebViewController_is_presented_when_navigationController_is_showing_another_view_with_storeCreationM2_disabled() throws { // Given navigationController.show(.init(), sender: nil) - let coordinator = StoreCreationCoordinator(source: .loggedOut(source: .loginEmailError), navigationController: navigationController) + let featureFlagService = MockFeatureFlagService(isStoreCreationM2Enabled: false) + let coordinator = StoreCreationCoordinator(source: .loggedOut(source: .loginEmailError), + navigationController: navigationController, + featureFlagService: featureFlagService) XCTAssertNotNil(navigationController.topViewController) XCTAssertNil(navigationController.presentedViewController) @@ -62,4 +68,51 @@ final class StoreCreationCoordinatorTests: XCTestCase { let storeCreationNavigationController = try XCTUnwrap(navigationController.presentedViewController as? UINavigationController) assertThat(storeCreationNavigationController.topViewController, isAnInstanceOf: AuthenticatedWebViewController.self) } + + // MARK: - Presentation in different states for store creation M2 + + func test_DomainSelectorHostingController_is_presented_when_navigationController_is_presenting_another_view() throws { + // Given + let featureFlagService = MockFeatureFlagService(isStoreCreationM2Enabled: true) + let coordinator = StoreCreationCoordinator(source: .storePicker, + navigationController: navigationController, + featureFlagService: featureFlagService) + waitFor { promise in + self.navigationController.present(.init(), animated: false) { + promise(()) + } + } + XCTAssertNotNil(navigationController.presentedViewController) + + // When + coordinator.start() + + // Then + waitUntil { + self.navigationController.presentedViewController is UINavigationController + } + let storeCreationNavigationController = try XCTUnwrap(navigationController.presentedViewController as? UINavigationController) + assertThat(storeCreationNavigationController.topViewController, isAnInstanceOf: DomainSelectorHostingController.self) + } + + func test_AuthenticatedWebViewController_is_presented_when_navigationController_is_showing_another_view() throws { + // Given + let featureFlagService = MockFeatureFlagService(isStoreCreationM2Enabled: true) + navigationController.show(.init(), sender: nil) + let coordinator = StoreCreationCoordinator(source: .loggedOut(source: .loginEmailError), + navigationController: navigationController, + featureFlagService: featureFlagService) + XCTAssertNotNil(navigationController.topViewController) + XCTAssertNil(navigationController.presentedViewController) + + // When + coordinator.start() + + // Then + waitUntil { + self.navigationController.presentedViewController is UINavigationController + } + let storeCreationNavigationController = try XCTUnwrap(navigationController.presentedViewController as? UINavigationController) + assertThat(storeCreationNavigationController.topViewController, isAnInstanceOf: DomainSelectorHostingController.self) + } } diff --git a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift index f0cc8353611..68fcc7a57b1 100644 --- a/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift +++ b/WooCommerce/WooCommerceTests/Mocks/MockFeatureFlagService.swift @@ -8,6 +8,7 @@ struct MockFeatureFlagService: FeatureFlagService { private let shippingLabelsOnboardingM1: Bool private let isLoginPrologueOnboardingEnabled: Bool private let isStoreCreationMVPEnabled: Bool + private let isStoreCreationM2Enabled: Bool private let isProductsOnboardingEnabled: Bool init(isInboxOn: Bool = false, @@ -15,7 +16,8 @@ struct MockFeatureFlagService: FeatureFlagService { isUpdateOrderOptimisticallyOn: Bool = false, shippingLabelsOnboardingM1: Bool = false, isLoginPrologueOnboardingEnabled: Bool = false, - isStoreCreationMVPEnabled: Bool = false, + isStoreCreationMVPEnabled: Bool = true, + isStoreCreationM2Enabled: Bool = false, isProductsOnboardingEnabled: Bool = false) { self.isInboxOn = isInboxOn self.isSplitViewInOrdersTabOn = isSplitViewInOrdersTabOn @@ -23,6 +25,7 @@ struct MockFeatureFlagService: FeatureFlagService { self.shippingLabelsOnboardingM1 = shippingLabelsOnboardingM1 self.isLoginPrologueOnboardingEnabled = isLoginPrologueOnboardingEnabled self.isStoreCreationMVPEnabled = isStoreCreationMVPEnabled + self.isStoreCreationM2Enabled = isStoreCreationM2Enabled self.isProductsOnboardingEnabled = isProductsOnboardingEnabled } @@ -40,6 +43,8 @@ struct MockFeatureFlagService: FeatureFlagService { return isLoginPrologueOnboardingEnabled case .storeCreationMVP: return isStoreCreationMVPEnabled + case .storeCreationM2: + return isStoreCreationM2Enabled case .productsOnboarding: return isProductsOnboardingEnabled default: diff --git a/WooCommerce/WooCommerceTests/ViewModels/Domains/DomainSelectorViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewModels/Domains/DomainSelectorViewModelTests.swift new file mode 100644 index 00000000000..0f8830784a7 --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewModels/Domains/DomainSelectorViewModelTests.swift @@ -0,0 +1,83 @@ +import XCTest +import Yosemite +@testable import WooCommerce + +final class DomainSelectorViewModelTests: XCTestCase { + private var stores: MockStoresManager! + private var viewModel: DomainSelectorViewModel! + + override func setUp() { + super.setUp() + stores = MockStoresManager(sessionManager: SessionManager.makeForTesting()) + viewModel = .init(stores: stores, debounceDuration: 0) + } + + override func tearDown() { + viewModel = nil + stores = nil + super.tearDown() + } + + func test_DomainAction_is_not_dispatched_when_searchTerm_is_empty() { + // Given + stores.whenReceivingAction(ofType: DomainAction.self) { action in + XCTFail("Unexpected action: \(action)") + } + + // When + viewModel.searchTerm = "" + viewModel.searchTerm = "" + + // Then + XCTAssertEqual(viewModel.domains, []) + XCTAssertTrue(stores.receivedActions.isEmpty) + } + + func test_domain_suggestions_success_returns_domain_rows_for_free_domains() { + // Given + mockDomainSuggestionsSuccess(suggestions: [ + .init(name: "free.com", isFree: true), + .init(name: "paid.com", isFree: false) + ]) + + // When + viewModel.searchTerm = "woo" + + // Then + waitUntil { + self.viewModel.domains.isNotEmpty + } + XCTAssertEqual(viewModel.domains, ["free.com"]) + } + + func test_domain_suggestions_failure_does_not_update_domain_rows() { + // Given + mockDomainSuggestionsFailure(error: SampleError.first) + + // When + viewModel.searchTerm = "woo" + + // Then + XCTAssertEqual(viewModel.domains, []) + } +} + +private extension DomainSelectorViewModelTests { + func mockDomainSuggestionsSuccess(suggestions: [FreeDomainSuggestion]) { + stores.whenReceivingAction(ofType: DomainAction.self) { action in + switch action { + case let .loadFreeDomainSuggestions(_, completion): + completion(.success(suggestions)) + } + } + } + + func mockDomainSuggestionsFailure(error: Error) { + stores.whenReceivingAction(ofType: DomainAction.self) { action in + switch action { + case let .loadFreeDomainSuggestions(_, completion): + completion(.failure(error)) + } + } + } +}