diff --git a/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift b/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift index 709ceddeb71..29c7a03e4a5 100644 --- a/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift +++ b/Modules/Sources/NetworkingCore/Remote/OrdersRemote.swift @@ -458,7 +458,7 @@ extension OrdersRemote: POSOrdersRemoteProtocol { ParameterKeys.statusKey: Defaults.statusAny, ParameterKeys.usesGMTDates: true, ParameterKeys.fields: ParameterValues.fieldValues, - ParameterKeys.createdVia: "pos-rest-api" + ParameterKeys.createdVia: ParameterValues.posFilter ] let path = Constants.ordersPath @@ -469,10 +469,30 @@ extension OrdersRemote: POSOrdersRemoteProtocol { parameters: parameters, availableAsRESTRequest: true) let mapper = OrderListMapper(siteID: siteID) + let (orders, responseHeaders) = try await enqueueWithResponseHeaders(request, mapper: mapper) + return createPagedItems(items: orders, responseHeaders: responseHeaders, currentPageNumber: pageNumber) + } - let orders: [Order] = try await enqueue(request, mapper: mapper) - let hasMorePages = orders.count == pageSize - return PagedItems(items: orders, hasMorePages: hasMorePages, totalItems: nil) + public func searchPOSOrders(siteID: Int64, searchTerm: String, pageNumber: Int, pageSize: Int) async throws -> PagedItems { + let parameters: [String: Any] = [ + ParameterKeys.keyword: searchTerm, + ParameterKeys.page: String(pageNumber), + ParameterKeys.perPage: String(pageSize), + ParameterKeys.statusKey: Defaults.statusAny, + ParameterKeys.usesGMTDates: true, + ParameterKeys.fields: ParameterValues.fieldValues, + ParameterKeys.createdVia: ParameterValues.posFilter + ] + let path = Constants.ordersPath + let request = JetpackRequest(wooApiVersion: .mark3, + method: .get, + siteID: siteID, + path: path, + parameters: parameters, + availableAsRESTRequest: true) + let mapper = OrderListMapper(siteID: siteID) + let (orders, responseHeaders) = try await enqueueWithResponseHeaders(request, mapper: mapper) + return createPagedItems(items: orders, responseHeaders: responseHeaders, currentPageNumber: pageNumber) } } @@ -522,6 +542,7 @@ public extension OrdersRemote { "is_editable", "needs_payment", "needs_processing", "gift_cards", "created_via" ] static let dateModifiedField = "date_modified_gmt" + static let posFilter = "pos-rest-api" } enum NestedFieldKeys { diff --git a/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift b/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift index 60e7f8dff5e..3d301afa3ac 100644 --- a/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift +++ b/Modules/Sources/NetworkingCore/Remote/POSOrdersRemoteProtocol.swift @@ -18,4 +18,9 @@ public protocol POSOrdersRemoteProtocol { func loadPOSOrders(siteID: Int64, pageNumber: Int, pageSize: Int) async throws -> PagedItems + + func searchPOSOrders(siteID: Int64, + searchTerm: String, + pageNumber: Int, + pageSize: Int) async throws -> PagedItems } diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategy.swift index f59b1931f39..5641b32eadf 100644 --- a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategy.swift @@ -3,10 +3,21 @@ import struct NetworkingCore.PagedItems public protocol PointOfSaleOrderListFetchStrategy { func fetchOrders(pageNumber: Int) async throws -> PagedItems + var supportsCaching: Bool { get } + var showsLoadingWithItems: Bool { get } + var id: String { get } +} + +extension PointOfSaleOrderListFetchStrategy { + var id: String { + String(describing: type(of: self)) + } } struct PointOfSaleDefaultOrderListFetchStrategy: PointOfSaleOrderListFetchStrategy { private let orderListService: PointOfSaleOrderListServiceProtocol + let supportsCaching: Bool = true + var showsLoadingWithItems: Bool = true init(orderListService: PointOfSaleOrderListServiceProtocol) { self.orderListService = orderListService @@ -16,3 +27,20 @@ struct PointOfSaleDefaultOrderListFetchStrategy: PointOfSaleOrderListFetchStrate try await orderListService.providePointOfSaleOrders(pageNumber: pageNumber) } } + +struct PointOfSaleSearchOrderListFetchStrategy: PointOfSaleOrderListFetchStrategy { + private let orderListService: PointOfSaleOrderListServiceProtocol + private let searchTerm: String + + var supportsCaching: Bool = false + var showsLoadingWithItems = false + + init(orderListService: PointOfSaleOrderListServiceProtocol, searchTerm: String) { + self.orderListService = orderListService + self.searchTerm = searchTerm + } + + func fetchOrders(pageNumber: Int) async throws -> PagedItems { + try await orderListService.searchPointOfSaleOrders(searchTerm: searchTerm, pageNumber: pageNumber) + } +} diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategyFactory.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategyFactory.swift index a350d54ed1b..eebaf996b6b 100644 --- a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategyFactory.swift +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListFetchStrategyFactory.swift @@ -5,6 +5,7 @@ import class WooFoundationCore.CurrencyFormatter public protocol PointOfSaleOrderListFetchStrategyFactoryProtocol { func defaultStrategy() -> PointOfSaleOrderListFetchStrategy + func searchStrategy(searchTerm: String) -> PointOfSaleOrderListFetchStrategy } public final class PointOfSaleOrderListFetchStrategyFactory: PointOfSaleOrderListFetchStrategyFactoryProtocol { @@ -30,4 +31,15 @@ public final class PointOfSaleOrderListFetchStrategyFactory: PointOfSaleOrderLis ) ) } + + public func searchStrategy(searchTerm: String) -> PointOfSaleOrderListFetchStrategy { + PointOfSaleSearchOrderListFetchStrategy( + orderListService: PointOfSaleOrderListService( + siteID: siteID, + ordersRemote: ordersRemote, + currencyFormatter: currencyFormatter + ), + searchTerm: searchTerm + ) + } } diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListService.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListService.swift index 22748a5600e..d266e7ce410 100644 --- a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListService.swift +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListService.swift @@ -44,4 +44,30 @@ public final class PointOfSaleOrderListService: PointOfSaleOrderListServiceProto throw PointOfSaleOrderListServiceError.requestFailed } } + + public func searchPointOfSaleOrders(searchTerm: String, pageNumber: Int = 1) async throws -> PagedItems { + do { + let pagedOrders = try await ordersRemote.searchPOSOrders( + siteID: siteID, + searchTerm: searchTerm, + pageNumber: pageNumber, + pageSize: 25 + ) + + if pageNumber != 1 && pagedOrders.items.count == 0 { + return .init(items: [], hasMorePages: false, totalItems: 0) + } + + // Convert Order objects to POSOrder objects + let posOrders = pagedOrders.items.map { mapper.map(order: $0) } + + return .init(items: posOrders, + hasMorePages: pagedOrders.hasMorePages, + totalItems: pagedOrders.totalItems) + } catch AFError.explicitlyCancelled { + throw PointOfSaleOrderListServiceError.requestCancelled + } catch { + throw PointOfSaleOrderListServiceError.requestFailed + } + } } diff --git a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListServiceProtocol.swift b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListServiceProtocol.swift index c80cfe9a0e3..110a8e4f6f9 100644 --- a/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListServiceProtocol.swift +++ b/Modules/Sources/Yosemite/PointOfSale/OrderList/PointOfSaleOrderListServiceProtocol.swift @@ -8,4 +8,5 @@ public enum PointOfSaleOrderListServiceError: Error, Equatable { public protocol PointOfSaleOrderListServiceProtocol { func providePointOfSaleOrders(pageNumber: Int) async throws -> PagedItems + func searchPointOfSaleOrders(searchTerm: String, pageNumber: Int) async throws -> PagedItems } diff --git a/Modules/Tests/NetworkingTests/Remote/OrdersRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/OrdersRemoteTests.swift index c0463a3547a..8ffe84f3bd0 100644 --- a/Modules/Tests/NetworkingTests/Remote/OrdersRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/OrdersRemoteTests.swift @@ -916,6 +916,43 @@ final class OrdersRemoteTests: XCTestCase { "value": cashPaymentChangeDueAmount]] assertEqual(received, expected) } + + func test_searchPOSOrders_sends_correct_parameters() async throws { + // Given + let remote = OrdersRemote(network: network) + let searchTerm = "test search" + let pageNumber = 2 + let pageSize = 10 + + // When + _ = try? await remote.searchPOSOrders(siteID: sampleSiteID, searchTerm: searchTerm, pageNumber: pageNumber, pageSize: pageSize) + + // Then + let request = try XCTUnwrap(network.requestsForResponseData.last as? JetpackRequest) + let parameters = request.parameters + + XCTAssertEqual(parameters["search"] as? String, searchTerm) + XCTAssertEqual(parameters["page"] as? String, String(pageNumber)) + XCTAssertEqual(parameters["per_page"] as? String, String(pageSize)) + XCTAssertEqual(parameters["status"] as? String, "any") + XCTAssertEqual(parameters["created_via"] as? String, "pos-rest-api") + XCTAssertEqual(parameters["dates_are_gmt"] as? Bool, true) + XCTAssertNotNil(parameters["_fields"] as? String) + } + + func test_searchPOSOrders_properly_relays_networking_error() async throws { + // Given + let remote = OrdersRemote(network: network) + + do { + // When + _ = try await remote.searchPOSOrders(siteID: sampleSiteID, searchTerm: "test", pageNumber: 1, pageSize: 25) + XCTFail("Expected error to be thrown") + } catch { + // Then + XCTAssertEqual(error as? NetworkError, .notFound(response: nil)) + } + } } private extension OrdersRemoteTests { diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift index 64927dddf4b..9a1cdaba963 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSOrdersRemote.swift @@ -53,4 +53,23 @@ final class MockPOSOrdersRemote: POSOrdersRemoteProtocol { throw error } } + + var mockSearchPagedOrdersResult: Result, Error> = .success(PagedItems(items: [], hasMorePages: false, totalItems: 0)) + var searchPOSOrdersCalled = false + var spySearchTerm: String? + + func searchPOSOrders(siteID: Int64, searchTerm: String, pageNumber: Int, pageSize: Int) async throws -> PagedItems { + searchPOSOrdersCalled = true + spySiteID = siteID + spySearchTerm = searchTerm + spyPageNumber = pageNumber + spyPageSize = pageSize + + switch mockSearchPagedOrdersResult { + case .success(let pagedOrders): + return pagedOrders + case .failure(let error): + throw error + } + } } diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderListController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderListController.swift index 49cb6718f0d..416a5dff61a 100644 --- a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderListController.swift +++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderListController.swift @@ -18,17 +18,31 @@ protocol PointOfSaleOrderListControllerProtocol { func selectOrder(_ order: POSOrder?) } -@Observable final class PointOfSaleOrderListController: PointOfSaleOrderListControllerProtocol { +protocol PointOfSaleSearchingOrderListControllerProtocol: PointOfSaleOrderListControllerProtocol { + func searchOrders(searchTerm: String) async + func clearSearchOrders() +} + +@Observable final class PointOfSaleOrderListController: PointOfSaleSearchingOrderListControllerProtocol { var ordersViewState: POSOrderListState - private let paginationTracker: AsyncPaginationTracker + private var strategyPaginationTracker: [String: AsyncPaginationTracker] = [:] private var fetchStrategy: PointOfSaleOrderListFetchStrategy private var cachedOrders: [POSOrder] = [] private(set) var selectedOrder: POSOrder? + private let orderListFetchStrategyFactory: PointOfSaleOrderListFetchStrategyFactoryProtocol + private var paginationTracker: AsyncPaginationTracker { + if let existing = strategyPaginationTracker[fetchStrategy.id] { + return existing + } + let tracker = AsyncPaginationTracker() + strategyPaginationTracker[fetchStrategy.id] = tracker + return tracker + } init(orderListFetchStrategyFactory: PointOfSaleOrderListFetchStrategyFactoryProtocol, initialState: POSOrderListState = .loading([])) { self.ordersViewState = initialState - self.paginationTracker = .init() + self.orderListFetchStrategyFactory = orderListFetchStrategyFactory self.fetchStrategy = orderListFetchStrategyFactory.defaultStrategy() } @@ -50,7 +64,7 @@ protocol PointOfSaleOrderListControllerProtocol { return } let currentOrders = ordersViewState.orders - ordersViewState = .loading(currentOrders) + ordersViewState = fetchStrategy.showsLoadingWithItems ? .loading(currentOrders) : .loading([]) do { _ = try await paginationTracker.ensureNextPageIsSynced { [weak self] pageNumber in guard let self else { return true } @@ -83,6 +97,11 @@ protocol PointOfSaleOrderListControllerProtocol { } private func setLoadingState() { + if !fetchStrategy.showsLoadingWithItems { + ordersViewState = .loading([]) + return + } + let orders = ordersViewState.orders let isInitialState = ordersViewState.isLoading && orders.isEmpty if !isInitialState { @@ -103,7 +122,7 @@ protocol PointOfSaleOrderListControllerProtocol { ordersViewState = allOrders.isEmpty ? .empty : .loaded(allOrders, hasMoreItems: pagedOrders.hasMorePages) - if pageNumber == 1 && !appendToExistingOrders { + if fetchStrategy.supportsCaching { cachedOrders = allOrders } @@ -115,6 +134,10 @@ protocol PointOfSaleOrderListControllerProtocol { @MainActor private func setCachedData() { + guard fetchStrategy.supportsCaching else { + return + } + guard !ordersViewState.orders.isEmpty || !cachedOrders.isEmpty else { return } @@ -126,4 +149,24 @@ protocol PointOfSaleOrderListControllerProtocol { func selectOrder(_ order: POSOrder?) { selectedOrder = order } + + @MainActor + func searchOrders(searchTerm: String) async { + fetchStrategy = orderListFetchStrategyFactory.searchStrategy(searchTerm: searchTerm) + ordersViewState = .loading([]) + await loadFirstPage() + } + + @MainActor + func clearSearchOrders() { + fetchStrategy = orderListFetchStrategyFactory.defaultStrategy() + if cachedOrders.isNotEmpty { + ordersViewState = .loaded(cachedOrders, hasMoreItems: true) + } else { + ordersViewState = .loading([]) + Task { + await loadFirstPage() + } + } + } } diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift b/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift index ffc0b28d6e4..43d29811d51 100644 --- a/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift +++ b/WooCommerce/Classes/POS/Models/PointOfSaleOrderListModel.swift @@ -2,9 +2,9 @@ import Foundation import Observation @Observable final class PointOfSaleOrderListModel { - let ordersController: PointOfSaleOrderListControllerProtocol + let ordersController: PointOfSaleSearchingOrderListControllerProtocol - init(ordersController: PointOfSaleOrderListControllerProtocol) { + init(ordersController: PointOfSaleSearchingOrderListControllerProtocol) { self.ordersController = ordersController } } diff --git a/WooCommerce/Classes/POS/Presentation/Item Search/POSProductSearchable.swift b/WooCommerce/Classes/POS/Presentation/Item Search/POSProductSearchable.swift index 9724f309885..a74053265dd 100644 --- a/WooCommerce/Classes/POS/Presentation/Item Search/POSProductSearchable.swift +++ b/WooCommerce/Classes/POS/Presentation/Item Search/POSProductSearchable.swift @@ -4,7 +4,7 @@ import protocol Yosemite.POSSearchHistoryProviding import enum Yosemite.POSItem final class POSProductSearchable: POSSearchable { - let itemListType: ItemListType + private let itemListType: ItemListType private let itemsController: PointOfSaleSearchingItemsControllerProtocol private let searchHistoryProvider: POSSearchHistoryProviding @@ -20,6 +20,10 @@ final class POSProductSearchable: POSSearchable { searchHistoryProvider.searchHistory(for: itemListType.itemType) } + var searchFieldPlaceholder: String { + itemListType.itemType.searchFieldLabel + } + func performSearch(term: String) async { await itemsController.searchItems(searchTerm: term, baseItem: .root) } diff --git a/WooCommerce/Classes/POS/Presentation/Item Search/POSSearchView.swift b/WooCommerce/Classes/POS/Presentation/Item Search/POSSearchView.swift index 3ca46f58015..4eacf355bf0 100644 --- a/WooCommerce/Classes/POS/Presentation/Item Search/POSSearchView.swift +++ b/WooCommerce/Classes/POS/Presentation/Item Search/POSSearchView.swift @@ -4,9 +4,7 @@ import enum Yosemite.POSItem /// Protocol defining search capabilities for POS items protocol POSSearchable { - /// The type of item lists being searched - var itemListType: ItemListType { get } - + var searchFieldPlaceholder: String { get } /// Recent search history for the current item type var searchHistory: [String] { get } @@ -47,7 +45,7 @@ struct POSSearchField: View { })) TextField(text: $searchTerm) { - Text(searchable.itemListType.itemType.searchFieldLabel) + Text(searchable.searchFieldPlaceholder) } .textFieldStyle(POSSearchTextFieldStyle(focused: isSearchFieldFocused, searchTerm: $searchTerm)) @@ -107,13 +105,16 @@ struct POSSearchContentView: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize private let searchable: any POSSearchable + private let itemListType: ItemListType @Binding private var searchTerm: String private let content: (Bool) -> Content init(searchable: any POSSearchable, + itemListType: ItemListType, searchTerm: Binding, @ViewBuilder content: @escaping (Bool) -> Content) { self.searchable = searchable + self.itemListType = itemListType self._searchTerm = searchTerm self.content = content } @@ -134,15 +135,15 @@ struct POSSearchContentView: View { onSearchSelected: { selectedSearchTerm in searchTerm = selectedSearchTerm ServiceLocator.analytics.track( - event: .PointOfSale.preSearchRecentTermTapped(itemListType: searchable.itemListType)) + event: .PointOfSale.preSearchRecentTermTapped(itemListType: itemListType)) }, - itemListType: searchable.itemListType + itemListType: itemListType ) } } // MARK: - Localization -private extension POSItemType { +extension POSItemType { var searchFieldLabel: String { switch self { case .product: diff --git a/WooCommerce/Classes/POS/Presentation/ItemListView.swift b/WooCommerce/Classes/POS/Presentation/ItemListView.swift index 914183c2c7e..697bbc93098 100644 --- a/WooCommerce/Classes/POS/Presentation/ItemListView.swift +++ b/WooCommerce/Classes/POS/Presentation/ItemListView.swift @@ -128,6 +128,7 @@ struct ItemListView: View { searchable: POSProductSearchable(itemListType: selectedItemListType, itemsController: searchItemsController, searchHistoryProvider: posModel.searchHistoryService), + itemListType: selectedItemListType, searchTerm: $searchTerm ) { _ in itemListContent(selectedItemListType) diff --git a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderListView.swift b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderListView.swift index 7877c180b6d..d02281fb979 100644 --- a/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderListView.swift +++ b/WooCommerce/Classes/POS/Presentation/Orders/PointOfSaleOrderListView.swift @@ -8,6 +8,10 @@ struct PointOfSaleOrderListView: View { @Environment(PointOfSaleOrderListModel.self) private var orderListModel @StateObject private var infiniteScrollTriggerDeterminer = ThresholdInfiniteScrollTriggerDeterminer() + @State private var isSearching: Bool = false + @State private var searchTerm: String = "" + @Namespace private var searchTransition + private var ordersViewState: POSOrderListState { orderListModel.ordersController.ordersViewState } @@ -15,15 +19,50 @@ struct PointOfSaleOrderListView: View { var body: some View { VStack(spacing: 0) { POSPageHeaderView( - title: Localization.ordersTitle, - isLoading: { - if case .loading(let orders) = ordersViewState { - return !orders.isEmpty + items: isSearching ? [] : [.init( + title: Localization.ordersTitle, + subtitle: nil, + isSelected: true, + isLoading: isSearching ? false : { + if case .loading(let orders) = ordersViewState { + return !orders.isEmpty + } + return false + }() + )], + backButtonConfiguration: isSearching ? nil : .init(state: .enabled, action: onClose, buttonIcon: "xmark"), + trailingContent: { + if !isSearching { + POSPageHeaderActionButton( + systemName: "magnifyingglass", + backgroundColor: .posSurface, + imageColor: .posOnSurface + ) { + setSearch(true) + } + .matchedGeometryEffect(id: Constants.searchControlID, in: searchTransition) + .transition(.opacity.combined(with: .scale)) + } + + if isSearching { + POSSearchField( + searchTerm: $searchTerm, + searchable: POSOrderSearchable(ordersController: orderListModel.ordersController), + onBack: { + setSearch(false) + } + ) + .matchedGeometryEffect(id: Constants.searchControlID, in: searchTransition) + .transition(.opacity.combined(with: .move(edge: .leading))) + .onChange(of: searchTerm) { _, newValue in + if newValue.isEmpty { + orderListModel.ordersController.clearSearchOrders() + } + } } - return false - }(), - backButtonConfiguration: .init(state: .enabled, action: onClose, buttonIcon: "xmark") + } ) + .animation(.easeInOut(duration: Constants.animationDuration), value: isSearching) InfiniteScrollView( triggerDeterminer: infiniteScrollTriggerDeterminer, @@ -276,9 +315,52 @@ struct PointOfSaleOrderBadgeView: View { } } +// MARK: - Search + +private extension PointOfSaleOrderListView { + func setSearch(_ isSearchingValue: Bool) { + if isSearchingValue { + isSearching = true + } else { + searchTerm = "" + isSearching = false + // Clear search results and return to default orders + orderListModel.ordersController.clearSearchOrders() + } + } +} + +final class POSOrderSearchable: POSSearchable { + private let ordersController: PointOfSaleSearchingOrderListControllerProtocol + + init(ordersController: PointOfSaleSearchingOrderListControllerProtocol) { + self.ordersController = ordersController + } + + var searchFieldPlaceholder: String { + Localization.searchFieldPlaceholder + } + + var searchHistory: [String] { + [] + } + + func performSearch(term: String) async { + await ordersController.searchOrders(searchTerm: term) + } + + func clearSearchResults() { + ordersController.clearSearchOrders() + } +} + +// MARK: - Constants + private enum Constants { static let orderCardMinHeight: CGFloat = 90 static let maximumOrderCardHeight: CGFloat = Constants.orderCardMinHeight * 2 + static let animationDuration: CGFloat = 0.2 + static let searchControlID = "searchControl" } private enum Localization { @@ -286,6 +368,12 @@ private enum Localization { "pos.orderListView.ordersTitle", value: "Orders", comment: "Title at the header for the Orders view.") + + static let searchFieldPlaceholder = NSLocalizedString( + "pos.orderListView.searchFieldPlaceholder", + value: "Search orders", + comment: "Placeholder for a search field in the Orders view." + ) } #if DEBUG diff --git a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift index 2c3decd0d40..093317f8b7c 100644 --- a/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift +++ b/WooCommerce/Classes/POS/Presentation/PointOfSaleEntryPointView.swift @@ -15,7 +15,7 @@ struct PointOfSaleEntryPointView: View { private let purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol private let couponsController: PointOfSaleCouponsControllerProtocol private let couponsSearchController: PointOfSaleSearchingItemsControllerProtocol - private let ordersController: PointOfSaleOrderListControllerProtocol + private let ordersController: PointOfSaleSearchingOrderListControllerProtocol private let cardPresentPaymentService: CardPresentPaymentFacade private let orderController: PointOfSaleOrderControllerProtocol private let settingsController: PointOfSaleSettingsControllerProtocol @@ -28,7 +28,7 @@ struct PointOfSaleEntryPointView: View { purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol, couponsController: PointOfSaleCouponsControllerProtocol, couponsSearchController: PointOfSaleSearchingItemsControllerProtocol, - ordersController: PointOfSaleOrderListControllerProtocol, + ordersController: PointOfSaleSearchingOrderListControllerProtocol, onPointOfSaleModeActiveStateChange: @escaping ((Bool) -> Void), cardPresentPaymentService: CardPresentPaymentFacade, orderController: PointOfSaleOrderControllerProtocol, diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderActionButton.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderActionButton.swift index 64636aba5d8..d94fb8d449a 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderActionButton.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderActionButton.swift @@ -2,7 +2,10 @@ import SwiftUI struct POSPageHeaderActionButton: View { let systemName: String + var backgroundColor: Color = .posSurfaceContainerLow + var imageColor: Color = .posOnSurface let action: () -> Void + @ScaledMetric private var scaledButtonSize: CGFloat = POSHeaderLayoutConstants.minHeight private var constrainedButtonSize: CGFloat { max(POSHeaderLayoutConstants.minHeight, min(scaledButtonSize, POSHeaderLayoutConstants.minHeight * 1.2)) @@ -11,11 +14,11 @@ struct POSPageHeaderActionButton: View { var body: some View { Button(action: action) { Circle() - .foregroundColor(.posSurfaceContainerLow) + .foregroundColor(backgroundColor) .overlay { Image(systemName: systemName) .font(.posButtonSymbolSmall) - .foregroundColor(.posOnSurface) + .foregroundColor(imageColor) .dynamicTypeSize(...POSHeaderLayoutConstants.maximumDynamicTypeSize) } .frame(width: constrainedButtonSize, height: constrainedButtonSize) diff --git a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift index 2b7b8043cd2..c8bbbabf990 100644 --- a/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift +++ b/WooCommerce/Classes/POS/Presentation/Reusable Views/POSPageHeaderView.swift @@ -38,11 +38,12 @@ struct POSPageHeaderItem: Identifiable { /// A header view for POS pages. /// Design ref: 1qcjzXitBHU7xPnpCOWnNM-fi-450_24951 -struct POSPageHeaderView: View { +struct POSPageHeaderView: View { private let items: [POSPageHeaderItem] private let backButtonConfiguration: POSPageHeaderBackButtonConfiguration? - private let trailingContent: TrailingContent? - private let bottomContent: BottomContent? + private let leadingContent: LeadingContent + private let trailingContent: TrailingContent + private let bottomContent: BottomContent private var hStackAlignment: VerticalAlignment { items.first?.subtitle == nil ? .center: .firstTextBaseline @@ -57,11 +58,13 @@ struct POSPageHeaderView: View { subtitle: String? = nil, isLoading: Bool = false, backButtonConfiguration: POSPageHeaderBackButtonConfiguration? = nil, + @ViewBuilder leadingContent: () -> LeadingContent = { EmptyView() }, @ViewBuilder trailingContent: () -> TrailingContent = { EmptyView() }, @ViewBuilder bottomContent: () -> BottomContent = { EmptyView() } ) { self.items = [.init(title: title, subtitle: subtitle, isSelected: true, isLoading: isLoading)] self.backButtonConfiguration = backButtonConfiguration + self.leadingContent = leadingContent() self.trailingContent = trailingContent() self.bottomContent = bottomContent() } @@ -69,71 +72,75 @@ struct POSPageHeaderView: View { init( items: [POSPageHeaderItem], backButtonConfiguration: POSPageHeaderBackButtonConfiguration? = nil, + @ViewBuilder leadingContent: () -> LeadingContent = { EmptyView() }, @ViewBuilder trailingContent: () -> TrailingContent = { EmptyView() }, @ViewBuilder bottomContent: () -> BottomContent = { EmptyView() } ) { self.items = items self.backButtonConfiguration = backButtonConfiguration + self.leadingContent = leadingContent() self.trailingContent = trailingContent() self.bottomContent = bottomContent() } var body: some View { HStack(alignment: hStackAlignment, spacing: Constants.horizontalSpacing) { + leadingContent + if showsBackButton { backButton } - HStack(alignment: hStackAlignment, spacing: POSSpacing.large) { - ForEach(0.. PointOfSaleOrderListFetchStrategy { MockPointOfSaleOrderListFetchStrategy(orderService: orderService) } + + func searchStrategy(searchTerm: String) -> PointOfSaleOrderListFetchStrategy { + MockPointOfSaleOrderListSearchFetchStrategy(orderService: orderService, searchTerm: searchTerm) + } } private struct MockPointOfSaleOrderListFetchStrategy: PointOfSaleOrderListFetchStrategy { let orderService: PointOfSaleOrderListServiceProtocol + var supportsCaching: Bool { true } + var showsLoadingWithItems: Bool { true } + var id: String = "default" + func fetchOrders(pageNumber: Int) async throws -> PagedItems { try await orderService.providePointOfSaleOrders(pageNumber: pageNumber) } } + +private struct MockPointOfSaleOrderListSearchFetchStrategy: PointOfSaleOrderListFetchStrategy { + let orderService: PointOfSaleOrderListServiceProtocol + let searchTerm: String + + var supportsCaching: Bool { false } + var showsLoadingWithItems: Bool { false } + var id: String = "search" + + func fetchOrders(pageNumber: Int) async throws -> PagedItems { + try await orderService.searchPointOfSaleOrders(searchTerm: searchTerm, pageNumber: pageNumber) + } +} diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListService.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListService.swift index 153bbb548a0..977550c2366 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListService.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderListService.swift @@ -7,6 +7,7 @@ import WooFoundation final class MockPointOfSaleOrderListService: PointOfSaleOrderListServiceProtocol { var orderPages: [[POSOrder]] = [] + var searchOrderPages: [[POSOrder]] = [] var errorToThrow: Error? var shouldReturnZeroOrders = false var shouldSimulateTwoPages = false @@ -15,6 +16,8 @@ final class MockPointOfSaleOrderListService: PointOfSaleOrderListServiceProtocol var spyLastRequestedPageNumber: Int? var spyCallCount = 0 + var spyLastSearchTerm: String? + var lastSearchTerm: String? func providePointOfSaleOrders(pageNumber: Int) async throws -> PagedItems { spyLastRequestedPageNumber = pageNumber @@ -60,6 +63,48 @@ final class MockPointOfSaleOrderListService: PointOfSaleOrderListServiceProtocol totalItems: 2 ) } + + func searchPointOfSaleOrders(searchTerm: String, pageNumber: Int) async throws -> PagedItems { + spyLastSearchTerm = searchTerm + lastSearchTerm = searchTerm + spyLastRequestedPageNumber = pageNumber + spyCallCount += 1 + + if shouldThrowError { + throw PointOfSaleOrderListServiceError.requestFailed + } + + if let errorToThrow { + throw errorToThrow + } + + if shouldReturnZeroOrders { + return .init(items: [], hasMorePages: false, totalItems: 0) + } + + // Use searchOrderPages if available, otherwise filter based on search term + if !searchOrderPages.isEmpty { + return .init( + items: (searchOrderPages[safe: pageNumber - 1] ?? []), + hasMorePages: searchOrderPages.count > pageNumber, + totalItems: searchOrderPages.flatMap { $0 }.count + ) + } + + // For testing purposes, return filtered results based on search term + let allOrders = MockPointOfSaleOrderListService.makeInitialOrders() + let filteredOrders = allOrders.filter { order in + order.number.contains(searchTerm) || + order.customerEmail?.contains(searchTerm) == true || + order.lineItems.contains { $0.name.lowercased().contains(searchTerm.lowercased()) } + } + + return .init( + items: filteredOrders, + hasMorePages: false, + totalItems: filteredOrders.count + ) + } } extension MockPointOfSaleOrderListService { @@ -206,4 +251,38 @@ extension MockPointOfSaleOrderListService { return [order3, order4] } + + static func makeSearchOrders() -> [POSOrder] { + let baseDate = Date(timeIntervalSince1970: 1672531200) // Fixed date: Jan 1, 2023 + + let searchOrder = POSOrder( + id: 2001, + number: "2001", + dateCreated: baseDate.addingTimeInterval(14400), + status: .completed, + formattedTotal: "$18.50", + formattedSubtotal: "$18.50", + customerEmail: "search@example.com", + paymentMethodID: "cod", + paymentMethodTitle: "Cash", + lineItems: [ + POSOrderItem( + itemID: 7, + name: "Test Product", + quantity: 1, + formattedPrice: "src", + formattedTotal: "$18.50", + imageSrc: "$18.50", + attributes: [] + ) + ], + refunds: [], + formattedTotalTax: "$0.00", + formattedDiscountTotal: nil, + formattedPaymentTotal: "$18.50", + formattedNetAmount: nil + ) + + return [searchOrder] + } }