Skip to content

Commit 11235c7

Browse files
authored
Merge pull request #8092 from woocommerce/feat/8045-domain-selector-updates
Store creation M2 - domain selection: empty/error/loading states, design updates
2 parents 5b59808 + 42d811f commit 11235c7

File tree

12 files changed

+333
-45
lines changed

12 files changed

+333
-45
lines changed

WooCommerce/Classes/Authentication/Store Creation/StoreCreationCoordinator.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ private extension StoreCreationCoordinator {
7474
// TODO-8045: skip to the next step of store creation with an auto-generated domain.
7575
})
7676
let storeCreationNavigationController = UINavigationController(rootViewController: domainSelector)
77+
storeCreationNavigationController.navigationBar.prefersLargeTitles = true
7778
presentStoreCreation(viewController: storeCreationNavigationController)
7879
}
7980

WooCommerce/Classes/Extensions/UIImage+Woo.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,12 @@ extension UIImage {
226226
return UIImage.gridicon(.cross, size: CGSize(width: 22, height: 22))
227227
}
228228

229+
/// Domain search placeholder image.
230+
///
231+
static var domainSearchPlaceholderImage: UIImage {
232+
return UIImage(named: "domain-search-placeholder")!
233+
}
234+
229235
/// Ellipsis Icon
230236
///
231237
static var ellipsisImage: UIImage {

WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainRowView.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ struct DomainRowView: View {
4545
.foregroundColor(Color(.brand))
4646
}
4747
}
48+
.padding(Layout.insets)
49+
}
50+
}
51+
52+
private extension DomainRowView {
53+
enum Layout {
54+
static let insets: EdgeInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 16)
4855
}
4956
}
5057

WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorView.swift

