diff --git a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift index 754f058228d..ac7a79f0cf0 100644 --- a/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift +++ b/WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift @@ -74,6 +74,7 @@ private extension StoreCreationCoordinator { // TODO-8045: skip to the next step of store creation with an auto-generated domain. }) let storeCreationNavigationController = UINavigationController(rootViewController: domainSelector) + storeCreationNavigationController.navigationBar.prefersLargeTitles = true presentStoreCreation(viewController: storeCreationNavigationController) } diff --git a/WooCommerce/Classes/Extensions/UIImage+Woo.swift b/WooCommerce/Classes/Extensions/UIImage+Woo.swift index cc437bce170..57bc6fcf2e4 100644 --- a/WooCommerce/Classes/Extensions/UIImage+Woo.swift +++ b/WooCommerce/Classes/Extensions/UIImage+Woo.swift @@ -226,6 +226,12 @@ extension UIImage { return UIImage.gridicon(.cross, size: CGSize(width: 22, height: 22)) } + /// Domain search placeholder image. + /// + static var domainSearchPlaceholderImage: UIImage { + return UIImage(named: "domain-search-placeholder")! + } + /// Ellipsis Icon /// static var ellipsisImage: UIImage { diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainRowView.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainRowView.swift index 022508baf90..5774564ef9b 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainRowView.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainRowView.swift @@ -45,6 +45,13 @@ struct DomainRowView: View { .foregroundColor(Color(.brand)) } } + .padding(Layout.insets) + } +} + +private extension DomainRowView { + enum Layout { + static let insets: EdgeInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 16) } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift index 3746d085418..92d13326b61 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift @@ -42,9 +42,11 @@ private extension DomainSelectorHostingController { /// Shows a transparent navigation bar without a bottom border. func configureNavigationBarAppearance() { + navigationItem.title = Localization.title + let appearance = UINavigationBarAppearance() appearance.configureWithTransparentBackground() - appearance.backgroundColor = UIColor.clear + appearance.backgroundColor = .systemBackground navigationItem.standardAppearance = appearance navigationItem.scrollEdgeAppearance = appearance @@ -60,6 +62,7 @@ private extension DomainSelectorHostingController { private extension DomainSelectorHostingController { enum Localization { + static let title = NSLocalizedString("Choose a domain", comment: "Title of the domain selector.") static let skipButtonTitle = NSLocalizedString("Skip", comment: "Navigation bar button on the domain selector screen to skip domain selection.") } } @@ -70,49 +73,97 @@ struct DomainSelectorView: View { var onDomainSelection: ((String) -> Void) = { _ in } /// View model to drive the view. - @ObservedObject var viewModel: DomainSelectorViewModel + @ObservedObject private 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? + @State private var selectedDomainName: String? + + init(viewModel: DomainSelectorViewModel) { + self.viewModel = viewModel + } 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)) + VStack(alignment: .leading, spacing: 0) { + ScrollView { + VStack(alignment: .leading) { + // Header label. + Text(Localization.subtitle) + .foregroundColor(Color(.secondaryLabel)) + .bodyStyle() + .padding(.horizontal, Layout.defaultHorizontalPadding) + + // Search text field. + SearchHeader(text: $viewModel.searchTerm, + placeholder: Localization.searchPlaceholder, + customizations: .init(backgroundColor: .clear, + borderColor: .separator, + internalHorizontalPadding: 21, + internalVerticalPadding: 12)) + + // Results header. + Text(Localization.suggestionsHeader) + .foregroundColor(Color(.secondaryLabel)) + .bodyStyle() + .padding(.horizontal, Layout.defaultHorizontalPadding) + + if viewModel.searchTerm.isEmpty { + // Placeholder image when search query is empty. + HStack { + Spacer() + Image(uiImage: .domainSearchPlaceholderImage) + Spacer() + } + } else if viewModel.isLoadingDomainSuggestions { + // Progress indicator when loading domain suggestions. + HStack { + Spacer() + ProgressView() + Spacer() + } + } else if let errorMessage = viewModel.errorMessage { + // Error message when there is an error loading domain suggestions. + Text(errorMessage) + .padding(Layout.defaultPadding) + } else { + // Domain suggestions. + LazyVStack { + ForEach(viewModel.domains, id: \.self) { domain in + Button { + selectedDomainName = domain + } label: { + VStack(alignment: .leading) { + DomainRowView(viewModel: .init(domainName: domain, + searchQuery: viewModel.searchTerm, + isSelected: domain == selectedDomainName)) + Divider() + .frame(height: Layout.dividerHeight) + .padding(.leading, Layout.defaultHorizontalPadding) + } + } + } + } + } } - }.listStyle(.inset) + } + // Continue button when a domain is selected. if let selectedDomainName { + Divider() + .frame(height: Layout.dividerHeight) + .foregroundColor(Color(.separator)) Button(Localization.continueButtonTitle) { onDomainSelection(selectedDomainName) } .buttonStyle(PrimaryButtonStyle()) + .padding(Layout.defaultPadding) + } + } + .onChange(of: viewModel.isLoadingDomainSuggestions) { isLoadingDomainSuggestions in + // Resets selected domain when loading domain suggestions. + if isLoadingDomainSuggestions { + selectedDomainName = nil } } } @@ -122,10 +173,11 @@ private extension DomainSelectorView { enum Layout { static let spacingBetweenTitleAndSubtitle: CGFloat = 16 static let defaultHorizontalPadding: CGFloat = 16 + static let dividerHeight: CGFloat = 1 + static let defaultPadding: EdgeInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 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.") @@ -135,8 +187,62 @@ private extension DomainSelectorView { } } +#if DEBUG + +import Yosemite +import enum Networking.DotcomError + +/// StoresManager that specifically handles `DomainAction` for `DomainSelectorView` previews. +final class DomainSelectorViewStores: DefaultStoresManager { + private let result: Result<[FreeDomainSuggestion], Error>? + + init(result: Result<[FreeDomainSuggestion], Error>?) { + self.result = result + super.init(sessionManager: ServiceLocator.stores.sessionManager) + } + + override func dispatch(_ action: Action) { + if let action = action as? DomainAction { + if case let .loadFreeDomainSuggestions(_, completion) = action { + if let result { + completion(result) + } + } + } + } +} + struct DomainSelectorView_Previews: PreviewProvider { static var previews: some View { - DomainSelectorView(viewModel: .init()) + Group { + // Empty query state. + DomainSelectorView(viewModel: + .init(initialSearchTerm: "", + stores: DomainSelectorViewStores(result: nil))) + // Results state. + DomainSelectorView(viewModel: + .init(initialSearchTerm: "Fruit smoothie", + stores: DomainSelectorViewStores(result: .success([ + .init(name: "grapefruitsmoothie.com", isFree: true), + .init(name: "fruitsmoothie.com", isFree: true), + .init(name: "grapefruitsmoothiee.com", isFree: true), + .init(name: "freesmoothieeee.com", isFree: true), + .init(name: "greatfruitsmoothie1.com", isFree: true), + .init(name: "tropicalsmoothie.com", isFree: true) + ])))) + // Error state. + DomainSelectorView(viewModel: + .init(initialSearchTerm: "test", + stores: DomainSelectorViewStores(result: .failure( + DotcomError.unknown(code: "invalid_query", + message: "Domain searches must contain a word with the following characters.") + )))) + // Loading state. + DomainSelectorView(viewModel: + .init(initialSearchTerm: "test", + stores: DomainSelectorViewStores(result: nil))) + } } } + +#endif diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift index bb263c61d30..5ebc0a47004 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift @@ -1,6 +1,7 @@ import Combine import SwiftUI import Yosemite +import enum Networking.DotcomError /// View model for `DomainSelectorView`. final class DomainSelectorViewModel: ObservableObject { @@ -11,30 +12,48 @@ final class DomainSelectorViewModel: ObservableObject { /// Domain names after domain suggestions are loaded remotely. @Published private(set) var domains: [String] = [] + /// Error message from loading domain suggestions. + @Published private(set) var errorMessage: String? + + /// Whether domain suggestions are being loaded. + @Published private(set) var isLoadingDomainSuggestions: Bool = false + /// 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, + init(initialSearchTerm: String = "", + stores: StoresManager = ServiceLocator.stores, debounceDuration: Double = Constants.fieldDebounceDuration) { self.stores = stores self.debounceDuration = debounceDuration + + // Sets the initial search term after related subscriptions are set up + // so that the initial value is always emitted. + // In `observeDomainQuery`, `share()` transforms the publisher to `PassthroughSubject` + // and thus the initial value isn't emitted in `observeDomainQuery` until setting the value afterward. observeDomainQuery() + self.searchTerm = initialSearchTerm } } private extension DomainSelectorViewModel { func observeDomainQuery() { searchQuerySubscription = $searchTerm + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .debounce(for: .seconds(debounceDuration), scheduler: DispatchQueue.main) .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 + self.errorMessage = nil + self.isLoadingDomainSuggestions = true let result = await self.loadFreeDomainSuggestions(query: searchTerm) + self.isLoadingDomainSuggestions = false + switch result { case .success(let suggestions): self.handleFreeDomainSuggestions(suggestions, query: searchTerm) @@ -66,8 +85,13 @@ private extension DomainSelectorViewModel { @MainActor func handleError(_ error: Error) { - // TODO-8045: error handling - maybe show an error message. - DDLogError("Cannot load domain suggestions for \(searchTerm)") + if let dotcomError = error as? DotcomError, + case let .unknown(_, message) = dotcomError { + errorMessage = message + } else { + errorMessage = Localization.defaultErrorMessage + } + DDLogError("Cannot load domain suggestions for \(searchTerm): \(error)") } } @@ -76,3 +100,11 @@ private extension DomainSelectorViewModel { static let fieldDebounceDuration = 0.3 } } + +extension DomainSelectorViewModel { + enum Localization { + static let defaultErrorMessage = + NSLocalizedString("Please try another query.", + comment: "Default message when there is an unexpected error loading domain suggestions on the domain selector.") + } +} diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/FilterListSelector.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/FilterListSelector.swift index 61c1ece47d5..46a8ece5462 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/FilterListSelector.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/FilterListSelector.swift @@ -48,7 +48,7 @@ struct FilterListSelector: View { var body: some View { VStack(spacing: 0) { - SearchHeader(filterText: $searchTerm, filterPlaceholder: viewModel.filterPlaceholder) + SearchHeader(text: $searchTerm, placeholder: viewModel.filterPlaceholder) .background(Color(.listForeground)) .onChange(of: searchTerm) { newValue in viewModel.searchTerm = newValue diff --git a/WooCommerce/Classes/ViewRelated/Products/ProductSelector/ProductSelector.swift b/WooCommerce/Classes/ViewRelated/Products/ProductSelector/ProductSelector.swift index c963b6d075d..639b9d0e306 100644 --- a/WooCommerce/Classes/ViewRelated/Products/ProductSelector/ProductSelector.swift +++ b/WooCommerce/Classes/ViewRelated/Products/ProductSelector/ProductSelector.swift @@ -42,7 +42,7 @@ struct ProductSelector: View { var body: some View { NavigationView { VStack(spacing: 0) { - SearchHeader(filterText: $viewModel.searchTerm, filterPlaceholder: Localization.searchPlaceholder) + SearchHeader(text: $viewModel.searchTerm, placeholder: Localization.searchPlaceholder) .padding(.horizontal, insets: safeAreaInsets) .accessibilityIdentifier("product-selector-search-bar") HStack { diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/SearchHeader.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/SearchHeader.swift index 2f147b73819..1567fba39f7 100644 --- a/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/SearchHeader.swift +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/SwiftUI Components/SearchHeader.swift @@ -3,17 +3,43 @@ import SwiftUI /// Search Header View /// struct SearchHeader: View { + /// Customizations for the search header component. + struct Customizations { + let backgroundColor: UIColor + let borderColor: UIColor + let internalHorizontalPadding: CGFloat + let internalVerticalPadding: CGFloat + } // Tracks the scale of the view due to accessibility changes @ScaledMetric private var scale: CGFloat = 1 /// Filter search term /// - @Binding var filterText: String + @Binding private var text: String /// Placeholder for the filter text field /// - let filterPlaceholder: String + private let placeholder: String + + private let customizations: Customizations + + /// - Parameters: + /// - text: Search term binding. + /// - placeholder: Placeholder for the text field. + /// - customizations: Customizations of the view styles. + init(text: Binding, + placeholder: String, + customizations: Customizations = .init( + backgroundColor: .searchBarBackground, + borderColor: .clear, + internalHorizontalPadding: Layout.internalPadding, + internalVerticalPadding: Layout.internalPadding + )) { + self._text = text + self.placeholder = placeholder + self.customizations = customizations + } var body: some View { HStack(spacing: 0) { @@ -23,16 +49,20 @@ struct SearchHeader: View { .resizable() .frame(width: Layout.iconSize.width * scale, height: Layout.iconSize.height * scale) .foregroundColor(Color(.listSmallIcon)) - .padding([.leading, .trailing], Layout.internalPadding) + .padding([.leading, .trailing], customizations.internalHorizontalPadding) .accessibilityHidden(true) // TextField - TextField(filterPlaceholder, text: $filterText) - .padding([.bottom, .top], Layout.internalPadding) + TextField(placeholder, text: $text) + .padding([.bottom, .top], customizations.internalVerticalPadding) .accessibility(addTraits: .isSearchField) } - .background(Color(.searchBarBackground)) + .background(Color(customizations.backgroundColor)) .cornerRadius(Layout.cornerRadius) + .overlay( + RoundedRectangle(cornerRadius: Layout.cornerRadius) + .stroke(Color(customizations.borderColor), style: StrokeStyle(lineWidth: 1)) + ) .padding(Layout.externalPadding) } } @@ -47,3 +77,19 @@ private extension SearchHeader { static let externalPadding = EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16) } } + +struct SearchHeaderView_Previews: PreviewProvider { + static var previews: some View { + VStack { + // Default styles. + SearchHeader(text: .constant("pineapple"), placeholder: "Search fruits") + // Domain selector styles. + SearchHeader(text: .constant("papaya"), + placeholder: "Search fruits", + customizations: .init(backgroundColor: .clear, + borderColor: .separator, + internalHorizontalPadding: 21, + internalVerticalPadding: 12)) + } + } +} diff --git a/WooCommerce/Resources/Images.xcassets/domain-search-placeholder.imageset/Contents.json b/WooCommerce/Resources/Images.xcassets/domain-search-placeholder.imageset/Contents.json new file mode 100644 index 00000000000..f7152ebcc14 --- /dev/null +++ b/WooCommerce/Resources/Images.xcassets/domain-search-placeholder.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "domain-search-placeholder.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/WooCommerce/Resources/Images.xcassets/domain-search-placeholder.imageset/domain-search-placeholder.pdf b/WooCommerce/Resources/Images.xcassets/domain-search-placeholder.imageset/domain-search-placeholder.pdf new file mode 100644 index 00000000000..4155db7777b Binary files /dev/null and b/WooCommerce/Resources/Images.xcassets/domain-search-placeholder.imageset/domain-search-placeholder.pdf differ diff --git a/WooCommerce/WooCommerceTests/Extensions/IconsTests.swift b/WooCommerce/WooCommerceTests/Extensions/IconsTests.swift index ee15caf0a78..a08faff433a 100644 --- a/WooCommerce/WooCommerceTests/Extensions/IconsTests.swift +++ b/WooCommerce/WooCommerceTests/Extensions/IconsTests.swift @@ -128,6 +128,10 @@ final class IconsTests: XCTestCase { XCTAssertEqual(deleteCellImage.size, CGSize(width: 22, height: 22)) } + func test_domainSearchPlaceholderImage_is_not_nil() { + XCTAssertNotNil(UIImage.domainSearchPlaceholderImage) + } + func testEllipsisImageIconIsNotNil() { XCTAssertNotNil(UIImage.ellipsisImage) } diff --git a/WooCommerce/WooCommerceTests/ViewModels/Domains/DomainSelectorViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewModels/Domains/DomainSelectorViewModelTests.swift index 0f8830784a7..55db00afbb4 100644 --- a/WooCommerce/WooCommerceTests/ViewModels/Domains/DomainSelectorViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewModels/Domains/DomainSelectorViewModelTests.swift @@ -1,10 +1,13 @@ +import Combine import XCTest import Yosemite +import enum Networking.DotcomError @testable import WooCommerce final class DomainSelectorViewModelTests: XCTestCase { private var stores: MockStoresManager! private var viewModel: DomainSelectorViewModel! + private var subscriptions: Set = [] override func setUp() { super.setUp() @@ -60,6 +63,74 @@ final class DomainSelectorViewModelTests: XCTestCase { // Then XCTAssertEqual(viewModel.domains, []) } + + // MARK: - `errorMessage` + + func test_domain_suggestions_failure_with_non_DotcomError_sets_default_error_message() { + // Given + mockDomainSuggestionsFailure(error: SampleError.first) + + // When + viewModel.searchTerm = "woo" + + // Then + waitUntil { + self.viewModel.errorMessage?.isNotEmpty == true + } + XCTAssertEqual(viewModel.errorMessage, DomainSelectorViewModel.Localization.defaultErrorMessage) + } + + func test_domain_suggestions_failure_with_DotcomError_unknown_error_sets_error_message() { + // Given + mockDomainSuggestionsFailure(error: DotcomError.unknown(code: "", message: "error message")) + + // When + viewModel.searchTerm = "woo" + + // Then + waitUntil { + self.viewModel.errorMessage?.isNotEmpty == true + } + XCTAssertEqual(viewModel.errorMessage, "error message") + } + + func test_domain_suggestions_error_message_is_reset_when_loading_domain_suggestions() { + // Given + mockDomainSuggestionsFailure(error: SampleError.first) + + // When + viewModel.searchTerm = "woo" + waitUntil { + self.viewModel.errorMessage?.isNotEmpty == true + } + + mockDomainSuggestionsSuccess(suggestions: []) + viewModel.searchTerm = "wooo" + + // Then + waitUntil { + self.viewModel.errorMessage == nil + } + } + + // MARK: `isLoadingDomainSuggestions` + + func test_isLoadingDomainSuggestions_is_toggled_when_loading_suggestions() { + var loadingValues: [Bool] = [] + viewModel.$isLoadingDomainSuggestions.sink { value in + loadingValues.append(value) + }.store(in: &subscriptions) + + mockDomainSuggestionsFailure(error: SampleError.first) + + // When + viewModel.searchTerm = "Woo" + + // Then + waitUntil { + loadingValues == [false, true, false] + } + } } private extension DomainSelectorViewModelTests {