diff --git a/Modules/Sources/Networking/Remote/BookingsRemote.swift b/Modules/Sources/Networking/Remote/BookingsRemote.swift index 8fe00809a05..4820d1e254f 100644 --- a/Modules/Sources/Networking/Remote/BookingsRemote.swift +++ b/Modules/Sources/Networking/Remote/BookingsRemote.swift @@ -10,7 +10,8 @@ public protocol BookingsRemoteProtocol { pageNumber: Int, pageSize: Int, startDateBefore: String?, - startDateAfter: String?) async throws -> [Booking] + startDateAfter: String?, + searchQuery: String?) async throws -> [Booking] } /// Booking: Remote Endpoints @@ -27,12 +28,14 @@ public final class BookingsRemote: Remote, BookingsRemoteProtocol { /// - pageSize: Number of bookings to be retrieved per page. /// - startDateBefore: Filter bookings with start date before this timestamp. /// - startDateAfter: Filter bookings with start date after this timestamp. + /// - searchQuery: Search query to filter bookings. /// public func loadAllBookings(for siteID: Int64, pageNumber: Int = Default.pageNumber, pageSize: Int = Default.pageSize, startDateBefore: String? = nil, - startDateAfter: String? = nil) async throws -> [Booking] { + startDateAfter: String? = nil, + searchQuery: String? = nil) async throws -> [Booking] { var parameters = [ ParameterKey.page: String(pageNumber), ParameterKey.perPage: String(pageSize) @@ -46,6 +49,10 @@ public final class BookingsRemote: Remote, BookingsRemoteProtocol { parameters[ParameterKey.startDateAfter] = startDateAfter } + if let searchQuery = searchQuery, !searchQuery.isEmpty { + parameters[ParameterKey.search] = searchQuery + } + let path = Path.bookings let request = JetpackRequest(wooApiVersion: .wcBookings, method: .get, siteID: siteID, path: path, parameters: parameters, availableAsRESTRequest: true) let mapper = ListMapper(siteID: siteID) @@ -71,5 +78,6 @@ public extension BookingsRemote { static let perPage: String = "per_page" static let startDateBefore: String = "start_date_before" static let startDateAfter: String = "start_date_after" + static let search: String = "search" } } diff --git a/Modules/Sources/Yosemite/Actions/BookingAction.swift b/Modules/Sources/Yosemite/Actions/BookingAction.swift index 69b84cbe04e..b2d6087dfeb 100644 --- a/Modules/Sources/Yosemite/Actions/BookingAction.swift +++ b/Modules/Sources/Yosemite/Actions/BookingAction.swift @@ -23,4 +23,16 @@ public enum BookingAction: Action { /// case checkIfStoreHasBookings(siteID: Int64, onCompletion: (Result) -> Void) + + /// Searches for bookings matching the specified criteria and search query. + /// + /// - Parameter onCompletion: called when search completes, returns an error or an array of bookings. + /// + case searchBookings(siteID: Int64, + searchQuery: String, + pageNumber: Int, + pageSize: Int = BookingsRemote.Default.pageSize, + startDateBefore: String? = nil, + startDateAfter: String? = nil, + onCompletion: (Result<[Booking], Error>) -> Void) } diff --git a/Modules/Sources/Yosemite/Stores/BookingStore.swift b/Modules/Sources/Yosemite/Stores/BookingStore.swift index 7c9869ea04e..0708c64702d 100644 --- a/Modules/Sources/Yosemite/Stores/BookingStore.swift +++ b/Modules/Sources/Yosemite/Stores/BookingStore.swift @@ -45,6 +45,14 @@ public class BookingStore: Store { onCompletion: onCompletion) case let .checkIfStoreHasBookings(siteID, onCompletion): checkIfStoreHasBookings(siteID: siteID, onCompletion: onCompletion) + case let .searchBookings(siteID, searchQuery, pageNumber, pageSize, startDateBefore, startDateAfter, onCompletion): + searchBookings(siteID: siteID, + searchQuery: searchQuery, + pageNumber: pageNumber, + pageSize: pageSize, + startDateBefore: startDateBefore, + startDateAfter: startDateAfter, + onCompletion: onCompletion) } } } @@ -69,7 +77,8 @@ private extension BookingStore { pageNumber: pageNumber, pageSize: pageSize, startDateBefore: startDateBefore, - startDateAfter: startDateAfter) + startDateAfter: startDateAfter, + searchQuery: nil) await upsertStoredBookingsInBackground( readOnlyBookings: bookings, siteID: siteID, @@ -101,7 +110,8 @@ private extension BookingStore { pageNumber: 1, pageSize: 1, startDateBefore: nil, - startDateAfter: nil) + startDateAfter: nil, + searchQuery: nil) let hasRemoteBookings = !bookings.isEmpty onCompletion(.success(hasRemoteBookings)) } catch { @@ -109,6 +119,31 @@ private extension BookingStore { } } } + + /// Searches for bookings matching the specified criteria and search query. + /// Returns results immediately without saving to storage. + /// + func searchBookings(siteID: Int64, + searchQuery: String, + pageNumber: Int, + pageSize: Int, + startDateBefore: String?, + startDateAfter: String?, + onCompletion: @escaping (Result<[Booking], Error>) -> Void) { + Task { @MainActor in + do { + let bookings = try await remote.loadAllBookings(for: siteID, + pageNumber: pageNumber, + pageSize: pageSize, + startDateBefore: startDateBefore, + startDateAfter: startDateAfter, + searchQuery: searchQuery) + onCompletion(.success(bookings)) + } catch { + onCompletion(.failure(error)) + } + } + } } diff --git a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift index c266deebeb8..e93715f9ec2 100644 --- a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift @@ -43,6 +43,7 @@ struct BookingsRemoteTests { let remote = BookingsRemote(network: network) let startDateBefore = "2024-12-31T23:59:59" let startDateAfter = "2024-01-01T00:00:00" + let searchQuery = "test search" network.simulateResponse(requestUrlSuffix: "bookings", filename: "booking-list") // When @@ -50,7 +51,8 @@ struct BookingsRemoteTests { pageNumber: 2, pageSize: 50, startDateBefore: startDateBefore, - startDateAfter: startDateAfter) + startDateAfter: startDateAfter, + searchQuery: searchQuery) // Then let request = try #require(network.requestsForResponseData.first as? JetpackRequest) @@ -60,9 +62,10 @@ struct BookingsRemoteTests { #expect((parameters["per_page"] as? String) == "50") #expect((parameters["start_date_before"] as? String) == startDateBefore) #expect((parameters["start_date_after"] as? String) == startDateAfter) + #expect((parameters["search"] as? String) == searchQuery) } - @Test func test_loadAllBookings_omits_nil_date_parameters() async throws { + @Test func test_loadAllBookings_omits_nil_parameters() async throws { // Given let remote = BookingsRemote(network: network) network.simulateResponse(requestUrlSuffix: "bookings", filename: "booking-list") @@ -70,7 +73,8 @@ struct BookingsRemoteTests { // When _ = try await remote.loadAllBookings(for: sampleSiteID, startDateBefore: nil, - startDateAfter: nil) + startDateAfter: nil, + searchQuery: nil) // Then let request = try #require(network.requestsForResponseData.first as? JetpackRequest) @@ -78,6 +82,7 @@ struct BookingsRemoteTests { #expect(parameters["start_date_before"] == nil) #expect(parameters["start_date_after"] == nil) + #expect(parameters["s"] == nil) #expect(parameters["page"] != nil) #expect(parameters["per_page"] != nil) } diff --git a/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift b/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift index 19004ed5342..a435c581556 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockBookingsRemote.swift @@ -14,7 +14,8 @@ final class MockBookingsRemote: BookingsRemoteProtocol { pageNumber: Int, pageSize: Int, startDateBefore: String?, - startDateAfter: String?) async throws -> [Booking] { + startDateAfter: String?, + searchQuery: String?) async throws -> [Booking] { guard let result = loadAllBookingsResult else { throw NetworkError.timeout() } diff --git a/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift b/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift index 3dddd900cb6..2733258724b 100644 --- a/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift +++ b/Modules/Tests/YosemiteTests/Stores/BookingStoreTests.swift @@ -374,6 +374,87 @@ struct BookingStoreTests { let error = result.failure as? NetworkError #expect(error == .timeout()) } + + // MARK: - searchBookings + + @Test func searchBookings_returns_bookings_on_success() async throws { + // Given + let booking1 = Booking.fake().copy(siteID: sampleSiteID, bookingID: 123) + let booking2 = Booking.fake().copy(siteID: sampleSiteID, bookingID: 456) + remote.whenLoadingAllBookings(thenReturn: .success([booking1, booking2])) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote) + + // When + let result = await withCheckedContinuation { continuation in + store.onAction(BookingAction.searchBookings(siteID: sampleSiteID, + searchQuery: "test", + pageNumber: defaultPageNumber, + pageSize: defaultPageSize, + onCompletion: { result in + continuation.resume(returning: result) + })) + } + + // Then + let bookings = try result.get() + #expect(bookings.count == 2) + #expect(bookings[0].bookingID == 123) + #expect(bookings[1].bookingID == 456) + } + + @Test func searchBookings_returns_error_on_failure() async throws { + // Given + remote.whenLoadingAllBookings(thenReturn: .failure(NetworkError.timeout())) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote) + + // When + let result = await withCheckedContinuation { continuation in + store.onAction(BookingAction.searchBookings(siteID: sampleSiteID, + searchQuery: "test", + pageNumber: defaultPageNumber, + pageSize: defaultPageSize, + onCompletion: { result in + continuation.resume(returning: result) + })) + } + + // Then + #expect(result.isFailure) + let error = result.failure as? NetworkError + #expect(error == .timeout()) + } + + @Test func searchBookings_does_not_save_results_to_storage() async throws { + // Given + let booking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 123) + remote.whenLoadingAllBookings(thenReturn: .success([booking])) + let store = BookingStore(dispatcher: Dispatcher(), + storageManager: storageManager, + network: network, + remote: remote) + #expect(storedBookingCount == 0) + + // When + let result = await withCheckedContinuation { continuation in + store.onAction(BookingAction.searchBookings(siteID: sampleSiteID, + searchQuery: "test", + pageNumber: defaultPageNumber, + pageSize: defaultPageSize, + onCompletion: { result in + continuation.resume(returning: result) + })) + } + + // Then + #expect(result.isSuccess) + #expect(storedBookingCount == 0) + } } private extension BookingStoreTests { diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift index b4f8396726a..b3b654a2b30 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift @@ -3,6 +3,7 @@ import struct Yosemite.Booking struct BookingListContainerView: View { @ObservedObject private var viewModel: BookingListContainerViewModel + @State private var isSearching = false @ScaledMetric private var scale: CGFloat = 1.0 @Binding var selectedBooking: Booking? @@ -18,6 +19,7 @@ struct BookingListContainerView: View { ForEach(BookingListTab.allCases, id: \.rawValue) { tab in BookingListView( viewModel: viewModel.listViewModel(for: tab), + searchViewModel: viewModel.searchViewModel(for: tab), selectedBooking: $selectedBooking ) .tag(tab) @@ -26,11 +28,21 @@ struct BookingListContainerView: View { .tabViewStyle(.page(indexDisplayMode: .never)) } .navigationTitle(Localization.viewTitle) + .if(isSearching, transform: { view in + view.searchable(text: $viewModel.searchQuery, + isPresented: $isSearching, + prompt: Localization.searchPrompt) + }) .toolbar(removing: .sidebarToggle) .toolbar { ToolbarItem(placement: .confirmationAction) { Button { - // TODO + withAnimation { + isSearching.toggle() + if !isSearching { + viewModel.searchQuery = "" + } + } } label: { Image(systemName: "magnifyingglass") } @@ -136,5 +148,10 @@ private extension BookingListContainerView { value: "Filter", comment: "Button to filter the booking list" ) + static let searchPrompt = NSLocalizedString( + "bookingListView.search.prompt", + value: "Search bookings", + comment: "Prompt in the search bar on top of the booking list" + ) } } diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift index 92fe5210505..1b789a11491 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import Combine /// View model for `BookingListContainerView` final class BookingListContainerViewModel: ObservableObject { @@ -6,12 +7,51 @@ final class BookingListContainerViewModel: ObservableObject { private let upcomingListViewModel: BookingListViewModel private let allListViewModel: BookingListViewModel + private let todaySearchViewModel: BookingSearchViewModel + private let upcomingSearchViewModel: BookingSearchViewModel + private let allSearchViewModel: BookingSearchViewModel + @Published var selectedTab: BookingListTab = .today + @Published var searchQuery: String = "" + + private let searchQuerySubject = PassthroughSubject() + private var searchQuerySubscription: AnyCancellable? init(siteID: Int64) { - self.todayListViewModel = BookingListViewModel(siteID: siteID, type: .today) - self.upcomingListViewModel = BookingListViewModel(siteID: siteID, type: .upcoming) - self.allListViewModel = BookingListViewModel(siteID: siteID, type: .all) + let searchQueryPublisher = searchQuerySubject.eraseToAnyPublisher() + self.todayListViewModel = BookingListViewModel( + siteID: siteID, + type: .today, + ) + self.upcomingListViewModel = BookingListViewModel( + siteID: siteID, + type: .upcoming, + ) + self.allListViewModel = BookingListViewModel( + siteID: siteID, + type: .all, + ) + + self.todaySearchViewModel = BookingSearchViewModel( + siteID: siteID, + type: .today, + searchQueryPublisher: searchQueryPublisher + ) + self.upcomingSearchViewModel = BookingSearchViewModel( + siteID: siteID, + type: .upcoming, + searchQueryPublisher: searchQueryPublisher + ) + self.allSearchViewModel = BookingSearchViewModel( + siteID: siteID, + type: .all, + searchQueryPublisher: searchQueryPublisher + ) + + searchQuerySubscription = $searchQuery + .sink { [weak self] query in + self?.searchQuerySubject.send(query) + } } func listViewModel(for tab: BookingListTab) -> BookingListViewModel { @@ -24,6 +64,17 @@ final class BookingListContainerViewModel: ObservableObject { allListViewModel } } + + func searchViewModel(for tab: BookingListTab) -> BookingSearchViewModel { + switch tab { + case .today: + todaySearchViewModel + case .upcoming: + upcomingSearchViewModel + case .all: + allSearchViewModel + } + } } enum BookingListTab: Int, CaseIterable { diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift index 54b1fe0fdd3..974388bbaae 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -3,44 +3,102 @@ import struct Yosemite.Booking struct BookingListView: View { @ObservedObject private var viewModel: BookingListViewModel + @ObservedObject private var searchViewModel: BookingSearchViewModel + @StateObject private var connectivityMonitor = ConnectivityMonitor() @ScaledMetric private var scale: CGFloat = 1.0 + @Binding var selectedBooking: Booking? - init(viewModel: BookingListViewModel, selectedBooking: Binding) { + init(viewModel: BookingListViewModel, + searchViewModel: BookingSearchViewModel, + selectedBooking: Binding) { self.viewModel = viewModel + self.searchViewModel = searchViewModel self._selectedBooking = selectedBooking } var body: some View { + mainContentView + .task { + viewModel.loadBookings() + } + .overlay { + searchContentView + .renderedIf(searchViewModel.currentSearchQuery.isNotEmpty) + } + } +} + +private extension BookingListView { + var mainContentView: some View { VStack { switch viewModel.syncState { case .empty: - emptyStateView + emptyStateView(isSearching: false) { + await viewModel.onRefreshAction() + } case .syncingFirstPage: - Spacer() - ProgressView().progressViewStyle(.circular) - Spacer() + loadingView case .results: - bookingList + bookingList(with: viewModel.bookings, + onNextPage: { viewModel.onLoadNextPageAction() }, + onRefresh: { await viewModel.onRefreshAction() }) } } - .task { - viewModel.loadBookings() - } .overlay(alignment: .bottom) { if viewModel.errorFetching { - errorSnackBar - .transition(.move(edge: .bottom)) + errorSnackBar(onTap: { + withAnimation { + viewModel.errorFetching = false + } + }) + .transition(.move(edge: .bottom)) } } } -} -private extension BookingListView { - var bookingList: some View { + var searchContentView: some View { + VStack { + if searchViewModel.isSearching { + loadingView + } else if searchViewModel.searchResults.isEmpty { + emptyStateView(isSearching: true) { + await searchViewModel.onRefreshAction() + } + } else { + bookingList(with: searchViewModel.searchResults, + onNextPage: { searchViewModel.onLoadNextPageAction() }, + onRefresh: { await searchViewModel.onRefreshAction() }) + } + } + .overlay(alignment: .bottom) { + if searchViewModel.errorFetching { + errorSnackBar(onTap: { + withAnimation { + searchViewModel.errorFetching = false + } + }) + .transition(.move(edge: .bottom)) + } + } + } + + var loadingView: some View { + VStack { + Spacer() + ProgressView().progressViewStyle(.circular) + Spacer() + } + .frame(maxWidth: .infinity) + .background(Color(.systemBackground)) + } + + func bookingList(with bookings: [Booking], + onNextPage: @escaping () -> Void, + onRefresh: @escaping () async -> Void) -> some View { List(selection: $selectedBooking) { - ForEach(viewModel.bookings) { item in + ForEach(bookings) { item in bookingItem(item) .tag(item) } @@ -48,14 +106,14 @@ private extension BookingListView { InfiniteScrollIndicator(showContent: viewModel.shouldShowBottomActivityIndicator) .padding(.top, Layout.viewPadding) .onAppear { - viewModel.onLoadNextPageAction() + onNextPage() } } .listStyle(.plain) .background(Color(.listBackground)) .accentColor(Color(.listSelectedBackground)) .refreshable { - await viewModel.onRefreshAction() + await onRefresh() } } @@ -96,35 +154,41 @@ private extension BookingListView { .background(color.clipShape(RoundedRectangle(cornerRadius: 4))) } - var emptyStateView: some View { + func emptyStateView(isSearching: Bool, onRefresh: @escaping () async -> Void) -> some View { GeometryReader { proxy in ScrollView { VStack(spacing: Layout.emptyStatePadding) { Spacer() - Image(uiImage: .noBookings) + Image(uiImage: isSearching ? .magnifyingGlassNotFound : .noBookings) .resizable() .aspectRatio(contentMode: .fit) .frame(width: Layout.emptyStateImageWidth * scale) .padding(.bottom, Layout.viewPadding) - VStack(spacing: Layout.textVerticalPadding) { - Text(viewModel.emptyStateTitle) - .font(.title2) - .fontWeight(.semibold) - .foregroundStyle(.primary) - Text(viewModel.emptyStateDescription) - .font(.title3) - .foregroundStyle(.secondary) - } - if viewModel.hasFilters { + if isSearching { + Text(Localization.emptySearchText) + .font(.body) + .foregroundStyle(Color.secondary) + } else { VStack(spacing: Layout.textVerticalPadding) { - Button("Change filters") { - // TODO - } - .buttonStyle(PrimaryButtonStyle()) - Button("Clear filters") { - // TODO + Text(viewModel.emptyStateTitle) + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(.primary) + Text(viewModel.emptyStateDescription) + .font(.title3) + .foregroundStyle(.secondary) + } + if viewModel.hasFilters { + VStack(spacing: Layout.textVerticalPadding) { + Button("Change filters") { + // TODO + } + .buttonStyle(PrimaryButtonStyle()) + Button("Clear filters") { + // TODO + } + .buttonStyle(SecondaryButtonStyle()) } - .buttonStyle(SecondaryButtonStyle()) } } Spacer() @@ -134,12 +198,13 @@ private extension BookingListView { .frame(minWidth: proxy.size.width, minHeight: proxy.size.height) } .refreshable { - await viewModel.onRefreshAction() + await onRefresh() } } + .background(Color(.systemBackground)) } - var errorSnackBar: some View { + func errorSnackBar(onTap: @escaping () -> Void) -> some View { Text(Localization.errorMessage) .foregroundStyle(Color(.listForeground(modal: false))) .frame(maxWidth: .infinity, alignment: .leading) @@ -151,11 +216,7 @@ private extension BookingListView { .padding(Layout.viewPadding) .padding(.bottom, connectivityMonitor.isOffline ? OfflineBannerView.height : 0) .contentShape(Rectangle()) - .onTapGesture { - withAnimation { - viewModel.errorFetching = false - } - } + .onTapGesture { onTap() } } } @@ -175,5 +236,10 @@ private extension BookingListView { value: "Error fetching bookings", comment: "Error message when fetching bookings fails" ) + static let emptySearchText = NSLocalizedString( + "bookingList.emptySearchText", + value: "We couldn’t find any bookings with that name — try adjusting your search term to see more results.", + comment: "Message displayed when searching bookings by keyword yields no results." + ) } } diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift index 43451c9967f..9c46258f510 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -1,6 +1,7 @@ import Foundation import SwiftUI import Yosemite +import Combine import protocol Storage.StorageManagerType /// View model for `BookingListView` @@ -37,6 +38,9 @@ final class BookingListViewModel: ObservableObject { /// Tracks if the infinite scroll indicator should be displayed. @Published private(set) var shouldShowBottomActivityIndicator = false + /// Tracks if initial load has been triggered. + private var hasLoadedInitially = false + /// Supports infinite scroll. private let paginationTracker: PaginationTracker private let pageFirstIndex: Int = PaginationTracker.Defaults.pageFirstIndex @@ -76,6 +80,8 @@ final class BookingListViewModel: ObservableObject { /// Called when loading the first page of bookings. func loadBookings() { + guard !hasLoadedInitially else { return } + hasLoadedInitially = true paginationTracker.syncFirstPage() } diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift new file mode 100644 index 00000000000..69230d970be --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift @@ -0,0 +1,137 @@ +import Foundation +import SwiftUI +import Yosemite +import Combine + +/// View model for booking search functionality +final class BookingSearchViewModel: ObservableObject { + + @Published private(set) var searchResults: [Booking] = [] + + @Published private(set) var isSearching = false + + @Published var errorFetching = false + + private let siteID: Int64 + private let type: BookingListTab + private let stores: StoresManager + private let currentDate: Date + private var searchQuerySubscription: AnyCancellable? + + /// Tracks if the infinite scroll indicator should be displayed. + @Published private(set) var shouldShowBottomActivityIndicator = false + + /// Supports infinite scroll for search results. + private let searchPaginationTracker: PaginationTracker + private let pageFirstIndex: Int = PaginationTracker.Defaults.pageFirstIndex + + /// Current search query + @Published var currentSearchQuery: String = "" + + init(siteID: Int64, + type: BookingListTab, + searchQueryPublisher: AnyPublisher, + stores: StoresManager = ServiceLocator.stores, + currentDate: Date = Date()) { + self.siteID = siteID + self.type = type + self.stores = stores + self.currentDate = currentDate + self.searchPaginationTracker = PaginationTracker(pageFirstIndex: pageFirstIndex) + + configureSearchPaginationTracker() + configureSearchQuerySubscription(searchQueryPublisher: searchQueryPublisher) + } + + /// Called when the user pulls down the list to refresh. + @MainActor + func onRefreshAction() async { + await withCheckedContinuation { continuation in + searchPaginationTracker.resync(reason: nil) { + continuation.resume(returning: ()) + } + } + } + + /// Called when the next page should be loaded. + func onLoadNextPageAction() { + guard !currentSearchQuery.isEmpty else { return } + searchPaginationTracker.ensureNextPageIsSynced() + } +} + +// MARK: Configuration + +private extension BookingSearchViewModel { + func configureSearchPaginationTracker() { + searchPaginationTracker.delegate = self + } + + /// Configures subscription to search query changes. + func configureSearchQuerySubscription(searchQueryPublisher: AnyPublisher) { + searchQueryPublisher + .removeDuplicates() + .assign(to: &$currentSearchQuery) + + searchQuerySubscription = $currentSearchQuery + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .removeDuplicates() + .sink { [weak self] query in + guard let self else { return } + isSearching = true + if query.isEmpty { + searchResults = [] + } else { + searchPaginationTracker.syncFirstPage() + } + } + } +} + +extension BookingSearchViewModel: PaginationTrackerDelegate { + func sync(pageNumber: Int, pageSize: Int, reason: String?, onCompletion: SyncCompletion?) { + guard !currentSearchQuery.isEmpty else { + onCompletion?(.success(false)) + isSearching = false + shouldShowBottomActivityIndicator = false + return + } + + shouldShowBottomActivityIndicator = true + withAnimation { + errorFetching = false + } + + let action = BookingAction.searchBookings( + siteID: siteID, + searchQuery: currentSearchQuery, + pageNumber: pageNumber, + pageSize: pageSize, + startDateBefore: type.startDateBefore(currentDate: currentDate)?.ISO8601Format(), + startDateAfter: type.startDateAfter(currentDate: currentDate)?.ISO8601Format() + ) { [weak self] result in + guard let self else { return } + switch result { + case .success(let bookings): + if pageNumber == pageFirstIndex { + searchResults = bookings + } else { + searchResults.append(contentsOf: bookings) + } + let hasNextPage = bookings.count == pageSize + onCompletion?(.success(hasNextPage)) + + case .failure(let error): + DDLogError("⛔️ Error searching bookings: \(error)") + withAnimation { + self.errorFetching = true + } + onCompletion?(.failure(error)) + } + + isSearching = false + shouldShowBottomActivityIndicator = false + } + stores.dispatch(action) + } +} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index f52e5e7328c..62011ba8155 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -2370,6 +2370,7 @@ DE3877E4283E35E80075D87E /* DiscountTypeBottomSheetListSelectorCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE3877E3283E35E80075D87E /* DiscountTypeBottomSheetListSelectorCommandTests.swift */; }; DE46133926B2BEB8001DE59C /* ShippingLabelCountryListSelectorCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE46133826B2BEB8001DE59C /* ShippingLabelCountryListSelectorCommand.swift */; }; DE49C7922BBFB8C500A45AEB /* ErrorTopBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE49C7912BBFB8C500A45AEB /* ErrorTopBanner.swift */; }; + DE49CD222E966814006DCB07 /* BookingSearchViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE49CD212E966814006DCB07 /* BookingSearchViewModelTests.swift */; }; DE4A33552A45A4DC00795DA9 /* WPComSitePlan+SimpleSite.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE4A33542A45A4DC00795DA9 /* WPComSitePlan+SimpleSite.swift */; }; DE4B3B2C2692DC2200EEF2D8 /* ReviewOrderViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE4B3B2B2692DC2200EEF2D8 /* ReviewOrderViewModelTests.swift */; }; DE4B3B2E269455D400EEF2D8 /* MockShipmentActionStoresManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE4B3B2D269455D400EEF2D8 /* MockShipmentActionStoresManager.swift */; }; @@ -5304,6 +5305,7 @@ DE3877E3283E35E80075D87E /* DiscountTypeBottomSheetListSelectorCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscountTypeBottomSheetListSelectorCommandTests.swift; sourceTree = ""; }; DE46133826B2BEB8001DE59C /* ShippingLabelCountryListSelectorCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelCountryListSelectorCommand.swift; sourceTree = ""; }; DE49C7912BBFB8C500A45AEB /* ErrorTopBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorTopBanner.swift; sourceTree = ""; }; + DE49CD212E966814006DCB07 /* BookingSearchViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookingSearchViewModelTests.swift; sourceTree = ""; }; DE4A33542A45A4DC00795DA9 /* WPComSitePlan+SimpleSite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WPComSitePlan+SimpleSite.swift"; sourceTree = ""; }; DE4B3B2B2692DC2200EEF2D8 /* ReviewOrderViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewOrderViewModelTests.swift; sourceTree = ""; }; DE4B3B2D269455D400EEF2D8 /* MockShipmentActionStoresManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockShipmentActionStoresManager.swift; sourceTree = ""; }; @@ -12525,6 +12527,7 @@ DED1E3162E8556270089909C /* Bookings */ = { isa = PBXGroup; children = ( + DE49CD212E966814006DCB07 /* BookingSearchViewModelTests.swift */, DED1E3152E8556270089909C /* BookingListViewModelTests.swift */, 2D054A292E953E3C004111FD /* BookingDetailsViewModelTests.swift */, ); @@ -16368,6 +16371,7 @@ B993051E2B7CC2A400456E35 /* LocallyStoredStateNameRetrieverTests.swift in Sources */, DE7E5E8A2B4D4015002E28D2 /* BlazeTargetDeviceViewModelTests.swift in Sources */, 02E4FD812306AA890049610C /* StatsTimeRangeBarViewModelTests.swift in Sources */, + DE49CD222E966814006DCB07 /* BookingSearchViewModelTests.swift in Sources */, 262B442E2C77D9B000441FD5 /* OrderDetailsViewControllerTests.swift in Sources */, CE755F752D4A6BF3002539F6 /* WooShippingNormalizeAddressViewModelTests.swift in Sources */, CCCFFC5D2934F0BA006130AF /* StatsIntervalDataParserTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift index e76b6765c00..ccfbb5507f4 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift @@ -357,7 +357,10 @@ struct BookingListViewModelTests { onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .upcoming, stores: stores, currentDate: testDate) + let viewModel = BookingListViewModel(siteID: sampleSiteID, + type: .upcoming, + stores: stores, + currentDate: testDate) // When viewModel.loadBookings() diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingSearchViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingSearchViewModelTests.swift new file mode 100644 index 00000000000..362dc6fc3c2 --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingSearchViewModelTests.swift @@ -0,0 +1,368 @@ +import Combine +import Foundation +import Testing +import Yosemite +@testable import WooCommerce + +@MainActor +struct BookingSearchViewModelTests { + + private let sampleSiteID: Int64 = 322 + + // MARK: - Search query subscription + + @Test func search_query_updates_from_publisher() async throws { + // Given + let searchQuerySubject = PassthroughSubject() + let viewModel = BookingSearchViewModel( + siteID: sampleSiteID, + type: .all, + searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher() + ) + + // When + searchQuerySubject.send("test query") + + // Wait for the publisher to propagate (no debounce on assignment) + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + // Then + #expect(viewModel.currentSearchQuery == "test query") + } + + // MARK: - Search action + + @Test func search_bookings_is_dispatched_when_query_is_not_empty() async throws { + // Given + let searchQuerySubject = PassthroughSubject() + let stores = MockStoresManager(sessionManager: .testingInstance) + var invocationCount = 0 + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .searchBookings(_, _, _, _, _, _, onCompletion) = action else { + return + } + invocationCount += 1 + onCompletion(.success([])) + } + + let viewModel = BookingSearchViewModel( + siteID: sampleSiteID, + type: .all, + searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), + stores: stores + ) + + // When + searchQuerySubject.send("test query") + try await Task.sleep(nanoseconds: 400_000_000) + + // Then + #expect(invocationCount == 1) + } + + @Test func search_bookings_passes_correct_search_query() async throws { + // Given + let searchQuerySubject = PassthroughSubject() + let stores = MockStoresManager(sessionManager: .testingInstance) + var capturedSearchQuery: String? + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .searchBookings(_, searchQuery, _, _, _, _, onCompletion) = action else { + return + } + capturedSearchQuery = searchQuery + onCompletion(.success([])) + } + + let viewModel = BookingSearchViewModel( + siteID: sampleSiteID, + type: .all, + searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), + stores: stores + ) + + // When + searchQuerySubject.send("my test query") + try await Task.sleep(nanoseconds: 400_000_000) + + // Then + #expect(capturedSearchQuery == "my test query") + } + + @Test func search_results_are_updated_on_successful_search() async throws { + // Given + let searchQuerySubject = PassthroughSubject() + let stores = MockStoresManager(sessionManager: .testingInstance) + let booking1 = Booking.fake().copy(siteID: sampleSiteID, bookingID: 1, startDate: Date()) + let booking2 = Booking.fake().copy(siteID: sampleSiteID, bookingID: 2, startDate: Date()) + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .searchBookings(_, _, _, _, _, _, onCompletion) = action else { + return + } + onCompletion(.success([booking1, booking2])) + } + + let viewModel = BookingSearchViewModel( + siteID: sampleSiteID, + type: .all, + searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), + stores: stores + ) + + // When + searchQuerySubject.send("test") + try await Task.sleep(nanoseconds: 400_000_000) + + // Then + #expect(viewModel.searchResults.count == 2) + #expect(viewModel.searchResults.contains { $0.bookingID == booking1.bookingID }) + #expect(viewModel.searchResults.contains { $0.bookingID == booking2.bookingID }) + } + + @Test func error_fetching_is_true_on_search_failure() async throws { + // Given + let searchQuerySubject = PassthroughSubject() + let stores = MockStoresManager(sessionManager: .testingInstance) + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .searchBookings(_, _, _, _, _, _, onCompletion) = action else { + return + } + onCompletion(.failure(NSError(domain: "test", code: 1))) + } + + let viewModel = BookingSearchViewModel( + siteID: sampleSiteID, + type: .all, + searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), + stores: stores + ) + + // When + searchQuerySubject.send("test") + try await Task.sleep(nanoseconds: 400_000_000) + + // Then + #expect(viewModel.errorFetching == true) + } + + // MARK: - Pagination + + @Test func on_load_next_page_action_loads_next_page() async throws { + // Given + let searchQuerySubject = PassthroughSubject() + let stores = MockStoresManager(sessionManager: .testingInstance) + var capturedPageNumbers: [Int] = [] + + // First page returns exactly pageSize (25) items to indicate there's a next page + let firstPageBookings = (1...25).map { Booking.fake().copy(siteID: sampleSiteID, bookingID: Int64($0), startDate: Date()) } + let secondPageBookings = [Booking.fake().copy(siteID: sampleSiteID, bookingID: 26, startDate: Date())] + + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .searchBookings(_, _, pageNumber, _, _, _, onCompletion) = action else { + return + } + capturedPageNumbers.append(pageNumber) + let bookings = pageNumber == 1 ? firstPageBookings : secondPageBookings + onCompletion(.success(bookings)) + } + + let viewModel = BookingSearchViewModel( + siteID: sampleSiteID, + type: .all, + searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), + stores: stores + ) + + // When + searchQuerySubject.send("test") + try await Task.sleep(nanoseconds: 400_000_000) + + #expect(viewModel.searchResults.count == 25, "First page should have 25 results") + + viewModel.onLoadNextPageAction() + try await Task.sleep(nanoseconds: 200_000_000) + + // Then + #expect(capturedPageNumbers == [1, 2]) + #expect(viewModel.searchResults.count == 26, "Should have 26 results total (25 from page 1 + 1 from page 2)") + } + + @Test func search_results_are_cleared_on_new_search() async throws { + // Given + let searchQuerySubject = PassthroughSubject() + let stores = MockStoresManager(sessionManager: .testingInstance) + let firstSearchBookings = [Booking.fake().copy(siteID: sampleSiteID, bookingID: 1, startDate: Date())] + let secondSearchBookings = [Booking.fake().copy(siteID: sampleSiteID, bookingID: 2, startDate: Date())] + var searchCount = 0 + + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .searchBookings(_, _, _, _, _, _, onCompletion) = action else { + return + } + searchCount += 1 + let bookings = searchCount == 1 ? firstSearchBookings : secondSearchBookings + onCompletion(.success(bookings)) + } + + let viewModel = BookingSearchViewModel( + siteID: sampleSiteID, + type: .all, + searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), + stores: stores + ) + + // When - first search + searchQuerySubject.send("test1") + try await Task.sleep(nanoseconds: 400_000_000) + + #expect(viewModel.searchResults.count == 1) + #expect(viewModel.searchResults.first?.bookingID == 1) + + // Second search + searchQuerySubject.send("test2") + try await Task.sleep(nanoseconds: 400_000_000) + + // Then - results should be replaced, not appended + #expect(viewModel.searchResults.count == 1) + #expect(viewModel.searchResults.first?.bookingID == 2) + } + + // MARK: - Type-based filtering + + @Test func today_tab_passes_correct_date_filters_to_search_action() async throws { + // Given + let testDate = Date(timeIntervalSince1970: 1609459200) // 2021-01-01 00:00:00 UTC + let searchQuerySubject = PassthroughSubject() + let stores = MockStoresManager(sessionManager: .testingInstance) + var capturedStartDateBefore: String? + var capturedStartDateAfter: String? + + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .searchBookings(_, _, _, _, startDateBefore, startDateAfter, onCompletion) = action else { + return + } + capturedStartDateBefore = startDateBefore + capturedStartDateAfter = startDateAfter + onCompletion(.success([])) + } + + let viewModel = BookingSearchViewModel( + siteID: sampleSiteID, + type: .today, + searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), + stores: stores, + currentDate: testDate + ) + + // When + searchQuerySubject.send("test") + try await Task.sleep(nanoseconds: 400_000_000) + + // Then + #expect(capturedStartDateAfter == "2020-12-31T23:59:59Z", "Today tab should filter after start of day") + #expect(capturedStartDateBefore == "2021-01-02T00:00:00Z", "Today tab should filter before end of day") + } + + @Test func upcoming_tab_passes_correct_date_filters_to_search_action() async throws { + // Given + let testDate = Date(timeIntervalSince1970: 1609459200) // 2021-01-01 00:00:00 UTC + let searchQuerySubject = PassthroughSubject() + let stores = MockStoresManager(sessionManager: .testingInstance) + var capturedStartDateBefore: String? + var capturedStartDateAfter: String? + + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .searchBookings(_, _, _, _, startDateBefore, startDateAfter, onCompletion) = action else { + return + } + capturedStartDateBefore = startDateBefore + capturedStartDateAfter = startDateAfter + onCompletion(.success([])) + } + + let viewModel = BookingSearchViewModel( + siteID: sampleSiteID, + type: .upcoming, + searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), + stores: stores, + currentDate: testDate + ) + + // When + searchQuerySubject.send("test") + try await Task.sleep(nanoseconds: 400_000_000) + + // Then + #expect(capturedStartDateBefore == nil, "Upcoming tab should not have startDateBefore filter") + #expect(capturedStartDateAfter == "2021-01-01T23:59:59Z", "Upcoming tab should filter after end of day") + } + + @Test func all_tab_passes_no_date_filters_to_search_action() async throws { + // Given + let testDate = Date(timeIntervalSince1970: 1609459200) // 2021-01-01 00:00:00 UTC + let searchQuerySubject = PassthroughSubject() + let stores = MockStoresManager(sessionManager: .testingInstance) + var capturedStartDateBefore: String? + var capturedStartDateAfter: String? + + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .searchBookings(_, _, _, _, startDateBefore, startDateAfter, onCompletion) = action else { + return + } + capturedStartDateBefore = startDateBefore + capturedStartDateAfter = startDateAfter + onCompletion(.success([])) + } + + let viewModel = BookingSearchViewModel( + siteID: sampleSiteID, + type: .all, + searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), + stores: stores, + currentDate: testDate + ) + + // When + searchQuerySubject.send("test") + try await Task.sleep(nanoseconds: 400_000_000) + + // Then + #expect(capturedStartDateBefore == nil, "All tab should not have startDateBefore filter") + #expect(capturedStartDateAfter == nil, "All tab should not have startDateAfter filter") + } + + // MARK: - Refresh action + + @Test func on_refresh_action_resyncs_search_results() async throws { + // Given + let searchQuerySubject = PassthroughSubject() + let stores = MockStoresManager(sessionManager: .testingInstance) + var searchCount = 0 + + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .searchBookings(_, _, _, _, _, _, onCompletion) = action else { + return + } + searchCount += 1 + onCompletion(.success([])) + } + + let viewModel = BookingSearchViewModel( + siteID: sampleSiteID, + type: .all, + searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), + stores: stores + ) + + // When - initial search + searchQuerySubject.send("test") + try await Task.sleep(nanoseconds: 400_000_000) + + #expect(searchCount == 1) + + // Refresh + await viewModel.onRefreshAction() + + // Then + #expect(searchCount == 2, "Should have searched twice") + } +}