Lines changed: 139 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ private extension DomainSelectorHostingController {
4242

4343
/// Shows a transparent navigation bar without a bottom border.
4444
func configureNavigationBarAppearance() {
45+
navigationItem.title = Localization.title
46+
4547
let appearance = UINavigationBarAppearance()
4648
appearance.configureWithTransparentBackground()
47-
appearance.backgroundColor = UIColor.clear
49+
appearance.backgroundColor = .systemBackground
4850

4951
navigationItem.standardAppearance = appearance
5052
navigationItem.scrollEdgeAppearance = appearance
@@ -60,6 +62,7 @@ private extension DomainSelectorHostingController {
6062

6163
private extension DomainSelectorHostingController {
6264
enum Localization {
65+
static let title = NSLocalizedString("Choose a domain", comment: "Title of the domain selector.")
6366
static let skipButtonTitle = NSLocalizedString("Skip", comment: "Navigation bar button on the domain selector screen to skip domain selection.")
6467
}
6568
}
@@ -70,49 +73,97 @@ struct DomainSelectorView: View {
7073
var onDomainSelection: ((String) -> Void) = { _ in }
7174

7275
/// View model to drive the view.
73-
@ObservedObject var viewModel: DomainSelectorViewModel
76+
@ObservedObject private var viewModel: DomainSelectorViewModel
7477

7578
/// Currently selected domain name.
7679
/// If this property is kept in the view model, a SwiftUI error appears `Publishing changes from within view updates`
7780
/// when a domain row is selected.
78-
@State var selectedDomainName: String?
81+
@State private var selectedDomainName: String?
82+
83+
init(viewModel: DomainSelectorViewModel) {
84+
self.viewModel = viewModel
85+
}
7986

8087
var body: some View {
81-
ScrollableVStack(alignment: .leading) {
82-
// Header labels.
83-
VStack(alignment: .leading, spacing: Layout.spacingBetweenTitleAndSubtitle) {
84-
Text(Localization.title)
85-
.titleStyle()
86-
Text(Localization.subtitle)
87-
.foregroundColor(Color(.secondaryLabel))
88-
.bodyStyle()
89-
}
90-
.padding(.horizontal, Layout.defaultHorizontalPadding)
91-
92-
SearchHeader(filterText: $viewModel.searchTerm,
93-
filterPlaceholder: Localization.searchPlaceholder)
94-
.padding(.horizontal, Layout.defaultHorizontalPadding)
95-
96-
Text(Localization.suggestionsHeader)
97-
.foregroundColor(Color(.secondaryLabel))
98-
.bodyStyle()
99-
.padding(.horizontal, Layout.defaultHorizontalPadding)
100-
101-
List(viewModel.domains, id: \.self) { domain in
102-
Button {
103-
selectedDomainName = domain
104-
} label: {
105-
DomainRowView(viewModel: .init(domainName: domain,
106-
searchQuery: viewModel.searchTerm,
107-
isSelected: domain == selectedDomainName))
88+
VStack(alignment: .leading, spacing: 0) {
89+
ScrollView {
90+
VStack(alignment: .leading) {
91+
// Header label.
92+
Text(Localization.subtitle)
93+
.foregroundColor(Color(.secondaryLabel))
94+
.bodyStyle()
95+
.padding(.horizontal, Layout.defaultHorizontalPadding)
96+
97+
// Search text field.
98+
SearchHeader(text: $viewModel.searchTerm,
99+
placeholder: Localization.searchPlaceholder,
100+
customizations: .init(backgroundColor: .clear,
101+
borderColor: .separator,
102+
internalHorizontalPadding: 21,
103+
internalVerticalPadding: 12))
104+
105+
// Results header.
106+
Text(Localization.suggestionsHeader)
107+
.foregroundColor(Color(.secondaryLabel))
108+
.bodyStyle()
109+
.padding(.horizontal, Layout.defaultHorizontalPadding)
110+
111+
if viewModel.searchTerm.isEmpty {
112+
// Placeholder image when search query is empty.
113+
HStack {
114+
Spacer()
115+
Image(uiImage: .domainSearchPlaceholderImage)
116+
Spacer()
117+
}
118+
} else if viewModel.isLoadingDomainSuggestions {
119+
// Progress indicator when loading domain suggestions.
120+
HStack {
121+
Spacer()
122+
ProgressView()
123+
Spacer()
124+
}
125+
} else if let errorMessage = viewModel.errorMessage {
126+
// Error message when there is an error loading domain suggestions.
127+
Text(errorMessage)
128+
.padding(Layout.defaultPadding)
129+
} else {
130+
// Domain suggestions.
131+
LazyVStack {
132+
ForEach(viewModel.domains, id: \.self) { domain in
133+
Button {
134+
selectedDomainName = domain
135+
} label: {
136+
VStack(alignment: .leading) {
137+
DomainRowView(viewModel: .init(domainName: domain,
138+
searchQuery: viewModel.searchTerm,
139+
isSelected: domain == selectedDomainName))
140+
Divider()
141+
.frame(height: Layout.dividerHeight)
142+
.padding(.leading, Layout.defaultHorizontalPadding)
143+
}
144+
}
145+
}
146+
}
147+
}
108148
}
109-
}.listStyle(.inset)
149+
}
110150

151+
// Continue button when a domain is selected.
111152
if let selectedDomainName {
153+
Divider()
154+
.frame(height: Layout.dividerHeight)
155+
.foregroundColor(Color(.separator))
112156
Button(Localization.continueButtonTitle) {
113157
onDomainSelection(selectedDomainName)
114158
}
115159
.buttonStyle(PrimaryButtonStyle())
160+
.padding(Layout.defaultPadding)
161+
}
162+
}
163+
.onChange(of: viewModel.isLoadingDomainSuggestions) { isLoadingDomainSuggestions in
164+
// Resets selected domain when loading domain suggestions.
165+
if isLoadingDomainSuggestions {
166+
selectedDomainName = nil
116167
}
117168
}
118169
}
@@ -122,10 +173,11 @@ private extension DomainSelectorView {
122173
enum Layout {
123174
static let spacingBetweenTitleAndSubtitle: CGFloat = 16
124175
static let defaultHorizontalPadding: CGFloat = 16
176+
static let dividerHeight: CGFloat = 1
177+
static let defaultPadding: EdgeInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 16)
125178
}
126179

127180
enum Localization {
128-
static let title = NSLocalizedString("Choose a domain", comment: "Title of the domain selector.")
129181
static let subtitle = NSLocalizedString(
130182
"This is where people will find you on the Internet. Don't worry, you can change it later.",
131183
comment: "Subtitle of the domain selector.")
@@ -135,8 +187,62 @@ private extension DomainSelectorView {
135187
}
136188
}
137189

190+
#if DEBUG
191+
192+
import Yosemite
193+
import enum Networking.DotcomError
194+
195+
/// StoresManager that specifically handles `DomainAction` for `DomainSelectorView` previews.
196+
final class DomainSelectorViewStores: DefaultStoresManager {
197+
private let result: Result<[FreeDomainSuggestion], Error>?
198+
199+
init(result: Result<[FreeDomainSuggestion], Error>?) {
200+
self.result = result
201+
super.init(sessionManager: ServiceLocator.stores.sessionManager)
202+
}
203+
204+
override func dispatch(_ action: Action) {
205+
if let action = action as? DomainAction {
206+
if case let .loadFreeDomainSuggestions(_, completion) = action {
207+
if let result {
208+
completion(result)
209+
}
210+
}
211+
}
212+
}
213+
}
214+
138215
struct DomainSelectorView_Previews: PreviewProvider {
139216
static var previews: some View {
140-
DomainSelectorView(viewModel: .init())
217+
Group {
218+
// Empty query state.
219+
DomainSelectorView(viewModel:
220+
.init(initialSearchTerm: "",
221+
stores: DomainSelectorViewStores(result: nil)))
222+
// Results state.
223+
DomainSelectorView(viewModel:
224+
.init(initialSearchTerm: "Fruit smoothie",
225+
stores: DomainSelectorViewStores(result: .success([
226+
.init(name: "grapefruitsmoothie.com", isFree: true),
227+
.init(name: "fruitsmoothie.com", isFree: true),
228+
.init(name: "grapefruitsmoothiee.com", isFree: true),
229+
.init(name: "freesmoothieeee.com", isFree: true),
230+
.init(name: "greatfruitsmoothie1.com", isFree: true),
231+
.init(name: "tropicalsmoothie.com", isFree: true)
232+
]))))
233+
// Error state.
234+
DomainSelectorView(viewModel:
235+
.init(initialSearchTerm: "test",
236+
stores: DomainSelectorViewStores(result: .failure(
237+
DotcomError.unknown(code: "invalid_query",
238+
message: "Domain searches must contain a word with the following characters.")
239+
))))
240+
// Loading state.
241+
DomainSelectorView(viewModel:
242+
.init(initialSearchTerm: "test",
243+
stores: DomainSelectorViewStores(result: nil)))
244+
}
141245
}
142246
}
247+
248+
#endif

WooCommerce/Classes/ViewRelated/Dashboard/Settings/Domains/DomainSelectorViewModel.swift

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Combine
22
import SwiftUI
33
import Yosemite
4+
import enum Networking.DotcomError
45

56
/// View model for `DomainSelectorView`.
67
final class DomainSelectorViewModel: ObservableObject {
@@ -11,30 +12,48 @@ final class DomainSelectorViewModel: ObservableObject {
1112
/// Domain names after domain suggestions are loaded remotely.
1213
@Published private(set) var domains: [String] = []
1314

15+
/// Error message from loading domain suggestions.
16+
@Published private(set) var errorMessage: String?
17+
18+
/// Whether domain suggestions are being loaded.
19+
@Published private(set) var isLoadingDomainSuggestions: Bool = false
20+
1421
/// Subscription for search query changes for domain search.
1522
private var searchQuerySubscription: AnyCancellable?
1623

1724
private let stores: StoresManager
1825
private let debounceDuration: Double
1926

20-
init(stores: StoresManager = ServiceLocator.stores,
27+
init(initialSearchTerm: String = "",
28+
stores: StoresManager = ServiceLocator.stores,
2129
debounceDuration: Double = Constants.fieldDebounceDuration) {
2230
self.stores = stores
2331
self.debounceDuration = debounceDuration
32+
33+
// Sets the initial search term after related subscriptions are set up
34+
// so that the initial value is always emitted.
35+
// In `observeDomainQuery`, `share()` transforms the publisher to `PassthroughSubject`
36+
// and thus the initial value isn't emitted in `observeDomainQuery` until setting the value afterward.
2437
observeDomainQuery()
38+
self.searchTerm = initialSearchTerm
2539
}
2640
}
2741

2842
private extension DomainSelectorViewModel {
2943
func observeDomainQuery() {
3044
searchQuerySubscription = $searchTerm
45+
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
46+
.debounce(for: .seconds(debounceDuration), scheduler: DispatchQueue.main)
3147
.filter { $0.isNotEmpty }
3248
.removeDuplicates()
33-
.debounce(for: .seconds(debounceDuration), scheduler: DispatchQueue.main)
3449
.sink { [weak self] searchTerm in
3550
guard let self = self else { return }
3651
Task { @MainActor in
52+
self.errorMessage = nil
53+
self.isLoadingDomainSuggestions = true
3754
let result = await self.loadFreeDomainSuggestions(query: searchTerm)
55+
self.isLoadingDomainSuggestions = false
56+
3857
switch result {
3958
case .success(let suggestions):
4059
self.handleFreeDomainSuggestions(suggestions, query: searchTerm)
@@ -66,8 +85,13 @@ private extension DomainSelectorViewModel {
6685

6786
@MainActor
6887
func handleError(_ error: Error) {
69-
// TODO-8045: error handling - maybe show an error message.
70-
DDLogError("Cannot load domain suggestions for \(searchTerm)")
88+
if let dotcomError = error as? DotcomError,
89+
case let .unknown(_, message) = dotcomError {
90+
errorMessage = message
91+
} else {
92+
errorMessage = Localization.defaultErrorMessage
93+
}
94+
DDLogError("Cannot load domain suggestions for \(searchTerm): \(error)")
7195
}
7296
}
7397

@@ -76,3 +100,11 @@ private extension DomainSelectorViewModel {
76100
static let fieldDebounceDuration = 0.3
77101
}
78102
}
103+
104+
extension DomainSelectorViewModel {
105+
enum Localization {
106+
static let defaultErrorMessage =
107+
NSLocalizedString("Please try another query.",
108+
comment: "Default message when there is an unexpected error loading domain suggestions on the domain selector.")
109+
}
110+
}

WooCommerce/Classes/ViewRelated/Orders/Order Details/Address Edit/FilterListSelector.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ struct FilterListSelector<ViewModel: FilterListSelectorViewModelable>: View {
4848

4949
var body: some View {
5050
VStack(spacing: 0) {
51-
SearchHeader(filterText: $searchTerm, filterPlaceholder: viewModel.filterPlaceholder)
51+
SearchHeader(text: $searchTerm, placeholder: viewModel.filterPlaceholder)
5252
.background(Color(.listForeground))
5353
.onChange(of: searchTerm) { newValue in
5454
viewModel.searchTerm = newValue

WooCommerce/Classes/ViewRelated/Products/ProductSelector/ProductSelector.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ struct ProductSelector: View {
4242
var body: some View {
4343
NavigationView {
4444
VStack(spacing: 0) {
45-
SearchHeader(filterText: $viewModel.searchTerm, filterPlaceholder: Localization.searchPlaceholder)
45+
SearchHeader(text: $viewModel.searchTerm, placeholder: Localization.searchPlaceholder)
4646
.padding(.horizontal, insets: safeAreaInsets)
4747
.accessibilityIdentifier("product-selector-search-bar")
4848
HStack {

0 commit comments

Comments
 (0)