Skip to content

Commit 8c4f0f1

Browse files
authored
Bookings: Update selector for service/event and customer name filters (#16278)
2 parents ef4a2df + b6ed021 commit 8c4f0f1

File tree

13 files changed

+239
-57
lines changed

13 files changed

+239
-57
lines changed
Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
1-
// periphery:ignore:all - will be used for booking filters
21
import Foundation
32

43
/// Used to filter bookings by product
54
///
65
public struct BookingProductFilter: Codable, Hashable {
76
/// ID of the product
7+
/// periphery:ignore - to be used later when applying filter
88
///
9-
public let id: Int64
9+
public let productID: Int64
1010

1111
/// Name of the product
1212
///
1313
public let name: String
1414

15-
public init(id: Int64,
16-
name: String) {
17-
self.id = id
15+
public init(productID: Int64, name: String) {
16+
self.productID = productID
1817
self.name = name
1918
}
2019
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import Foundation
2+
import Yosemite
3+
4+
/// Syncable implementation for booking services/events (bookable product) filtering
5+
struct BookableProductListSyncable: ListSyncable {
6+
typealias StorageType = StorageProduct
7+
typealias ModelType = Product
8+
9+
let siteID: Int64
10+
11+
var title: String { Localization.title }
12+
13+
var emptyStateMessage: String { Localization.noMembersFound }
14+
15+
// MARK: - ResultsController Configuration
16+
17+
func createPredicate() -> NSPredicate {
18+
NSPredicate(format: "siteID == %lld AND productTypeKey == %@", siteID, ProductType.booking.rawValue)
19+
}
20+
21+
func createSortDescriptors() -> [NSSortDescriptor] {
22+
[NSSortDescriptor(key: "productID", ascending: false)]
23+
}
24+
25+
// MARK: - Sync Configuration
26+
27+
func createSyncAction(
28+
pageNumber: Int,
29+
pageSize: Int,
30+
completion: @escaping (Result<Bool, Error>) -> Void
31+
) -> Action {
32+
ProductAction.synchronizeProducts(
33+
siteID: siteID,
34+
pageNumber: pageNumber,
35+
pageSize: pageSize,
36+
stockStatus: nil,
37+
productStatus: nil,
38+
productType: .booking,
39+
productCategory: nil,
40+
sortOrder: .dateDescending,
41+
productIDs: [],
42+
excludedProductIDs: [],
43+
shouldDeleteStoredProductsOnFirstPage: true,
44+
onCompletion: completion
45+
)
46+
}
47+
48+
// MARK: - Display Configuration
49+
50+
func displayName(for item: Product) -> String {
51+
item.name
52+
}
53+
}
54+
55+
private extension BookableProductListSyncable {
56+
enum Localization {
57+
static let title = NSLocalizedString(
58+
"bookingServiceEventSelectorView.title",
59+
value: "Service / Event",
60+
comment: "Title of the booking service/event selector view"
61+
)
62+
static let noMembersFound = NSLocalizedString(
63+
"bookingServiceEventSelectorView.noMembersFound",
64+
value: "No service or event found",
65+
comment: "Text on the empty view of the booking service/event selector view"
66+
)
67+
}
68+
}

WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ final class BookingFiltersViewModel: FilterListViewModel {
2727
filterTypeViewModels = [
2828
teamMemberFilterViewModel,
2929
productFilterViewModel,
30-
customerFilterViewModel,
3130
attendanceStatusFilterViewModel,
3231
paymentStatusFilterViewModel,
32+
customerFilterViewModel,
3333
dateTimeFilterViewModel
3434
]
3535
}
@@ -188,11 +188,11 @@ extension BookingFiltersViewModel.BookingListFilter {
188188
selectedValue: filters.teamMember)
189189
case .product(let siteID):
190190
return FilterTypeViewModel(title: title,
191-
listSelectorConfig: .products(siteID: siteID),
191+
listSelectorConfig: .bookableProduct(siteID: siteID),
192192
selectedValue: filters.product)
193193
case .customer(let siteID):
194194
return FilterTypeViewModel(title: title,
195-
listSelectorConfig: .customer(siteID: siteID),
195+
listSelectorConfig: .customer(siteID: siteID, source: .booking),
196196
selectedValue: filters.customer)
197197
case .attendanceStatus:
198198
let options: [BookingAttendanceStatus?] = [nil, .booked, .checkedIn, .cancelled, .noShow]
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Foundation
2+
3+
extension CustomerSelectorViewController.Configuration {
4+
static let configurationForBookingFilter = CustomerSelectorViewController.Configuration(
5+
title: BookingFilterLocalization.customerSelectorTitle,
6+
disallowSelectingGuest: true,
7+
guestDisallowedMessage: BookingFilterLocalization.guestSelectionDisallowedError,
8+
disallowCreatingCustomer: true,
9+
showGuestLabel: true,
10+
shouldTrackCustomerAdded: false,
11+
isModal: false,
12+
hideDetailText: true
13+
)
14+
15+
enum BookingFilterLocalization {
16+
static let customerSelectorTitle = NSLocalizedString(
17+
"configurationForBookingFilter.customerName",
18+
value: "Customer name",
19+
comment: "Title for the screen to select customer in booking filtering."
20+
)
21+
static let guestSelectionDisallowedError = NSLocalizedString(
22+
"configurationForBookingFilter.guestSelectionDisallowedError",
23+
value: "This user is a guest, and guests can’t be used for filtering bookings.",
24+
comment: "Error message when selecting guest customer in booking filtering"
25+
)
26+
}
27+
}

WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,18 @@ struct SyncableListSelectorView<Syncable: ListSyncable>: View {
55
@State var selectedItem: Syncable.ModelType?
66

77
private let syncable: Syncable
8+
private let initialSelection: (Syncable.ModelType?) -> Bool
89
private let onSelection: (Syncable.ModelType?) -> Void
910

1011
private let viewPadding: CGFloat = 16
1112

1213
init(viewModel: SyncableListSelectorViewModel<Syncable>,
1314
syncable: Syncable,
14-
selectedItem: Syncable.ModelType?,
15+
initialSelection: @escaping (Syncable.ModelType?) -> Bool,
1516
onSelection: @escaping (Syncable.ModelType?) -> Void) {
1617
self.viewModel = viewModel
1718
self.syncable = syncable
18-
self.selectedItem = selectedItem
19+
self.initialSelection = initialSelection
1920
self.onSelection = onSelection
2021
}
2122

@@ -68,7 +69,7 @@ private extension SyncableListSelectorView {
6869

6970
ForEach(items, id: \.self) { item in
7071
optionRow(text: syncable.displayName(for: item),
71-
isSelected: item == selectedItem,
72+
isSelected: isItemSelected(item),
7273
onSelection: { selectedItem = item })
7374
}
7475

@@ -82,6 +83,13 @@ private extension SyncableListSelectorView {
8283
.background(Color(.listBackground))
8384
}
8485

86+
func isItemSelected(_ item: Syncable.ModelType?) -> Bool {
87+
if let selectedItem {
88+
return item == selectedItem
89+
}
90+
return initialSelection(item)
91+
}
92+
8593
func optionRow(text: String, isSelected: Bool, onSelection: @escaping () -> Void) -> some View {
8694
HStack {
8795
Text(text)

WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,11 @@ enum FilterListValueSelectorConfig {
9393
// Filter list selector for products
9494
case products(siteID: Int64)
9595
// Filter list selector for customer
96-
case customer(siteID: Int64)
96+
case customer(siteID: Int64, source: FilterSource)
9797
// Filter list selector for booking team member
9898
case bookingResource(siteID: Int64)
99+
// Filter list selector for bookable product
100+
case bookableProduct(siteID: Int64)
99101

100102
}
101103

@@ -341,39 +343,63 @@ private extension FilterListViewController {
341343
}()
342344
self.listSelector.present(controller, animated: true)
343345

344-
case .customer(let siteID):
346+
case .customer(let siteID, let source):
347+
let configuration: CustomerSelectorViewController.Configuration = {
348+
switch source {
349+
case .booking: .configurationForBookingFilter
350+
case .orders: .configurationForOrderFilter
351+
case .products: fatalError("Customer filter not supported!")
352+
}
353+
}()
354+
let selectedCustomerID = (selected.selectedValue as? CustomerFilter)?.id
345355
let controller: CustomerSelectorViewController = {
346356
return CustomerSelectorViewController(
347357
siteID: siteID,
348-
configuration: .configurationForOrderFilter,
358+
configuration: configuration,
349359
addressFormViewModel: nil,
360+
selectedCustomerID: selectedCustomerID,
350361
onCustomerSelected: { [weak self] customer in
351362
selected.selectedValue = CustomerFilter(customer: customer)
352363

353364
self?.updateUI(numberOfActiveFilters: self?.viewModel.filterTypeViewModels.numberOfActiveFilters ?? 0)
354365
self?.listSelector.reloadData()
355-
self?.listSelector.dismiss(animated: true)
356366
}
357367
)
358368
}()
359369

360-
self.listSelector.present(
361-
WooNavigationController(rootViewController: controller),
362-
animated: true
363-
)
370+
self.listSelector.navigationController?.pushViewController(controller, animated: true)
364371
case .bookingResource(let siteID):
365372
let selectedMember = selected.selectedValue as? BookingResource
366373
let syncable = TeamMemberListSyncable(siteID: siteID)
367374
let viewModel = SyncableListSelectorViewModel(syncable: syncable)
368375
let memberListSelectorView = SyncableListSelectorView(
369376
viewModel: viewModel,
370377
syncable: syncable,
371-
selectedItem: selectedMember,
378+
initialSelection: { $0 == selectedMember },
372379
onSelection: { [weak self] resource in
373380
selected.selectedValue = resource
374381
self?.updateUI(numberOfActiveFilters: self?.viewModel.filterTypeViewModels.numberOfActiveFilters ?? 0)
375382
self?.listSelector.reloadData()
376-
self?.listSelector.navigationController?.popViewController(animated: true)
383+
}
384+
)
385+
let hostingController = UIHostingController(rootView: memberListSelectorView)
386+
listSelector.navigationController?.pushViewController(hostingController, animated: true)
387+
388+
case .bookableProduct(let siteID):
389+
let selectedProduct = selected.selectedValue as? BookingProductFilter
390+
let syncable = BookableProductListSyncable(siteID: siteID)
391+
let viewModel = SyncableListSelectorViewModel(syncable: syncable)
392+
let memberListSelectorView = SyncableListSelectorView(
393+
viewModel: viewModel,
394+
syncable: syncable,
395+
initialSelection: { $0?.productID == selectedProduct?.productID },
396+
onSelection: { [weak self] product in
397+
selected.selectedValue = {
398+
guard let product else { return BookingProductFilter?.none }
399+
return BookingProductFilter(productID: product.productID, name: product.name)
400+
}()
401+
self?.updateUI(numberOfActiveFilters: self?.viewModel.filterTypeViewModels.numberOfActiveFilters ?? 0)
402+
self?.listSelector.reloadData()
377403
}
378404
)
379405
let hostingController = UIHostingController(rootView: memberListSelectorView)

WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,20 @@ final class CustomerSearchUICommand: SearchUICommand {
6161
// Whether to hide button for creating customer in empty state
6262
private let disallowCreatingCustomer: Bool
6363

64+
// Whether to hide the detail text (username or "Guest") in each customer row
65+
private let hideDetailText: Bool
66+
67+
// The currently selected customer ID to show checkmark
68+
private var selectedCustomerID: Int64?
69+
6470
init(siteID: Int64,
6571
loadResultsWhenSearchTermIsEmpty: Bool = false,
6672
showSearchFilters: Bool = false,
6773
showGuestLabel: Bool = false,
6874
shouldTrackCustomerAdded: Bool = true,
6975
disallowCreatingCustomer: Bool = false,
76+
hideDetailText: Bool = false,
77+
selectedCustomerID: Int64? = nil,
7078
stores: StoresManager = ServiceLocator.stores,
7179
analytics: Analytics = ServiceLocator.analytics,
7280
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService,
@@ -80,6 +88,8 @@ final class CustomerSearchUICommand: SearchUICommand {
8088
self.showGuestLabel = showGuestLabel
8189
self.shouldTrackCustomerAdded = shouldTrackCustomerAdded
8290
self.disallowCreatingCustomer = disallowCreatingCustomer
91+
self.hideDetailText = hideDetailText
92+
self.selectedCustomerID = selectedCustomerID
8393
self.stores = stores
8494
self.analytics = analytics
8595
self.featureFlagService = featureFlagService
@@ -89,6 +99,10 @@ final class CustomerSearchUICommand: SearchUICommand {
8999
self.onDidFinishSyncingAllCustomersFirstPage = onDidFinishSyncingAllCustomersFirstPage
90100
}
91101

102+
func updateSelectedCustomerID(_ customerID: Int64?) {
103+
self.selectedCustomerID = customerID
104+
}
105+
92106
var hideCancelButton: Bool {
93107
featureFlagService.isFeatureFlagEnabled(.betterCustomerSelectionInOrder)
94108
}
@@ -143,9 +157,12 @@ final class CustomerSearchUICommand: SearchUICommand {
143157
let storageManager = ServiceLocator.storageManager
144158
let predicate = NSPredicate(format: "siteID == %lld", siteID)
145159
let newCustomerSelectorIsEnabled = featureFlagService.isFeatureFlagEnabled(.betterCustomerSelectionInOrder)
146-
let descriptor = newCustomerSelectorIsEnabled ?
147-
NSSortDescriptor(keyPath: \StorageCustomer.firstName, ascending: true) : NSSortDescriptor(keyPath: \StorageCustomer.customerID, ascending: false)
148-
return ResultsController<StorageCustomer>(storageManager: storageManager, matching: predicate, sortedBy: [descriptor])
160+
let descriptors = newCustomerSelectorIsEnabled ?
161+
[
162+
NSSortDescriptor(keyPath: \StorageCustomer.firstName, ascending: true),
163+
NSSortDescriptor(keyPath: \StorageCustomer.customerID, ascending: true)
164+
] : [NSSortDescriptor(keyPath: \StorageCustomer.customerID, ascending: false)]
165+
return ResultsController<StorageCustomer>(storageManager: storageManager, matching: predicate, sortedBy: descriptors)
149166
}
150167

151168
func createStarterViewController() -> UIViewController? {
@@ -171,7 +188,11 @@ final class CustomerSearchUICommand: SearchUICommand {
171188
}
172189

173190
func createCellViewModel(model: Customer) -> UnderlineableTitleAndSubtitleAndDetailTableViewCell.ViewModel {
174-
let detail = showGuestLabel && model.customerID == 0 ? Localization.guestLabel : model.username ?? ""
191+
let detail: String = {
192+
guard !hideDetailText else { return "" }
193+
return showGuestLabel && model.customerID == 0 ? Localization.guestLabel : model.username ?? ""
194+
}()
195+
let isSelected = selectedCustomerID == model.customerID
175196

176197
return CellViewModel(
177198
id: "\(model.customerID)",
@@ -181,7 +202,8 @@ final class CustomerSearchUICommand: SearchUICommand {
181202
subtitle: model.email,
182203
accessibilityLabel: "",
183204
detail: detail,
184-
underlinedText: searchTerm?.count ?? 0 > 1 ? searchTerm : "" // Only underline the search term if it's longer than 1 character
205+
underlinedText: searchTerm?.count ?? 0 > 1 ? searchTerm : "", // Only underline the search term if it's longer than 1 character
206+
isSelected: isSelected
185207
)
186208
}
187209

0 commit comments

Comments
 (0)