diff --git a/Modules/Sources/Networking/Model/Customer.swift b/Modules/Sources/Networking/Model/Customer.swift index 059c8ed3adc..cde1b71aeb2 100644 --- a/Modules/Sources/Networking/Model/Customer.swift +++ b/Modules/Sources/Networking/Model/Customer.swift @@ -4,7 +4,7 @@ import Codegen /// Represents a Customer entity: /// https://woocommerce.github.io/woocommerce-rest-api-docs/#customer-properties /// -public struct Customer: Codable, GeneratedCopiable, GeneratedFakeable, Equatable { +public struct Customer: Codable, GeneratedCopiable, GeneratedFakeable, Equatable, Hashable { /// The siteID for the customer public let siteID: Int64 diff --git a/Modules/Sources/Yosemite/Actions/CustomerAction.swift b/Modules/Sources/Yosemite/Actions/CustomerAction.swift index 2d161fe10b9..259c47c3607 100644 --- a/Modules/Sources/Yosemite/Actions/CustomerAction.swift +++ b/Modules/Sources/Yosemite/Actions/CustomerAction.swift @@ -50,8 +50,8 @@ public enum CustomerAction: Action { ///- `filter`: Filter to perform the search. ///- `retrieveFullCustomersData`: If `true`, retrieves all customers data one by one after the search request. It will be removed once /// `betterCustomerSelectionInOrder` is finished for performance reasons. - ///- `onCompletion`: Invoked when the operation finishes. - /// - `result.success()`: On success. + ///- `onCompletion`: Invoked when the operation finishes. Returns true if there are more customers to be synced in the search results. + /// - `result.success(Bool)`: On success, returns whether there are more pages available. /// - `result.failure(Error)`: Error fetching data case searchCustomers( siteID: Int64, @@ -63,7 +63,7 @@ public enum CustomerAction: Action { retrieveFullCustomersData: Bool, filter: CustomerSearchFilter, filterEmpty: WCAnalyticsCustomerRemote.FilterEmpty? = nil, - onCompletion: (Result<(), Error>) -> Void) + onCompletion: (Result) -> Void) /// Searches for WCAnalyticsCustomers by keyword and stores the results. /// diff --git a/Modules/Sources/Yosemite/Model/Bookings/BookingCustomerFilter.swift b/Modules/Sources/Yosemite/Model/Bookings/BookingCustomerFilter.swift new file mode 100644 index 00000000000..2485caa2c71 --- /dev/null +++ b/Modules/Sources/Yosemite/Model/Bookings/BookingCustomerFilter.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Used to filter bookings by customers +/// +public struct BookingCustomerFilter: Codable, Hashable { + /// ID of the customer + /// periphery:ignore - to be used later when applying filter + /// + public let customerID: Int64 + + /// Name of the customer + /// + public let name: String + + public init(customerID: Int64, name: String) { + self.customerID = customerID + self.name = name + } +} diff --git a/Modules/Sources/Yosemite/Stores/CustomerStore.swift b/Modules/Sources/Yosemite/Stores/CustomerStore.swift index 6225093c782..aeb2c4d819a 100644 --- a/Modules/Sources/Yosemite/Stores/CustomerStore.swift +++ b/Modules/Sources/Yosemite/Stores/CustomerStore.swift @@ -90,7 +90,7 @@ public final class CustomerStore: Store { /// - Parameters: /// - siteID: The site for which the array of Customers should be fetched. /// - keyword: Keyword that we pass to the `?query={keyword}` endpoint to perform the search - /// - onCompletion: Invoked when the operation finishes. + /// - onCompletion: Invoked when the operation finishes. Returns true if there are more pages available. /// func searchCustomers( for siteID: Int64, @@ -102,7 +102,7 @@ public final class CustomerStore: Store { retrieveFullCustomersData: Bool, filter: CustomerSearchFilter, filterEmpty: WCAnalyticsCustomerRemote.FilterEmpty?, - onCompletion: @escaping (Result<(), Error>) -> Void) { + onCompletion: @escaping (Result) -> Void) { wcAnalyticsCustomerRemote.searchCustomers(for: siteID, pageNumber: pageNumber, pageSize: pageSize, @@ -114,15 +114,24 @@ public final class CustomerStore: Store { guard let self else { return } switch result { case .success(let customers): + let hasNextPage = customers.count == pageSize if retrieveFullCustomersData { - self.mapSearchResultsToCustomerObjects(for: siteID, with: keyword, with: customers, onCompletion: onCompletion) + self.mapSearchResultsToCustomerObjects(for: siteID, + with: keyword, + with: customers, + onCompletion: { result in + switch result { + case .success: onCompletion(.success(hasNextPage)) + case .failure(let error): onCompletion(.failure(error)) + } + }) } else { self.upsertCustomersAndSave(siteID: siteID, readOnlyCustomers: customers, shouldDeleteExistingCustomers: pageNumber == 1, keyword: keyword, onCompletion: { - onCompletion(.success(())) + onCompletion(.success(hasNextPage)) }) } case .failure(let error): @@ -262,7 +271,7 @@ public final class CustomerStore: Store { private func mapSearchResultsToCustomerObjects(for siteID: Int64, with keyword: String, with searchResults: [WCAnalyticsCustomer], - onCompletion: @escaping (Result<(), Error>) -> Void) { + onCompletion: @escaping (Result) -> Void) { var customers = [Customer]() let group = DispatchGroup() for result in searchResults { diff --git a/Modules/Tests/YosemiteTests/Stores/CustomerStoreTests.swift b/Modules/Tests/YosemiteTests/Stores/CustomerStoreTests.swift index 47a302c6839..f2a58783116 100644 --- a/Modules/Tests/YosemiteTests/Stores/CustomerStoreTests.swift +++ b/Modules/Tests/YosemiteTests/Stores/CustomerStoreTests.swift @@ -143,7 +143,7 @@ final class CustomerStoreTests: XCTestCase { network.simulateError(requestUrlSuffix: "", error: expectedError) // When - let result = waitFor { promise in + let result: Result = waitFor { promise in let action = CustomerAction.searchCustomers( siteID: self.dummySiteID, pageNumber: 1, @@ -172,7 +172,7 @@ final class CustomerStoreTests: XCTestCase { XCTAssertEqual(viewStorage.countObjects(ofType: Storage.CustomerSearchResult.self), 0) // When - let response = waitFor { promise in + let response: Result = waitFor { promise in let action = CustomerAction.searchCustomers(siteID: self.dummySiteID, pageNumber: 1, pageSize: 25, @@ -188,6 +188,8 @@ final class CustomerStoreTests: XCTestCase { // Then XCTAssertTrue(response.isSuccess) + // Verify hasNextPage is false since we received 2 customers with pageSize 25 + XCTAssertEqual(try? response.get(), false) XCTAssertEqual(viewStorage.countObjects(ofType: Storage.Customer.self), 2) XCTAssertEqual(viewStorage.countObjects(ofType: Storage.CustomerSearchResult.self), 1) diff --git a/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift b/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift index af3c34b91b9..79ab1f49412 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/BookableProductListSyncable.swift @@ -9,9 +9,14 @@ struct BookableProductListSyncable: ListSyncable { let siteID: Int64 - var title: String { Localization.title } + let title = Localization.title - var emptyStateMessage: String { Localization.noMembersFound } + let emptyStateMessage = Localization.noMembersFound + let emptyItemTitlePlaceholder: String? = nil + + let searchConfiguration: ListSearchConfiguration? = nil + + let selectionDisabledMessage: String? = nil // MARK: - ResultsController Configuration @@ -46,12 +51,22 @@ struct BookableProductListSyncable: ListSyncable { ) } + /// Creates the action to search items with keyword + func createSearchAction(keyword: String, pageNumber: Int, pageSize: Int, completion: @escaping (Result) -> Void) -> Action { + fatalError("Searching is not supported") + } + // MARK: - Display Configuration func displayName(for item: Product) -> String { item.name } + /// Returns the description for an item + func description(for item: Product) -> String? { nil } + + func selectionEnabled(for item: Product) -> Bool { true } + func filterItem(for item: Product) -> BookingProductFilter { BookingProductFilter(productID: item.productID, name: item.name) } diff --git a/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift b/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift index a4cf9d02dcf..fb4cc328ef4 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/BookingFiltersViewModel.swift @@ -37,7 +37,7 @@ final class BookingFiltersViewModel: FilterListViewModel { var criteria: Filters { let teamMembers = (teamMemberFilterViewModel.selectedValue as? MultipleFilterSelection)?.items as? [BookingResource] ?? [] let products = (productFilterViewModel.selectedValue as? MultipleFilterSelection)?.items as? [BookingProductFilter] ?? [] - let customer = customerFilterViewModel.selectedValue as? CustomerFilter + let customers = (customerFilterViewModel.selectedValue as? MultipleFilterSelection)?.items as? [BookingCustomerFilter] ?? [] let attendanceStatuses = (attendanceStatusFilterViewModel.selectedValue as? MultipleFilterSelection)?.items as? [BookingAttendanceStatus] ?? [] let paymentStatuses = (paymentStatusFilterViewModel.selectedValue as? MultipleFilterSelection)?.items as? [BookingStatus] ?? [] let dateRange = dateTimeFilterViewModel.selectedValue as? BookingDateRangeFilter @@ -47,7 +47,7 @@ final class BookingFiltersViewModel: FilterListViewModel { products: products, attendanceStatuses: attendanceStatuses, paymentStatuses: paymentStatuses, - customer: customer, + customers: customers, dateRange: dateRange, numberOfActiveFilters: numberOfActiveFilters) } @@ -90,7 +90,7 @@ final class BookingFiltersViewModel: FilterListViewModel { let products: [BookingProductFilter] let attendanceStatuses: [BookingAttendanceStatus] let paymentStatuses: [BookingStatus] - let customer: CustomerFilter? + let customers: [BookingCustomerFilter] let dateRange: BookingDateRangeFilter? let numberOfActiveFilters: Int @@ -100,7 +100,7 @@ final class BookingFiltersViewModel: FilterListViewModel { products = [] attendanceStatuses = [] paymentStatuses = [] - customer = nil + customers = [] dateRange = nil numberOfActiveFilters = 0 } @@ -109,14 +109,14 @@ final class BookingFiltersViewModel: FilterListViewModel { products: [BookingProductFilter], attendanceStatuses: [BookingAttendanceStatus], paymentStatuses: [BookingStatus], - customer: CustomerFilter?, + customers: [BookingCustomerFilter], dateRange: BookingDateRangeFilter?, numberOfActiveFilters: Int) { self.teamMembers = teamMembers self.products = products self.attendanceStatuses = attendanceStatuses self.paymentStatuses = paymentStatuses - self.customer = customer + self.customers = customers self.dateRange = dateRange self.numberOfActiveFilters = numberOfActiveFilters } @@ -127,9 +127,8 @@ final class BookingFiltersViewModel: FilterListViewModel { attendanceStatuses.map { $0.localizedTitle } + paymentStatuses.map { $0.localizedTitle } - if let customer { - readable.append(customer.description) - } + readable += customers.map { $0.name } + if let dateRange { readable.append(dateRange.description) } @@ -184,8 +183,8 @@ extension BookingFiltersViewModel.BookingListFilter { selectedValue: MultipleFilterSelection(items: filters.products)) case .customer(let siteID): return FilterTypeViewModel(title: title, - listSelectorConfig: .customer(siteID: siteID, source: .booking), - selectedValue: filters.customer) + listSelectorConfig: .bookingCustomers(siteID: siteID), + selectedValue: MultipleFilterSelection(items: filters.customers)) case .attendanceStatus: let options: [BookingAttendanceStatus?] = [.booked, .checkedIn, .cancelled, .noShow] return FilterTypeViewModel(title: title, @@ -244,6 +243,14 @@ extension BookingProductFilter: FilterType { var isActive: Bool { true } } +extension BookingCustomerFilter: FilterType { + /// The user-facing description of the filter value. + var description: String { name } + + /// Whether the filter is set to a non-empty value. + var isActive: Bool { true } +} + extension BookingDateRangeFilter: FilterType { var description: String { if let startDate, let endDate { @@ -314,8 +321,8 @@ private extension BookingFiltersViewModel.BookingListFilter { comment: "Row title for filtering bookings by product.") static let rowTitleCustomer = NSLocalizedString( - "bookingFilters.rowTitleCustomer", - value: "Customer name", + "bookingFilters.rowCustomer", + value: "Customer", comment: "Row title for filtering bookings by customer.") static let rowTitleAttendanceStatus = NSLocalizedString( diff --git a/WooCommerce/Classes/Bookings/BookingFilters/CustomerListSyncable.swift b/WooCommerce/Classes/Bookings/BookingFilters/CustomerListSyncable.swift new file mode 100644 index 00000000000..cd8abbb6fd7 --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingFilters/CustomerListSyncable.swift @@ -0,0 +1,132 @@ +import Foundation +import Yosemite + +/// Syncable implementation for customer filtering +struct CustomerListSyncable: ListSyncable { + typealias StorageType = StorageCustomer + typealias ModelType = Customer + typealias ListFilterType = BookingCustomerFilter + + let siteID: Int64 + + let title = Localization.title + + let emptyStateMessage = Localization.noCustomersFound + let emptyItemTitlePlaceholder: String? = Localization.emptyItemTitlePlaceholder + + let searchConfiguration: ListSearchConfiguration? = ListSearchConfiguration( + searchPrompt: Localization.searchPrompt, + emptySearchTitle: Localization.noCustomersFound, + emptySearchDescription: Localization.emptySearchDescription + ) + + let selectionDisabledMessage: String? = Localization.selectionDisabledMessage + + // MARK: - ResultsController Configuration + + func createPredicate() -> NSPredicate { + NSPredicate(format: "siteID == %lld", siteID) + } + + func createSortDescriptors() -> [NSSortDescriptor] { + [NSSortDescriptor(key: "customerID", ascending: false)] + } + + // MARK: - Sync Configuration + + func createSyncAction( + pageNumber: Int, + pageSize: Int, + completion: @escaping (Result) -> Void + ) -> Action { + CustomerAction.synchronizeLightCustomersData( + siteID: siteID, + pageNumber: pageNumber, + pageSize: pageSize, + orderby: .name, + order: .asc, + filterEmpty: .email, + onCompletion: completion + ) + } + + /// Creates the action to search items with keyword + func createSearchAction(keyword: String, pageNumber: Int, pageSize: Int, completion: @escaping (Result) -> Void) -> Action { + CustomerAction.searchCustomers( + siteID: siteID, + pageNumber: pageNumber, + pageSize: pageSize, + orderby: .name, + order: .asc, + keyword: keyword, + retrieveFullCustomersData: false, + filter: .all, + filterEmpty: .email, + onCompletion: completion + ) + } + + // MARK: - Display Configuration + + func displayName(for item: Customer) -> String { + guard let firstName = item.firstName, firstName.isNotEmpty, + let lastName = item.lastName, lastName.isNotEmpty else { + return "" + } + return [firstName, lastName].joined(separator: " ") + } + + /// Returns the description for an item + func description(for item: Customer) -> String? { + item.email + } + + func selectionEnabled(for item: Customer) -> Bool { + item.customerID > 0 + } + + func filterItem(for item: Customer) -> BookingCustomerFilter { + let name: String = { + if let firstName = item.firstName, let lastName = item.lastName { + return [firstName, lastName].joined(separator: " ") + } + return item.username ?? item.email + }() + return BookingCustomerFilter(customerID: item.customerID, name: name) + } +} + +private extension CustomerListSyncable { + enum Localization { + static let title = NSLocalizedString( + "bookingCustomerSelectorView.title", + value: "Customer", + comment: "Title of the booking customer selector view" + ) + static let noCustomersFound = NSLocalizedString( + "bookingCustomerSelectorView.noCustomersFound", + value: "No customer found", + comment: "Text on the empty view of the booking customer selector view" + ) + static let emptyItemTitlePlaceholder = NSLocalizedString( + "bookingCustomerSelectorView.emptyItemTitlePlaceholder", + value: "No name", + comment: "Title of the booking customer selector view" + ) + static let searchPrompt = NSLocalizedString( + "bookingCustomerSelectorView.searchPrompt", + value: "Search customer", + comment: "Prompt in the search bar of the booking customer selector view" + ) + static let emptySearchDescription = NSLocalizedString( + "bookingCustomerSelectorView.emptySearchDescription", + value: "Try adjusting your search term to see more results", + comment: "Message on the empty search result view of the booking customer selector view" + ) + static let selectionDisabledMessage = NSLocalizedString( + "bookingCustomerSelectorView.selectionDisabledMessage", + 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/Bookings/BookingFilters/CustomerSelector+BookingFilter.swift b/WooCommerce/Classes/Bookings/BookingFilters/CustomerSelector+BookingFilter.swift deleted file mode 100644 index 4d538a6c704..00000000000 --- a/WooCommerce/Classes/Bookings/BookingFilters/CustomerSelector+BookingFilter.swift +++ /dev/null @@ -1,27 +0,0 @@ -import Foundation - -extension CustomerSelectorViewController.Configuration { - static let configurationForBookingFilter = CustomerSelectorViewController.Configuration( - title: BookingFilterLocalization.customerSelectorTitle, - disallowSelectingGuest: true, - guestDisallowedMessage: BookingFilterLocalization.guestSelectionDisallowedError, - disallowCreatingCustomer: true, - showGuestLabel: true, - shouldTrackCustomerAdded: false, - isModal: false, - hideDetailText: true - ) - - enum BookingFilterLocalization { - static let customerSelectorTitle = NSLocalizedString( - "configurationForBookingFilter.customerName", - value: "Customer name", - 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/Bookings/BookingFilters/ListSyncable.swift b/WooCommerce/Classes/Bookings/BookingFilters/ListSyncable.swift index 783f41a5703..4fe4f949c89 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/ListSyncable.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/ListSyncable.swift @@ -10,6 +10,9 @@ protocol ListSyncable { var title: String { get } var emptyStateMessage: String { get } + var emptyItemTitlePlaceholder: String? { get } + var searchConfiguration: ListSearchConfiguration? { get } + var selectionDisabledMessage: String? { get } // MARK: - ResultsController Configuration @@ -24,11 +27,26 @@ protocol ListSyncable { /// Creates the action to sync items from remote func createSyncAction(pageNumber: Int, pageSize: Int, completion: @escaping (Result) -> Void) -> Action + /// Creates the action to search items with keyword + func createSearchAction(keyword: String, pageNumber: Int, pageSize: Int, completion: @escaping (Result) -> Void) -> Action + // MARK: - Display Configuration /// Returns the display name for an item func displayName(for item: ModelType) -> String + /// Returns the description for an item + func description(for item: ModelType) -> String? + + /// Checks whether the specified item can be selected + func selectionEnabled(for item: ModelType) -> Bool + /// Returns the filter type for an item func filterItem(for item: ModelType) -> ListFilterType } + +struct ListSearchConfiguration { + let searchPrompt: String + let emptySearchTitle: String + let emptySearchDescription: String +} diff --git a/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift index 67a2fb549a4..bceffcc5701 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorView.swift @@ -3,11 +3,15 @@ import SwiftUI struct SyncableListSelectorView: View { @ObservedObject private var viewModel: SyncableListSelectorViewModel @State private var selectedItems: [Syncable.ListFilterType] + @State private var notice: Notice? + + @ScaledMetric private var scale: CGFloat = 1.0 private let syncable: Syncable private let onSelection: ([Syncable.ListFilterType]) -> Void private let viewPadding: CGFloat = 16 + private let emptyStateImageWidth: CGFloat = 67 init(viewModel: SyncableListSelectorViewModel, syncable: Syncable, @@ -28,7 +32,7 @@ struct SyncableListSelectorView: View { loadingView case .results: itemList(with: viewModel.items, - onNextPage: { viewModel.onLoadNextPageAction() }) + onNextPage: { viewModel.onLoadNextPageAction() }) } } .task { @@ -36,6 +40,12 @@ struct SyncableListSelectorView: View { } .navigationTitle(syncable.title) .navigationBarTitleDisplayMode(.inline) + .notice($notice) + .if(syncable.searchConfiguration != nil) { view in + view.searchable(text: $viewModel.searchQuery, + placement: .navigationBarDrawer(displayMode: .always), + prompt: syncable.searchConfiguration!.searchPrompt) + } } } @@ -59,17 +69,20 @@ private extension SyncableListSelectorView { value: "Any", comment: "Option to select no filter on a list selector view" ), + description: nil, isSelected: selectedItems.isEmpty, onSelection: { selectedItems.removeAll() onSelection([]) } ) + .renderedIf(viewModel.searchQuery.isEmpty) ForEach(items, id: \.self) { item in optionRow(text: syncable.displayName(for: item), + description: syncable.description(for: item), isSelected: selectedItems.contains(where: { $0 == syncable.filterItem(for: item) }), - onSelection: { toggleSelection(for: item) }) + onSelection: { toggleSelectionIfPossible(for: item) }) } InfiniteScrollIndicator(showContent: viewModel.shouldShowBottomActivityIndicator) @@ -82,7 +95,13 @@ private extension SyncableListSelectorView { .background(Color(.listBackground)) } - func toggleSelection(for item: Syncable.ModelType) { + func toggleSelectionIfPossible(for item: Syncable.ModelType) { + guard syncable.selectionEnabled(for: item) else { + if let message = syncable.selectionDisabledMessage { + notice = Notice(message: message, feedbackType: .error) + } + return + } let filterItem = syncable.filterItem(for: item) if let index = selectedItems.firstIndex(of: filterItem) { selectedItems.remove(at: index) @@ -92,9 +111,24 @@ private extension SyncableListSelectorView { onSelection(selectedItems) } - func optionRow(text: String, isSelected: Bool, onSelection: @escaping () -> Void) -> some View { + func optionRow(text: String, description: String?, isSelected: Bool, onSelection: @escaping () -> Void) -> some View { HStack { - Text(text) + VStack(alignment: .leading) { + if text.isEmpty, let placeholder = syncable.emptyItemTitlePlaceholder { + Text(placeholder) + .font(.body.weight(.medium)) + .foregroundStyle(Color.secondary) + } else { + Text(text) + .font(.body.weight(.medium)) + .foregroundStyle(Color.primary) + } + + if let description { + Text(description) + .footnoteStyle() + } + } Spacer() Image(systemName: "checkmark") .font(.body.weight(.medium)) @@ -110,8 +144,26 @@ private extension SyncableListSelectorView { var emptyStateView: some View { VStack { Spacer() - Text(syncable.emptyStateMessage) - .secondaryBodyStyle() + Image(.magnifyingGlassNotFound) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: emptyStateImageWidth * scale) + .padding(.bottom, viewPadding) + .renderedIf(viewModel.searchQuery.isNotEmpty) + + if let configuration = syncable.searchConfiguration, + viewModel.searchQuery.isNotEmpty { + Text(configuration.emptySearchTitle) + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(Color.primary) + Text(configuration.emptySearchDescription) + .font(.title3) + .foregroundStyle(Color.secondary) + } else { + Text(syncable.emptyStateMessage) + .secondaryBodyStyle() + } Spacer() } .multilineTextAlignment(.center) diff --git a/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorViewModel.swift b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorViewModel.swift index 3011a63e043..d19b416cf42 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/SyncableListSelectorViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import Combine import Yosemite import protocol Storage.StorageManagerType @@ -12,6 +13,8 @@ final class SyncableListSelectorViewModel: ObservableObj /// Tracks if the infinite scroll indicator should be displayed. @Published private(set) var shouldShowBottomActivityIndicator = false + @Published var searchQuery: String = "" + private let syncable: Syncable private let stores: StoresManager private let storage: StorageManagerType @@ -20,6 +23,12 @@ final class SyncableListSelectorViewModel: ObservableObj private let paginationTracker: PaginationTracker private let pageFirstIndex: Int = PaginationTracker.Defaults.pageFirstIndex + /// Stores the current search keyword for pagination + private var currentSearchKeyword: String = "" + + /// Cancellable for search query observation + private var searchCancellable: AnyCancellable? + /// ResultsController configured by the syncable private lazy var resultsController: ResultsController = { let predicate = syncable.createPredicate() @@ -41,6 +50,7 @@ final class SyncableListSelectorViewModel: ObservableObj configureResultsController() configurePaginationTracker() + configureSearchObserver() } /// Called when loading the first page of resources. @@ -59,6 +69,23 @@ final class SyncableListSelectorViewModel: ObservableObj paginationTracker.delegate = self } + /// Observes search query changes and triggers search with debouncing + private func configureSearchObserver() { + searchCancellable = $searchQuery + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .removeDuplicates() + .sink { [weak self] newQuery in + self?.handleSearchQueryChange(newQuery) + } + } + + /// Handles search query changes by resetting pagination and triggering new search + private func handleSearchQueryChange(_ query: String) { + syncState = .syncingFirstPage + currentSearchKeyword = query + paginationTracker.syncFirstPage() + } + /// Performs initial fetch from storage and updates results. private func configureResultsController() { resultsController.onDidChangeContent = { [weak self] in @@ -85,23 +112,41 @@ final class SyncableListSelectorViewModel: ObservableObj extension SyncableListSelectorViewModel: PaginationTrackerDelegate { func sync(pageNumber: Int, pageSize: Int, reason: String?, onCompletion: SyncCompletion?) { transitionToSyncingState() - let action = syncable.createSyncAction( - pageNumber: pageNumber, - pageSize: pageSize - ) { [weak self] result in - switch result { - case .success(let hasNextPage): - onCompletion?(.success(hasNextPage)) - - case .failure(let error): - DDLogError("⛔️ Error synchronizing: \(error)") - onCompletion?(.failure(error)) - } - self?.updateResults() + // Use search action if there's a search keyword, otherwise use regular sync action + let action: Action + if currentSearchKeyword.isEmpty { + action = syncable.createSyncAction( + pageNumber: pageNumber, + pageSize: pageSize + ) { [weak self] result in + self?.handleSyncResult(result, onCompletion: onCompletion) + } + } else { + action = syncable.createSearchAction( + keyword: currentSearchKeyword, + pageNumber: pageNumber, + pageSize: pageSize + ) { [weak self] result in + self?.handleSyncResult(result, onCompletion: onCompletion) + } } + stores.dispatch(action) } + + private func handleSyncResult(_ result: Result, onCompletion: SyncCompletion?) { + switch result { + case .success(let hasNextPage): + onCompletion?(.success(hasNextPage)) + + case .failure(let error): + DDLogError("⛔️ Error synchronizing: \(error)") + onCompletion?(.failure(error)) + } + + updateResults() + } } // MARK: State Machine diff --git a/WooCommerce/Classes/Bookings/BookingFilters/TeamMemberListSyncable.swift b/WooCommerce/Classes/Bookings/BookingFilters/TeamMemberListSyncable.swift index d115f4e3334..c0c741c2be0 100644 --- a/WooCommerce/Classes/Bookings/BookingFilters/TeamMemberListSyncable.swift +++ b/WooCommerce/Classes/Bookings/BookingFilters/TeamMemberListSyncable.swift @@ -9,9 +9,14 @@ struct TeamMemberListSyncable: ListSyncable { let siteID: Int64 - var title: String { Localization.title } + let title = Localization.title - var emptyStateMessage: String { Localization.noMembersFound } + let emptyItemTitlePlaceholder: String? = nil + let emptyStateMessage = Localization.noMembersFound + + let searchConfiguration: ListSearchConfiguration? = nil + + let selectionDisabledMessage: String? = nil // MARK: - ResultsController Configuration @@ -38,12 +43,22 @@ struct TeamMemberListSyncable: ListSyncable { ) } + /// Creates the action to search items with keyword + func createSearchAction(keyword: String, pageNumber: Int, pageSize: Int, completion: @escaping (Result) -> Void) -> Action { + fatalError("Searching is not supported") + } + // MARK: - Display Configuration func displayName(for item: BookingResource) -> String { item.name } + /// Returns the description for an item + func description(for item: BookingResource) -> String? { nil } + + func selectionEnabled(for item: BookingResource) -> Bool { true } + func filterItem(for item: BookingResource) -> BookingResource { item } diff --git a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift index 591dbee0b47..2c6e9c66ded 100644 --- a/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Filters/FilterListViewController.swift @@ -95,13 +95,15 @@ enum FilterListValueSelectorConfig { // Filter list selector for products case products(siteID: Int64) // Filter list selector for customer - case customer(siteID: Int64, source: FilterSource) + 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) // Filter list selector for booking date time case bookingDateTime + // Filter list selector for booking customers + case bookingCustomers(siteID: Int64) } /// Contains data for rendering a filter type row. @@ -362,19 +364,12 @@ private extension FilterListViewController { }() self.listSelector.present(controller, animated: true) - 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!") - } - }() + case .customer(let siteID): let selectedCustomerID = (selected.selectedValue as? CustomerFilter)?.id let controller: CustomerSelectorViewController = { return CustomerSelectorViewController( siteID: siteID, - configuration: configuration, + configuration: .configurationForOrderFilter, addressFormViewModel: nil, selectedCustomerID: selectedCustomerID, onCustomerSelected: { customer in @@ -419,10 +414,7 @@ private extension FilterListViewController { viewModel: viewModel, syncable: syncable, initialSelections: selectedProducts, - onSelection: { products in - let filters = products.map { product in - BookingProductFilter(productID: product.productID, name: product.name) - } + onSelection: { filters in let filterType = MultipleFilterSelection(items: filters) selectedValueAction(filterType) } @@ -441,6 +433,27 @@ private extension FilterListViewController { ) let hostingController = UIHostingController(rootView: dateTimeFilterView) listSelector.navigationController?.pushViewController(hostingController, animated: true) + + case .bookingCustomers(let siteID): + let selectedCustomers: [BookingCustomerFilter] = { + if let wrapper = selected.selectedValue as? MultipleFilterSelection { + return wrapper.items.compactMap { $0 as? BookingCustomerFilter } + } + return [] + }() + let syncable = CustomerListSyncable(siteID: siteID) + let viewModel = SyncableListSelectorViewModel(syncable: syncable) + let memberListSelectorView = SyncableListSelectorView( + viewModel: viewModel, + syncable: syncable, + initialSelections: selectedCustomers, + onSelection: { customers in + let filterType = MultipleFilterSelection(items: customers) + selectedValueAction(filterType) + } + ) + let hostingController = UIHostingController(rootView: memberListSelectorView) + listSelector.navigationController?.pushViewController(hostingController, animated: true) } } } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift index 163924cb5e9..0a493051b00 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Creation/CustomerSection/CustomerSearchUICommand.swift @@ -318,10 +318,11 @@ private extension CustomerSearchUICommand { filter: searchFilter, filterEmpty: .email) { result in switch result { - case .success: - onCompletion?(result.isSuccess) + case .success(let hasNextPage): + onCompletion?(hasNextPage) case .failure(let error): DDLogError("Customer Search Failure \(error)") + onCompletion?(false) } } } diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Filters/FilterOrderListViewModel.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Filters/FilterOrderListViewModel.swift index 35340034a70..7fb52aa0ada 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, source: .orders), + listSelectorConfig: .customer(siteID: siteID), selectedValue: filters.customer) case .salesChannel: let salesChannelOptions: [SalesChannelFilter] = [.any, .pointOfSale, .webCheckout, .wpAdmin]