From 8b8dbe4a396db9df8304278fa8fc58601d82ac14 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Fri, 24 Oct 2025 11:57:05 +0700 Subject: [PATCH 1/8] Add new view for selecting service/event --- .../Model/Bookings/BookingProductFilter.swift | 12 ++-- .../BookableProductListSyncable.swift | 68 +++++++++++++++++++ .../BookingFiltersViewModel.swift | 2 +- .../Filters/FilterListViewController.swift | 23 +++++++ 4 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift diff --git a/Modules/Sources/Yosemite/Model/Bookings/BookingProductFilter.swift b/Modules/Sources/Yosemite/Model/Bookings/BookingProductFilter.swift index 445473fbc63..d33f364bf6d 100644 --- a/Modules/Sources/Yosemite/Model/Bookings/BookingProductFilter.swift +++ b/Modules/Sources/Yosemite/Model/Bookings/BookingProductFilter.swift @@ -1,9 +1,11 @@ -// periphery:ignore:all - will be used for booking filters import Foundation /// Used to filter bookings by product /// public struct BookingProductFilter: Codable, Hashable { + /// The underlying product + public let product: Product + /// ID of the product /// public let id: Int64 @@ -12,9 +14,9 @@ public struct BookingProductFilter: Codable, Hashable { /// public let name: String - public init(id: Int64, - name: String) { - self.id = id - self.name = name + public init(product: Product) { + self.id = product.productID + self.name = product.name + self.product = product } } diff --git a/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift b/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift new file mode 100644 index 00000000000..d64376003bc --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift @@ -0,0 +1,68 @@ +import Foundation +import Yosemite + +/// Syncable implementation for booking services/events (bookable product) filtering +struct BookableProductListSyncable: ListSyncable { + typealias StorageType = StorageProduct + typealias ModelType = Product + + let siteID: Int64 + + var title: String { Localization.title } + + var emptyStateMessage: String { Localization.noMembersFound } + + // MARK: - ResultsController Configuration + + func createPredicate() -> NSPredicate { + NSPredicate(format: "siteID == %lld AND productTypeKey == %@", siteID, ProductType.booking.rawValue) + } + + func createSortDescriptors() -> [NSSortDescriptor] { + [NSSortDescriptor(key: "productID", ascending: false)] + } + + // MARK: - Sync Configuration + + func createSyncAction( + pageNumber: Int, + pageSize: Int, + completion: @escaping (Result) -> Void + ) -> Action { + ProductAction.synchronizeProducts( + siteID: siteID, + pageNumber: pageNumber, + pageSize: pageSize, + stockStatus: nil, + productStatus: nil, + productType: .booking, + productCategory: nil, + sortOrder: .dateDescending, + productIDs: [], + excludedProductIDs: [], + shouldDeleteStoredProductsOnFirstPage: true, + onCompletion: completion + ) + } + + // MARK: - Display Configuration + + func displayName(for item: Product) -> String { + item.name + } +} + +private extension BookableProductListSyncable { + enum Localization { + static let title = NSLocalizedString( + "bookingServiceEventSelectorView.title", + value: "Service / Event", + comment: "Title of the booking service/event selector view" + ) + static let noMembersFound = NSLocalizedString( + "bookingServiceEventSelectorView.noMembersFound", + value: "No service or event found", + comment: "Text on the empty view of the booking service/event selector view" + ) + } +} diff --git a/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift b/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift index 6fefb18bb79..275dc10ff9c 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift @@ -188,7 +188,7 @@ extension BookingFiltersViewModel.BookingListFilter { selectedValue: filters.teamMember) case .product(let siteID): return FilterTypeViewModel(title: title, - listSelectorConfig: .products(siteID: siteID), + listSelectorConfig: .bookableProduct(siteID: siteID), selectedValue: filters.product) case .customer(let siteID): return FilterTypeViewModel(title: title, diff --git a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift index 0a907439ed3..2c892055e06 100644 --- a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift @@ -96,6 +96,8 @@ enum FilterListValueSelectorConfig { case customer(siteID: Int64) // Filter list selector for booking team member case bookingResource(siteID: Int64) + // Filter list selector for bookable product + case bookableProduct(siteID: Int64) } @@ -378,6 +380,27 @@ private extension FilterListViewController { ) let hostingController = UIHostingController(rootView: memberListSelectorView) listSelector.navigationController?.pushViewController(hostingController, animated: true) + + case .bookableProduct(let siteID): + let selectedProduct = selected.selectedValue as? BookingProductFilter + let syncable = BookableProductListSyncable(siteID: siteID) + let viewModel = SyncableListSelectorViewModel(syncable: syncable) + let memberListSelectorView = SyncableListSelectorView( + viewModel: viewModel, + syncable: syncable, + selectedItem: selectedProduct?.product, + onSelection: { [weak self] product in + selected.selectedValue = { + guard let product else { return BookingProductFilter?.none } + return BookingProductFilter(product: product) + }() + self?.updateUI(numberOfActiveFilters: self?.viewModel.filterTypeViewModels.numberOfActiveFilters ?? 0) + self?.listSelector.reloadData() + self?.listSelector.navigationController?.popViewController(animated: true) + } + ) + let hostingController = UIHostingController(rootView: memberListSelectorView) + listSelector.navigationController?.pushViewController(hostingController, animated: true) } } } From d2fe760fb38ca05d63142f19553d18b6ec15cea1 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Fri, 24 Oct 2025 12:54:44 +0700 Subject: [PATCH 2/8] Update customer filter --- .../BookingFiltersViewModel.swift | 2 +- .../CustomerSelector+BookingFilter.swift | 19 ++++++++++++++++++ .../Filters/FilterListViewController.swift | 20 +++++++++++-------- .../CustomerSelectorViewController.swift | 16 +++++++++++---- .../Legacy/LegacyOrderCustomerSection.swift | 3 ++- .../OrderCustomerSection.swift | 3 ++- .../CustomerSelector+Filter.swift | 3 ++- .../FilterOrderListViewModel.swift | 2 +- 8 files changed, 51 insertions(+), 17 deletions(-) create mode 100644 WooCommerce/Classes/Bookings/BookingFilters/CustomerSelector+BookingFilter.swift diff --git a/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift b/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift index 275dc10ff9c..6fda3cfd5fe 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift @@ -192,7 +192,7 @@ extension BookingFiltersViewModel.BookingListFilter { selectedValue: filters.product) case .customer(let siteID): return FilterTypeViewModel(title: title, - listSelectorConfig: .customer(siteID: siteID), + listSelectorConfig: .customer(siteID: siteID, source: .booking), selectedValue: filters.customer) case .attendanceStatus: let options: [BookingAttendanceStatus?] = [nil, .booked, .checkedIn, .cancelled, .noShow] diff --git a/WooCommerce/Classes/Bookings/BookingFilters/CustomerSelector+BookingFilter.swift b/WooCommerce/Classes/Bookings/BookingFilters/CustomerSelector+BookingFilter.swift new file mode 100644 index 00000000000..5ed16bad615 --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingFilters/CustomerSelector+BookingFilter.swift @@ -0,0 +1,19 @@ +import Foundation + +extension CustomerSelectorViewController.Configuration { + static let configurationForBookingFilter = CustomerSelectorViewController.Configuration( + title: BookingFilterLocalization.customerSelectorTitle, + disallowSelectingGuest: true, + disallowCreatingCustomer: true, + showGuestLabel: true, + shouldTrackCustomerAdded: false, + isModal: false + ) + + enum BookingFilterLocalization { + static let customerSelectorTitle = NSLocalizedString( + "configurationForBookingFilter.customerName", + value: "Customer name", + comment: "Title for the screen to select customer in booking filtering.") + } +} diff --git a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift index 2c892055e06..f0f1b85601b 100644 --- a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift @@ -93,7 +93,7 @@ enum FilterListValueSelectorConfig { // Filter list selector for products case products(siteID: Int64) // Filter list selector for customer - case customer(siteID: Int64) + case customer(siteID: Int64, source: FilterSource) // Filter list selector for booking team member case bookingResource(siteID: Int64) // Filter list selector for bookable product @@ -343,26 +343,30 @@ private extension FilterListViewController { }() self.listSelector.present(controller, animated: true) - case .customer(let siteID): + case .customer(let siteID, let source): + let configuration: CustomerSelectorViewController.Configuration = { + switch source { + case .booking: .configurationForBookingFilter + case .orders: .configurationForOrderFilter + case .products: fatalError("Customer filter not supported!") + } + }() let controller: CustomerSelectorViewController = { return CustomerSelectorViewController( siteID: siteID, - configuration: .configurationForOrderFilter, + configuration: configuration, addressFormViewModel: nil, onCustomerSelected: { [weak self] customer in selected.selectedValue = CustomerFilter(customer: customer) self?.updateUI(numberOfActiveFilters: self?.viewModel.filterTypeViewModels.numberOfActiveFilters ?? 0) self?.listSelector.reloadData() - self?.listSelector.dismiss(animated: true) + self?.listSelector.navigationController?.popViewController(animated: true) } ) }() - self.listSelector.present( - WooNavigationController(rootViewController: controller), - animated: true - ) + self.listSelector.navigationController?.pushViewController(controller, animated: true) case .bookingResource(let siteID): let selectedMember = selected.selectedValue as? BookingResource let syncable = TeamMemberListSyncable(siteID: siteID) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift index 3d5b56bf94f..b40d133be8e 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift @@ -82,6 +82,9 @@ extension CustomerSelectorViewController { // Whether to track when a customer cell is tapped. var shouldTrackCustomerAdded: Bool + + // Whether the selector is presented modally + var isModal: Bool } } @@ -134,7 +137,9 @@ private extension CustomerSelectorViewController { func configureNavigation() { navigationItem.title = configuration.title - navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelWasPressed)) + if configuration.isModal { + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelWasPressed)) + } if !configuration.disallowCreatingCustomer { navigationItem.rightBarButtonItem = UIBarButtonItem(image: .plusBarButtonItemImage, @@ -264,13 +269,16 @@ private extension CustomerSelectorViewController { activityIndicator.startAnimating() viewModel.onCustomerSelected(customer, onCompletion: { [weak self] result in - self?.activityIndicator.stopAnimating() + guard let self else { return } + activityIndicator.stopAnimating() switch result { case .success: - self?.dismiss(animated: true) + if configuration.isModal { + dismiss(animated: true) + } case .failure: - self?.showErrorNotice() + showErrorNotice() } }) } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/Legacy/LegacyOrderCustomerSection.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/Legacy/LegacyOrderCustomerSection.swift index 6a553b5927f..a143dc596ee 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/Legacy/LegacyOrderCustomerSection.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/Legacy/LegacyOrderCustomerSection.swift @@ -47,7 +47,8 @@ private extension CustomerSelectorViewController.Configuration { disallowSelectingGuest: false, disallowCreatingCustomer: false, showGuestLabel: false, - shouldTrackCustomerAdded: true + shouldTrackCustomerAdded: true, + isModal: true ) enum OrderCustomerLocalization { diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/OrderCustomerSection.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/OrderCustomerSection.swift index 56bbcccfe72..538f235218b 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/OrderCustomerSection.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/OrderCustomerSection.swift @@ -32,7 +32,8 @@ struct OrderCustomerSection: View { disallowSelectingGuest: viewModel.isCustomerAccountRequired, disallowCreatingCustomer: true, showGuestLabel: false, - shouldTrackCustomerAdded: true + shouldTrackCustomerAdded: true, + isModal: true ), addressFormViewModel: viewModel.addressFormViewModel) { customer in viewModel.addCustomerFromSearch(customer) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Filters/CustomerSelector+Filter.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Filters/CustomerSelector+Filter.swift index c170579fa5c..723d55079fd 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Filters/CustomerSelector+Filter.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Filters/CustomerSelector+Filter.swift @@ -6,7 +6,8 @@ extension CustomerSelectorViewController.Configuration { disallowSelectingGuest: true, disallowCreatingCustomer: true, showGuestLabel: true, - shouldTrackCustomerAdded: false + shouldTrackCustomerAdded: false, + isModal: false ) enum OrderFilterLocalization { diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Filters/FilterOrderListViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Filters/FilterOrderListViewModel.swift index 7fb52aa0ada..35340034a70 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Filters/FilterOrderListViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Filters/FilterOrderListViewModel.swift @@ -265,7 +265,7 @@ extension FilterOrderListViewModel.OrderListFilter { selectedValue: filters.product) case .customer(let siteID): return FilterTypeViewModel(title: title, - listSelectorConfig: .customer(siteID: siteID), + listSelectorConfig: .customer(siteID: siteID, source: .orders), selectedValue: filters.customer) case .salesChannel: let salesChannelOptions: [SalesChannelFilter] = [.any, .pointOfSale, .webCheckout, .wpAdmin] From ee6b4f67f647b2bd03e812482f5bcae4d8087533 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Fri, 24 Oct 2025 16:04:41 +0700 Subject: [PATCH 3/8] Move Customer name row down --- .../Sources/Yosemite/Model/Bookings/BookingProductFilter.swift | 1 + .../Bookings/BookingFilters/BookingFiltersViewModel.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Yosemite/Model/Bookings/BookingProductFilter.swift b/Modules/Sources/Yosemite/Model/Bookings/BookingProductFilter.swift index d33f364bf6d..c85203c4f17 100644 --- a/Modules/Sources/Yosemite/Model/Bookings/BookingProductFilter.swift +++ b/Modules/Sources/Yosemite/Model/Bookings/BookingProductFilter.swift @@ -7,6 +7,7 @@ public struct BookingProductFilter: Codable, Hashable { public let product: Product /// ID of the product + /// periphery:ignore - to be used later when applying filter /// public let id: Int64 diff --git a/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift b/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift index 6fda3cfd5fe..e786d5530a1 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift @@ -27,9 +27,9 @@ final class BookingFiltersViewModel: FilterListViewModel { filterTypeViewModels = [ teamMemberFilterViewModel, productFilterViewModel, - customerFilterViewModel, attendanceStatusFilterViewModel, paymentStatusFilterViewModel, + customerFilterViewModel, dateTimeFilterViewModel ] } From fdd478f7a7ff7dfca6cf17352e224157b3ca03a1 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Fri, 24 Oct 2025 17:48:42 +0700 Subject: [PATCH 4/8] Update layout for customer selector rows --- .../CustomerSelector+BookingFilter.swift | 9 ++++++++- .../ViewRelated/Filters/FilterListViewController.swift | 2 ++ .../CustomerSection/CustomerSearchUICommand.swift | 9 ++++++++- .../CustomerSelectorViewController.swift | 10 +++++++++- ...ineableTitleAndSubtitleAndDetailTableViewCell.swift | 5 ++++- 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/WooCommerce/Classes/Bookings/BookingFilters/CustomerSelector+BookingFilter.swift b/WooCommerce/Classes/Bookings/BookingFilters/CustomerSelector+BookingFilter.swift index 5ed16bad615..cdb027a7ef6 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/CustomerSelector+BookingFilter.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/CustomerSelector+BookingFilter.swift @@ -4,6 +4,7 @@ extension CustomerSelectorViewController.Configuration { static let configurationForBookingFilter = CustomerSelectorViewController.Configuration( title: BookingFilterLocalization.customerSelectorTitle, disallowSelectingGuest: true, + guestDisallowedMessage: BookingFilterLocalization.guestSelectionDisallowedError, disallowCreatingCustomer: true, showGuestLabel: true, shouldTrackCustomerAdded: false, @@ -14,6 +15,12 @@ extension CustomerSelectorViewController.Configuration { static let customerSelectorTitle = NSLocalizedString( "configurationForBookingFilter.customerName", value: "Customer name", - comment: "Title for the screen to select customer in booking filtering.") + comment: "Title for the screen to select customer in booking filtering." + ) + static let guestSelectionDisallowedError = NSLocalizedString( + "configurationForBookingFilter.guestSelectionDisallowedError", + value: "This user is a guest, and guests can’t be used for filtering bookings.", + comment: "Error message when selecting guest customer in booking filtering" + ) } } diff --git a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift index f0f1b85601b..2dcc085a063 100644 --- a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift @@ -351,11 +351,13 @@ private extension FilterListViewController { case .products: fatalError("Customer filter not supported!") } }() + let selectedCustomerID = (selected.selectedValue as? CustomerFilter)?.id let controller: CustomerSelectorViewController = { return CustomerSelectorViewController( siteID: siteID, configuration: configuration, addressFormViewModel: nil, + selectedCustomerID: selectedCustomerID, onCustomerSelected: { [weak self] customer in selected.selectedValue = CustomerFilter(customer: customer) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift index 9c817ad4cd2..45d0b3d228a 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift @@ -61,12 +61,16 @@ final class CustomerSearchUICommand: SearchUICommand { // Whether to hide button for creating customer in empty state private let disallowCreatingCustomer: Bool + // The currently selected customer ID to show checkmark + private let selectedCustomerID: Int64? + init(siteID: Int64, loadResultsWhenSearchTermIsEmpty: Bool = false, showSearchFilters: Bool = false, showGuestLabel: Bool = false, shouldTrackCustomerAdded: Bool = true, disallowCreatingCustomer: Bool = false, + selectedCustomerID: Int64? = nil, stores: StoresManager = ServiceLocator.stores, analytics: Analytics = ServiceLocator.analytics, featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService, @@ -80,6 +84,7 @@ final class CustomerSearchUICommand: SearchUICommand { self.showGuestLabel = showGuestLabel self.shouldTrackCustomerAdded = shouldTrackCustomerAdded self.disallowCreatingCustomer = disallowCreatingCustomer + self.selectedCustomerID = selectedCustomerID self.stores = stores self.analytics = analytics self.featureFlagService = featureFlagService @@ -172,6 +177,7 @@ final class CustomerSearchUICommand: SearchUICommand { func createCellViewModel(model: Customer) -> UnderlineableTitleAndSubtitleAndDetailTableViewCell.ViewModel { let detail = showGuestLabel && model.customerID == 0 ? Localization.guestLabel : model.username ?? "" + let isSelected = selectedCustomerID == model.customerID return CellViewModel( id: "\(model.customerID)", @@ -181,7 +187,8 @@ final class CustomerSearchUICommand: SearchUICommand { subtitle: model.email, accessibilityLabel: "", detail: detail, - underlinedText: searchTerm?.count ?? 0 > 1 ? searchTerm : "" // Only underline the search term if it's longer than 1 character + underlinedText: searchTerm?.count ?? 0 > 1 ? searchTerm : "", // Only underline the search term if it's longer than 1 character + isSelected: isSelected ) } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift index b40d133be8e..1e1d43b72d7 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift @@ -13,6 +13,7 @@ final class CustomerSelectorViewController: UIViewController, GhostableViewContr private let onCustomerSelected: (Customer) -> Void private let viewModel: CustomerSelectorViewModel private let addressFormViewModel: CreateOrderAddressFormViewModel? + private let selectedCustomerID: Int64? let configuration: CustomerSelectorViewController.Configuration @@ -35,11 +36,13 @@ final class CustomerSelectorViewController: UIViewController, GhostableViewContr /// - siteID: ID of the current site /// - configuration: UI configuration for the view controller /// - addressFormViewModel: Optional, has to be provided if the view has to include new customer creation. + /// - selectedCustomerID: Optional, ID of the currently selected customer to show checkmark /// - onCustomerSelected: Callback to be called when a customer is selected /// init(siteID: Int64, configuration: CustomerSelectorViewController.Configuration, addressFormViewModel: CreateOrderAddressFormViewModel?, + selectedCustomerID: Int64? = nil, onCustomerSelected: @escaping (Customer) -> Void) { viewModel = CustomerSelectorViewModel( siteID: siteID, @@ -48,6 +51,7 @@ final class CustomerSelectorViewController: UIViewController, GhostableViewContr self.siteID = siteID self.configuration = configuration self.addressFormViewModel = addressFormViewModel + self.selectedCustomerID = selectedCustomerID self.onCustomerSelected = onCustomerSelected super.init(nibName: nil, bundle: nil) @@ -74,6 +78,9 @@ extension CustomerSelectorViewController { // Whether guest-type customers can be selected or not var disallowSelectingGuest: Bool + // Error message when selecting guest if disallowed + var guestDisallowedMessage = Localization.guestSelectionDisallowedError + // Whether to show or hide button to create customer var disallowCreatingCustomer: Bool @@ -198,6 +205,7 @@ private extension CustomerSelectorViewController { showGuestLabel: showGuestLabel, shouldTrackCustomerAdded: shouldTrackCustomerAdded, disallowCreatingCustomer: disallowCreatingCustomer, + selectedCustomerID: selectedCustomerID, onAddCustomerDetailsManually: onAddCustomerDetailsManually, onDidSelectSearchResult: onCustomerTapped, onDidStartSyncingAllCustomersFirstPage: { @@ -290,7 +298,7 @@ private extension CustomerSelectorViewController { func showGuestSelectionDisallowedNotice() { noticePresenter.presentingViewController = self - noticePresenter.enqueue(notice: Notice(title: Localization.guestSelectionDisallowedError, feedbackType: .error)) + noticePresenter.enqueue(notice: Notice(title: configuration.guestDisallowedMessage, feedbackType: .error)) } } diff --git a/WooCommerce/Classes/ViewRelated/ReusableViews/UnderlineableTitleAndSubtitleAndDetailTableViewCell.swift b/WooCommerce/Classes/ViewRelated/ReusableViews/UnderlineableTitleAndSubtitleAndDetailTableViewCell.swift index d156d0f81c2..91d0771e46c 100644 --- a/WooCommerce/Classes/ViewRelated/ReusableViews/UnderlineableTitleAndSubtitleAndDetailTableViewCell.swift +++ b/WooCommerce/Classes/ViewRelated/ReusableViews/UnderlineableTitleAndSubtitleAndDetailTableViewCell.swift @@ -13,12 +13,14 @@ final class UnderlineableTitleAndSubtitleAndDetailTableViewCell: UITableViewCell override func awakeFromNib() { super.awakeFromNib() configureBackground() + accessoryType = .none } func configureCell(searchModel: ViewModel) { accessibilityLabel = searchModel.accessibilityLabel setupTitleLabelText(with: searchModel) subtitleLabel.attributedText = subtitleAttributedString(from: searchModel) + accessoryType = searchModel.isSelected ? .checkmark : .none } override func updateConfiguration(using state: UICellConfigurationState) { @@ -42,6 +44,7 @@ extension UnderlineableTitleAndSubtitleAndDetailTableViewCell { let accessibilityLabel: String let detail: String let underlinedText: String? + let isSelected: Bool } } @@ -54,7 +57,7 @@ private extension UnderlineableTitleAndSubtitleAndDetailTableViewCell { attributes: [.font: UIFont.caption1, .foregroundColor: UIColor.textTertiary]) } - let subtitle = NSMutableAttributedString(string: viewModel.subtitle, attributes: [.font: UIFont.caption1, .foregroundColor: UIColor.text]) + let subtitle = NSMutableAttributedString(string: viewModel.subtitle, attributes: [.font: UIFont.caption1, .foregroundColor: UIColor.secondaryLabel]) if let underlinedText = viewModel.underlinedText { subtitle.underlineSubstring(underlinedText: underlinedText) From 9e59c705644ba3ec668765611ad43508af3a9d0c Mon Sep 17 00:00:00 2001 From: Huong Do Date: Fri, 24 Oct 2025 18:02:03 +0700 Subject: [PATCH 5/8] Hide username for customers in booking filter --- .../CustomerSelector+BookingFilter.swift | 3 ++- .../CustomerSection/CustomerSearchUICommand.swift | 10 +++++++++- .../CustomerSelectorViewController.swift | 4 ++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/WooCommerce/Classes/Bookings/BookingFilters/CustomerSelector+BookingFilter.swift b/WooCommerce/Classes/Bookings/BookingFilters/CustomerSelector+BookingFilter.swift index cdb027a7ef6..4d538a6c704 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/CustomerSelector+BookingFilter.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/CustomerSelector+BookingFilter.swift @@ -8,7 +8,8 @@ extension CustomerSelectorViewController.Configuration { disallowCreatingCustomer: true, showGuestLabel: true, shouldTrackCustomerAdded: false, - isModal: false + isModal: false, + hideDetailText: true ) enum BookingFilterLocalization { diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift index 45d0b3d228a..7662bb1af36 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift @@ -61,6 +61,9 @@ final class CustomerSearchUICommand: SearchUICommand { // Whether to hide button for creating customer in empty state private let disallowCreatingCustomer: Bool + // Whether to hide the detail text (username or "Guest") in each customer row + private let hideDetailText: Bool + // The currently selected customer ID to show checkmark private let selectedCustomerID: Int64? @@ -70,6 +73,7 @@ final class CustomerSearchUICommand: SearchUICommand { showGuestLabel: Bool = false, shouldTrackCustomerAdded: Bool = true, disallowCreatingCustomer: Bool = false, + hideDetailText: Bool = false, selectedCustomerID: Int64? = nil, stores: StoresManager = ServiceLocator.stores, analytics: Analytics = ServiceLocator.analytics, @@ -84,6 +88,7 @@ final class CustomerSearchUICommand: SearchUICommand { self.showGuestLabel = showGuestLabel self.shouldTrackCustomerAdded = shouldTrackCustomerAdded self.disallowCreatingCustomer = disallowCreatingCustomer + self.hideDetailText = hideDetailText self.selectedCustomerID = selectedCustomerID self.stores = stores self.analytics = analytics @@ -176,7 +181,10 @@ final class CustomerSearchUICommand: SearchUICommand { } func createCellViewModel(model: Customer) -> UnderlineableTitleAndSubtitleAndDetailTableViewCell.ViewModel { - let detail = showGuestLabel && model.customerID == 0 ? Localization.guestLabel : model.username ?? "" + let detail: String = { + guard !hideDetailText else { return "" } + return showGuestLabel && model.customerID == 0 ? Localization.guestLabel : model.username ?? "" + }() let isSelected = selectedCustomerID == model.customerID return CellViewModel( diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift index 1e1d43b72d7..19ed2c5dc74 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift @@ -92,6 +92,9 @@ extension CustomerSelectorViewController { // Whether the selector is presented modally var isModal: Bool + + // Whether to hide the detail text (username or "Guest") in each customer row + var hideDetailText: Bool = false } } @@ -205,6 +208,7 @@ private extension CustomerSelectorViewController { showGuestLabel: showGuestLabel, shouldTrackCustomerAdded: shouldTrackCustomerAdded, disallowCreatingCustomer: disallowCreatingCustomer, + hideDetailText: configuration.hideDetailText, selectedCustomerID: selectedCustomerID, onAddCustomerDetailsManually: onAddCustomerDetailsManually, onDidSelectSearchResult: onCustomerTapped, From 5d7ed17899f7699efed035f8b7a3e583b044168a Mon Sep 17 00:00:00 2001 From: Huong Do Date: Mon, 27 Oct 2025 14:34:32 +0700 Subject: [PATCH 6/8] Do not dismiss selector views after selection --- .../Filters/FilterListViewController.swift | 3 -- .../CustomerSearchUICommand.swift | 6 ++- .../CustomerSelectorViewController.swift | 52 +++++++++++-------- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift index 2dcc085a063..ab89b4c0edc 100644 --- a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift @@ -363,7 +363,6 @@ private extension FilterListViewController { self?.updateUI(numberOfActiveFilters: self?.viewModel.filterTypeViewModels.numberOfActiveFilters ?? 0) self?.listSelector.reloadData() - self?.listSelector.navigationController?.popViewController(animated: true) } ) }() @@ -381,7 +380,6 @@ private extension FilterListViewController { selected.selectedValue = resource self?.updateUI(numberOfActiveFilters: self?.viewModel.filterTypeViewModels.numberOfActiveFilters ?? 0) self?.listSelector.reloadData() - self?.listSelector.navigationController?.popViewController(animated: true) } ) let hostingController = UIHostingController(rootView: memberListSelectorView) @@ -402,7 +400,6 @@ private extension FilterListViewController { }() self?.updateUI(numberOfActiveFilters: self?.viewModel.filterTypeViewModels.numberOfActiveFilters ?? 0) self?.listSelector.reloadData() - self?.listSelector.navigationController?.popViewController(animated: true) } ) let hostingController = UIHostingController(rootView: memberListSelectorView) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift index 7662bb1af36..d4cde572f48 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift @@ -65,7 +65,7 @@ final class CustomerSearchUICommand: SearchUICommand { private let hideDetailText: Bool // The currently selected customer ID to show checkmark - private let selectedCustomerID: Int64? + private var selectedCustomerID: Int64? init(siteID: Int64, loadResultsWhenSearchTermIsEmpty: Bool = false, @@ -99,6 +99,10 @@ final class CustomerSearchUICommand: SearchUICommand { self.onDidFinishSyncingAllCustomersFirstPage = onDidFinishSyncingAllCustomersFirstPage } + func updateSelectedCustomerID(_ customerID: Int64?) { + self.selectedCustomerID = customerID + } + var hideCancelButton: Bool { featureFlagService.isFeatureFlagEnabled(.betterCustomerSelectionInOrder) } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift index 19ed2c5dc74..7e9cb13879e 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSelectorViewController.swift @@ -8,6 +8,7 @@ import Yosemite /// final class CustomerSelectorViewController: UIViewController, GhostableViewController { private var searchViewController: SearchViewController? + private var customerSearchCommand: CustomerSearchUICommand? private var emptyStateViewController: UIViewController? private let siteID: Int64 private let onCustomerSelected: (Customer) -> Void @@ -200,31 +201,34 @@ private extension CustomerSelectorViewController { shouldTrackCustomerAdded: Bool, disallowCreatingCustomer: Bool, onAddCustomerDetailsManually: (() -> Void)? = nil) { + let command = CustomerSearchUICommand(siteID: siteID, + loadResultsWhenSearchTermIsEmpty: loadResultsWhenSearchTermIsEmpty, + showSearchFilters: showSearchFilters, + showGuestLabel: showGuestLabel, + shouldTrackCustomerAdded: shouldTrackCustomerAdded, + disallowCreatingCustomer: disallowCreatingCustomer, + hideDetailText: configuration.hideDetailText, + selectedCustomerID: selectedCustomerID, + onAddCustomerDetailsManually: onAddCustomerDetailsManually, + onDidSelectSearchResult: onCustomerTapped, + onDidStartSyncingAllCustomersFirstPage: { + Task { @MainActor [weak self] in + guard let searchTableView = self?.searchViewController?.tableView else { + return + } + self?.displayGhostContent(over: searchTableView) + } + }, + onDidFinishSyncingAllCustomersFirstPage: { + Task { @MainActor [weak self] in + self?.removeGhostContent() + } + }) + self.customerSearchCommand = command + let searchViewController = SearchViewController( storeID: siteID, - command: CustomerSearchUICommand(siteID: siteID, - loadResultsWhenSearchTermIsEmpty: loadResultsWhenSearchTermIsEmpty, - showSearchFilters: showSearchFilters, - showGuestLabel: showGuestLabel, - shouldTrackCustomerAdded: shouldTrackCustomerAdded, - disallowCreatingCustomer: disallowCreatingCustomer, - hideDetailText: configuration.hideDetailText, - selectedCustomerID: selectedCustomerID, - onAddCustomerDetailsManually: onAddCustomerDetailsManually, - onDidSelectSearchResult: onCustomerTapped, - onDidStartSyncingAllCustomersFirstPage: { - Task { @MainActor [weak self] in - guard let searchTableView = self?.searchViewController?.tableView else { - return - } - self?.displayGhostContent(over: searchTableView) - } - }, - onDidFinishSyncingAllCustomersFirstPage: { - Task { @MainActor [weak self] in - self?.removeGhostContent() - } - }), + command: command, cellType: UnderlineableTitleAndSubtitleAndDetailTableViewCell.self, cellSeparator: .none ) @@ -283,6 +287,8 @@ private extension CustomerSelectorViewController { viewModel.onCustomerSelected(customer, onCompletion: { [weak self] result in guard let self else { return } activityIndicator.stopAnimating() + customerSearchCommand?.updateSelectedCustomerID(customer.customerID) + searchViewController?.tableView.reloadData() switch result { case .success: From fb766876a48a06e4a829fd6c5bf1939fa46dc200 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Mon, 27 Oct 2025 16:52:49 +0700 Subject: [PATCH 7/8] Update sort descriptors for customers --- .../CustomerSection/CustomerSearchUICommand.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift index d4cde572f48..163924cb5e9 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift @@ -157,9 +157,12 @@ final class CustomerSearchUICommand: SearchUICommand { let storageManager = ServiceLocator.storageManager let predicate = NSPredicate(format: "siteID == %lld", siteID) let newCustomerSelectorIsEnabled = featureFlagService.isFeatureFlagEnabled(.betterCustomerSelectionInOrder) - let descriptor = newCustomerSelectorIsEnabled ? - NSSortDescriptor(keyPath: \StorageCustomer.firstName, ascending: true) : NSSortDescriptor(keyPath: \StorageCustomer.customerID, ascending: false) - return ResultsController(storageManager: storageManager, matching: predicate, sortedBy: [descriptor]) + let descriptors = newCustomerSelectorIsEnabled ? + [ + NSSortDescriptor(keyPath: \StorageCustomer.firstName, ascending: true), + NSSortDescriptor(keyPath: \StorageCustomer.customerID, ascending: true) + ] : [NSSortDescriptor(keyPath: \StorageCustomer.customerID, ascending: false)] + return ResultsController(storageManager: storageManager, matching: predicate, sortedBy: descriptors) } func createStarterViewController() -> UIViewController? { From b6ed021b9b629cdd351881b7194af15b61bdf96a Mon Sep 17 00:00:00 2001 From: Huong Do Date: Tue, 28 Oct 2025 10:32:30 +0700 Subject: [PATCH 8/8] Update initial selection logic and remove full product model from BookingProductFilter --- .../Model/Bookings/BookingProductFilter.swift | 12 ++++-------- .../BookingFilters/SyncableListSelectorView.swift | 14 +++++++++++--- .../Filters/FilterListViewController.swift | 6 +++--- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/Modules/Sources/Yosemite/Model/Bookings/BookingProductFilter.swift b/Modules/Sources/Yosemite/Model/Bookings/BookingProductFilter.swift index c85203c4f17..da0d7c6f71f 100644 --- a/Modules/Sources/Yosemite/Model/Bookings/BookingProductFilter.swift +++ b/Modules/Sources/Yosemite/Model/Bookings/BookingProductFilter.swift @@ -3,21 +3,17 @@ import Foundation /// Used to filter bookings by product /// public struct BookingProductFilter: Codable, Hashable { - /// The underlying product - public let product: Product - /// ID of the product /// periphery:ignore - to be used later when applying filter /// - public let id: Int64 + public let productID: Int64 /// Name of the product /// public let name: String - public init(product: Product) { - self.id = product.productID - self.name = product.name - self.product = product + public init(productID: Int64, name: String) { + self.productID = productID + self.name = name } } diff --git a/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift index 63c6a3bfdf0..23c8ccaee95 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift @@ -5,17 +5,18 @@ struct SyncableListSelectorView: View { @State var selectedItem: Syncable.ModelType? private let syncable: Syncable + private let initialSelection: (Syncable.ModelType?) -> Bool private let onSelection: (Syncable.ModelType?) -> Void private let viewPadding: CGFloat = 16 init(viewModel: SyncableListSelectorViewModel, syncable: Syncable, - selectedItem: Syncable.ModelType?, + initialSelection: @escaping (Syncable.ModelType?) -> Bool, onSelection: @escaping (Syncable.ModelType?) -> Void) { self.viewModel = viewModel self.syncable = syncable - self.selectedItem = selectedItem + self.initialSelection = initialSelection self.onSelection = onSelection } @@ -68,7 +69,7 @@ private extension SyncableListSelectorView { ForEach(items, id: \.self) { item in optionRow(text: syncable.displayName(for: item), - isSelected: item == selectedItem, + isSelected: isItemSelected(item), onSelection: { selectedItem = item }) } @@ -82,6 +83,13 @@ private extension SyncableListSelectorView { .background(Color(.listBackground)) } + func isItemSelected(_ item: Syncable.ModelType?) -> Bool { + if let selectedItem { + return item == selectedItem + } + return initialSelection(item) + } + func optionRow(text: String, isSelected: Bool, onSelection: @escaping () -> Void) -> some View { HStack { Text(text) diff --git a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift index ab89b4c0edc..2f3418bb03b 100644 --- a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift @@ -375,7 +375,7 @@ private extension FilterListViewController { let memberListSelectorView = SyncableListSelectorView( viewModel: viewModel, syncable: syncable, - selectedItem: selectedMember, + initialSelection: { $0 == selectedMember }, onSelection: { [weak self] resource in selected.selectedValue = resource self?.updateUI(numberOfActiveFilters: self?.viewModel.filterTypeViewModels.numberOfActiveFilters ?? 0) @@ -392,11 +392,11 @@ private extension FilterListViewController { let memberListSelectorView = SyncableListSelectorView( viewModel: viewModel, syncable: syncable, - selectedItem: selectedProduct?.product, + initialSelection: { $0?.productID == selectedProduct?.productID }, onSelection: { [weak self] product in selected.selectedValue = { guard let product else { return BookingProductFilter?.none } - return BookingProductFilter(product: product) + return BookingProductFilter(productID: product.productID, name: product.name) }() self?.updateUI(numberOfActiveFilters: self?.viewModel.filterTypeViewModels.numberOfActiveFilters ?? 0) self?.listSelector.reloadData()