From fd761e9f0efa08351380cc1ec91b423f8b4c6a38 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Tue, 7 Oct 2025 10:27:55 +0700 Subject: [PATCH 01/14] Add search bar to booking list --- .../BookingList/BookingListContainerView.swift | 18 +++++++++++++++++- .../BookingListContainerViewModel.swift | 1 + 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift index 09b7309f9e9..fd995481ffb 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 init(viewModel: BookingListContainerViewModel) { @@ -22,10 +23,20 @@ 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 { ToolbarItem(placement: .confirmationAction) { Button { - // TODO + withAnimation { + isSearching.toggle() + if !isSearching { + viewModel.searchQuery = "" + } + } } label: { Image(systemName: "magnifyingglass") } @@ -136,5 +147,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..8f5a81e4e9f 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift @@ -7,6 +7,7 @@ final class BookingListContainerViewModel: ObservableObject { private let allListViewModel: BookingListViewModel @Published var selectedTab: BookingListTab = .today + @Published var searchQuery: String = "" init(siteID: Int64) { self.todayListViewModel = BookingListViewModel(siteID: siteID, type: .today) From d3e2d113039f077d5c9bf9071999e1cafe6bac10 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Tue, 7 Oct 2025 10:57:31 +0700 Subject: [PATCH 02/14] Add option to search bookings in BookingsRemote --- .../Sources/Networking/Remote/BookingsRemote.swift | 12 ++++++++++-- Modules/Sources/Yosemite/Stores/BookingStore.swift | 6 ++++-- .../NetworkingTests/Remote/BookingsRemoteTests.swift | 11 ++++++++--- .../YosemiteTests/Mocks/MockBookingsRemote.swift | 3 ++- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/Networking/Remote/BookingsRemote.swift b/Modules/Sources/Networking/Remote/BookingsRemote.swift index 8fe00809a05..66df9dbdb26 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 = "s" } } diff --git a/Modules/Sources/Yosemite/Stores/BookingStore.swift b/Modules/Sources/Yosemite/Stores/BookingStore.swift index 7c9869ea04e..da59faf66c8 100644 --- a/Modules/Sources/Yosemite/Stores/BookingStore.swift +++ b/Modules/Sources/Yosemite/Stores/BookingStore.swift @@ -69,7 +69,8 @@ private extension BookingStore { pageNumber: pageNumber, pageSize: pageSize, startDateBefore: startDateBefore, - startDateAfter: startDateAfter) + startDateAfter: startDateAfter, + searchQuery: nil) await upsertStoredBookingsInBackground( readOnlyBookings: bookings, siteID: siteID, @@ -101,7 +102,8 @@ private extension BookingStore { pageNumber: 1, pageSize: 1, startDateBefore: nil, - startDateAfter: nil) + startDateAfter: nil, + searchQuery: nil) let hasRemoteBookings = !bookings.isEmpty onCompletion(.success(hasRemoteBookings)) } catch { diff --git a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift index c266deebeb8..4c33ddab299 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["s"] 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() } From 334d90dd7ac5b75e28166df28b3c48c1ec8ea60e Mon Sep 17 00:00:00 2001 From: Huong Do Date: Tue, 7 Oct 2025 11:15:16 +0700 Subject: [PATCH 03/14] Add new action for searching bookings --- .../Yosemite/Actions/BookingAction.swift | 12 +++ .../Yosemite/Stores/BookingStore.swift | 33 ++++++++ .../Stores/BookingStoreTests.swift | 81 +++++++++++++++++++ 3 files changed, 126 insertions(+) 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 da59faf66c8..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) } } } @@ -111,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/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 { From 3d6fce127b2775e9f181a1b48686143b494b25bb Mon Sep 17 00:00:00 2001 From: Huong Do Date: Wed, 8 Oct 2025 12:44:48 +0700 Subject: [PATCH 04/14] Send search query to booking list --- .../Networking/Remote/BookingsRemote.swift | 2 +- .../BookingListContainerViewModel.swift | 28 +++- .../BookingList/BookingListViewModel.swift | 12 ++ .../BookingList/BookingSearchViewModel.swift | 129 ++++++++++++++++++ 4 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift diff --git a/Modules/Sources/Networking/Remote/BookingsRemote.swift b/Modules/Sources/Networking/Remote/BookingsRemote.swift index 66df9dbdb26..4820d1e254f 100644 --- a/Modules/Sources/Networking/Remote/BookingsRemote.swift +++ b/Modules/Sources/Networking/Remote/BookingsRemote.swift @@ -78,6 +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 = "s" + static let search: String = "search" } } diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift index 8f5a81e4e9f..a0afd812d0f 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 { @@ -9,10 +10,31 @@ final class BookingListContainerViewModel: ObservableObject { @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, + searchQueryPublisher: searchQueryPublisher + ) + self.upcomingListViewModel = BookingListViewModel( + siteID: siteID, + type: .upcoming, + searchQueryPublisher: searchQueryPublisher + ) + self.allListViewModel = BookingListViewModel( + siteID: siteID, + type: .all, + searchQueryPublisher: searchQueryPublisher + ) + + searchQuerySubscription = $searchQuery + .sink { [weak self] query in + self?.searchQuerySubject.send(query) + } } func listViewModel(for tab: BookingListTab) -> BookingListViewModel { diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift index 43451c9967f..dc4ef6a15e9 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` @@ -10,6 +11,9 @@ final class BookingListViewModel: ObservableObject { @Published var errorFetching = false + /// Search view model for handling search functionality + let searchViewModel: BookingSearchViewModel + var hasFilters: Bool { // TODO: Update when adding filters return false @@ -60,6 +64,7 @@ final class BookingListViewModel: ObservableObject { init(siteID: Int64, type: BookingListTab, + searchQueryPublisher: AnyPublisher, stores: StoresManager = ServiceLocator.stores, storage: StorageManagerType = ServiceLocator.storageManager, currentDate: Date = Date()) { @@ -69,6 +74,13 @@ final class BookingListViewModel: ObservableObject { self.storage = storage self.currentDate = currentDate self.paginationTracker = PaginationTracker(pageFirstIndex: pageFirstIndex) + self.searchViewModel = BookingSearchViewModel( + siteID: siteID, + type: type, + searchQueryPublisher: searchQueryPublisher, + stores: stores, + currentDate: currentDate + ) configureResultsController() configurePaginationTracker() diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift new file mode 100644 index 00000000000..c7b7dd60644 --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift @@ -0,0 +1,129 @@ +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 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 } + if query.isEmpty { + self.searchResults = [] + self.isSearching = false + } else { + self.isSearching = true + self.searchPaginationTracker.syncFirstPage() + } + } + } +} + +extension BookingSearchViewModel: PaginationTrackerDelegate { + func sync(pageNumber: Int, pageSize: Int, reason: String?, onCompletion: SyncCompletion?) { + guard !currentSearchQuery.isEmpty else { + onCompletion?(.success(false)) + return + } + + if pageNumber == pageFirstIndex { + searchResults = [] // Clear previous search results + } + + 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 == self.pageFirstIndex { + self.searchResults = bookings + } else { + self.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)) + } + + self.shouldShowBottomActivityIndicator = false + } + stores.dispatch(action) + } +} From d66af8e2742944ba48064da25e558759989175ac Mon Sep 17 00:00:00 2001 From: Huong Do Date: Wed, 8 Oct 2025 14:43:23 +0700 Subject: [PATCH 05/14] Update list view to display search results --- .../BookingList/BookingListView.swift | 55 +++++++++++++++---- .../BookingList/BookingSearchViewModel.swift | 7 ++- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift index 54b1fe0fdd3..9164eac63af 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -3,31 +3,43 @@ 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) { self.viewModel = viewModel + self.searchViewModel = viewModel.searchViewModel self._selectedBooking = selectedBooking } var body: some View { + VStack { + mainContentView + .overlay { + searchContentView + .renderedIf(searchViewModel.currentSearchQuery.isNotEmpty) + } + } + .task { + viewModel.loadBookings() + } + } +} + +private extension BookingListView { + var mainContentView: some View { VStack { switch viewModel.syncState { case .empty: emptyStateView case .syncingFirstPage: - Spacer() - ProgressView().progressViewStyle(.circular) - Spacer() + loadingView case .results: - bookingList + bookingList(with: viewModel.bookings) } } - .task { - viewModel.loadBookings() - } .overlay(alignment: .bottom) { if viewModel.errorFetching { errorSnackBar @@ -35,12 +47,32 @@ struct BookingListView: View { } } } -} -private extension BookingListView { - var bookingList: some View { + var searchContentView: some View { + VStack { + if searchViewModel.isSearching { + loadingView + } else if searchViewModel.searchResults.isEmpty { + emptyStateView + } else { + bookingList(with: searchViewModel.searchResults) + } + } + } + + var loadingView: some View { + VStack { + Spacer() + ProgressView().progressViewStyle(.circular) + Spacer() + } + .frame(maxWidth: .infinity) + .background(Color(.systemBackground)) + } + + func bookingList(with bookings: [Booking]) -> some View { List(selection: $selectedBooking) { - ForEach(viewModel.bookings) { item in + ForEach(bookings) { item in bookingItem(item) .tag(item) } @@ -137,6 +169,7 @@ private extension BookingListView { await viewModel.onRefreshAction() } } + .background(Color(.systemBackground)) } var errorSnackBar: some View { diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift index c7b7dd60644..dd644db5219 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift @@ -107,9 +107,9 @@ extension BookingSearchViewModel: PaginationTrackerDelegate { switch result { case .success(let bookings): if pageNumber == self.pageFirstIndex { - self.searchResults = bookings + searchResults = bookings } else { - self.searchResults.append(contentsOf: bookings) + searchResults.append(contentsOf: bookings) } let hasNextPage = bookings.count == pageSize onCompletion?(.success(hasNextPage)) @@ -122,7 +122,8 @@ extension BookingSearchViewModel: PaginationTrackerDelegate { onCompletion?(.failure(error)) } - self.shouldShowBottomActivityIndicator = false + isSearching = false + shouldShowBottomActivityIndicator = false } stores.dispatch(action) } From dcb5b81f241a3fba4b45f45df5209a84e304dc8e Mon Sep 17 00:00:00 2001 From: Huong Do Date: Wed, 8 Oct 2025 15:51:44 +0700 Subject: [PATCH 06/14] Update empty state and error state --- .../BookingList/BookingListView.swift | 90 ++++++++++++------- .../BookingList/BookingSearchViewModel.swift | 39 ++++++++ 2 files changed, 95 insertions(+), 34 deletions(-) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift index 9164eac63af..ba9ff6473d3 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -33,17 +33,25 @@ private extension BookingListView { VStack { switch viewModel.syncState { case .empty: - emptyStateView + emptyStateView(isSearching: false) { + await viewModel.onRefreshAction() + } case .syncingFirstPage: loadingView case .results: - bookingList(with: viewModel.bookings) + bookingList(with: viewModel.bookings, onRefresh: { + await viewModel.onRefreshAction() + }) } } .overlay(alignment: .bottom) { if viewModel.errorFetching { - errorSnackBar - .transition(.move(edge: .bottom)) + errorSnackBar(onTap: { + withAnimation { + viewModel.errorFetching = false + } + }) + .transition(.move(edge: .bottom)) } } } @@ -53,9 +61,23 @@ private extension BookingListView { if searchViewModel.isSearching { loadingView } else if searchViewModel.searchResults.isEmpty { - emptyStateView + emptyStateView(isSearching: true) { + await searchViewModel.onRefreshAction() + } } else { - bookingList(with: searchViewModel.searchResults) + bookingList(with: searchViewModel.searchResults, onRefresh: { + await searchViewModel.onRefreshAction() + }) + } + } + .overlay(alignment: .bottom) { + if searchViewModel.errorFetching { + errorSnackBar(onTap: { + withAnimation { + searchViewModel.errorFetching = false + } + }) + .transition(.move(edge: .bottom)) } } } @@ -70,7 +92,7 @@ private extension BookingListView { .background(Color(.systemBackground)) } - func bookingList(with bookings: [Booking]) -> some View { + func bookingList(with bookings: [Booking], onRefresh: @escaping () async -> Void) -> some View { List(selection: $selectedBooking) { ForEach(bookings) { item in bookingItem(item) @@ -87,7 +109,7 @@ private extension BookingListView { .background(Color(.listBackground)) .accentColor(Color(.listSelectedBackground)) .refreshable { - await viewModel.onRefreshAction() + await onRefresh() } } @@ -128,35 +150,39 @@ 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(searchViewModel.emptyStateMessage) + } 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() @@ -166,13 +192,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) @@ -184,11 +210,7 @@ private extension BookingListView { .padding(Layout.viewPadding) .padding(.bottom, connectivityMonitor.isOffline ? OfflineBannerView.height : 0) .contentShape(Rectangle()) - .onTapGesture { - withAnimation { - viewModel.errorFetching = false - } - } + .onTapGesture { onTap() } } } diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift index dd644db5219..1ef9ee341a1 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift @@ -28,6 +28,24 @@ final class BookingSearchViewModel: ObservableObject { /// Current search query @Published var currentSearchQuery: String = "" + var emptyStateMessage: AttributedString { + let quotedSearchQuery = "\"\(currentSearchQuery)\"" + let content = String.localizedStringWithFormat(Localization.emptySearchText, quotedSearchQuery) + + var attributedText = AttributedString(content) + attributedText.font = .headline.weight(.regular) + attributedText.foregroundColor = Color(.text) + + if let range = attributedText.range(of: quotedSearchQuery) { + let textStyleContainer = AttributeContainer() + .font(.headline.weight(.semibold)) + .foregroundColor(Color(.text)) + attributedText[range].setAttributes(textStyleContainer) + } + + return attributedText + } + init(siteID: Int64, type: BookingListTab, searchQueryPublisher: AnyPublisher, @@ -43,6 +61,16 @@ final class BookingSearchViewModel: ObservableObject { 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 } @@ -128,3 +156,14 @@ extension BookingSearchViewModel: PaginationTrackerDelegate { stores.dispatch(action) } } + +private extension BookingSearchViewModel { + enum Localization { + static let emptySearchText = NSLocalizedString( + "bookingList.emptySearchText", + value: "We're sorry, we couldn't find results for %1$@", + comment: "Message displayed when searching bookings by keyword yields no results. " + + "The placeholder is the search keyword." + ) + } +} From 3b168ac5c953fb51665ab594f8d8daf8580511f4 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Wed, 8 Oct 2025 15:59:33 +0700 Subject: [PATCH 07/14] Fix test build failures --- .../Bookings/BookingListViewModelTests.swift | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift index e76b6765c00..3389e10a6ac 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift @@ -19,6 +19,9 @@ struct BookingListViewModelTests { storageManager.viewStorage } + /// Search query publisher for tests + private let searchQueryPublisher = PassthroughSubject().eraseToAnyPublisher() + init() { storageManager = MockStorageManager() } @@ -35,7 +38,7 @@ struct BookingListViewModelTests { } invocationCountOfLoadBookings += 1 } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher, stores: stores) // Then #expect(viewModel.syncState == .empty) @@ -52,7 +55,7 @@ struct BookingListViewModelTests { } invocationCountOfLoadBookings += 1 } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher, stores: stores) // When viewModel.loadBookings() @@ -63,7 +66,7 @@ struct BookingListViewModelTests { @Test func state_is_syncing_first_page_upon_load_bookings_if_no_existing_booking_in_storage() { // Given - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher) // When viewModel.loadBookings() @@ -77,6 +80,7 @@ struct BookingListViewModelTests { insertBookings([existingBooking]) let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, + searchQueryPublisher: searchQueryPublisher, stores: MockStoresManager(sessionManager: .testingInstance), storage: storageManager) @@ -100,6 +104,7 @@ struct BookingListViewModelTests { } let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, + searchQueryPublisher: searchQueryPublisher, stores: stores, storage: storageManager) @@ -137,6 +142,7 @@ struct BookingListViewModelTests { } let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, + searchQueryPublisher: searchQueryPublisher, stores: stores, storage: storageManager) @@ -181,6 +187,7 @@ struct BookingListViewModelTests { let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, + searchQueryPublisher: searchQueryPublisher, stores: stores, storage: storageManager) @@ -226,6 +233,7 @@ struct BookingListViewModelTests { } let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, + searchQueryPublisher: searchQueryPublisher, stores: stores, storage: storageManager) @@ -250,6 +258,7 @@ struct BookingListViewModelTests { } let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, + searchQueryPublisher: searchQueryPublisher, stores: stores, storage: storageManager) @@ -275,6 +284,7 @@ struct BookingListViewModelTests { } let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, + searchQueryPublisher: searchQueryPublisher, stores: stores, storage: storageManager) @@ -303,7 +313,7 @@ struct BookingListViewModelTests { onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher, stores: stores) // When await viewModel.onRefreshAction() @@ -331,7 +341,7 @@ struct BookingListViewModelTests { onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .today, stores: stores, currentDate: testDate) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .today, searchQueryPublisher: searchQueryPublisher, stores: stores, currentDate: testDate) // When viewModel.loadBookings() @@ -357,7 +367,7 @@ struct BookingListViewModelTests { onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .upcoming, stores: stores, currentDate: testDate) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .upcoming, searchQueryPublisher: searchQueryPublisher, stores: stores, currentDate: testDate) // When viewModel.loadBookings() @@ -383,7 +393,7 @@ struct BookingListViewModelTests { onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores, currentDate: testDate) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher, stores: stores, currentDate: testDate) // When viewModel.loadBookings() @@ -408,7 +418,7 @@ struct BookingListViewModelTests { onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher, stores: stores) // When viewModel.loadBookings() @@ -432,7 +442,7 @@ struct BookingListViewModelTests { onCompletion(.success(actionCallCount == 1)) // First call has next page, second doesn't } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher, stores: stores) // When viewModel.loadBookings() // First page @@ -456,7 +466,7 @@ struct BookingListViewModelTests { onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher, stores: stores) // When await viewModel.onRefreshAction() @@ -484,6 +494,7 @@ struct BookingListViewModelTests { let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .today, + searchQueryPublisher: searchQueryPublisher, stores: MockStoresManager(sessionManager: .testingInstance), storage: storageManager, currentDate: testDate) @@ -509,6 +520,7 @@ struct BookingListViewModelTests { let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .upcoming, + searchQueryPublisher: searchQueryPublisher, stores: MockStoresManager(sessionManager: .testingInstance), storage: storageManager, currentDate: testDate) @@ -538,6 +550,7 @@ struct BookingListViewModelTests { let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, + searchQueryPublisher: searchQueryPublisher, stores: MockStoresManager(sessionManager: .testingInstance), storage: storageManager, currentDate: testDate) @@ -565,6 +578,7 @@ struct BookingListViewModelTests { let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, + searchQueryPublisher: searchQueryPublisher, stores: MockStoresManager(sessionManager: .testingInstance), storage: storageManager, currentDate: testDate) From 333f71c36d9cf0a14dae30557a48e6603ad670c4 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Wed, 8 Oct 2025 16:32:40 +0700 Subject: [PATCH 08/14] Fix issues reported by CI --- .../Remote/BookingsRemoteTests.swift | 2 +- .../Bookings/BookingList/BookingListView.swift | 18 ++++++++++-------- .../BookingList/BookingSearchViewModel.swift | 8 +++++--- .../Bookings/BookingListViewModelTests.swift | 6 +++++- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift index 4c33ddab299..e93715f9ec2 100644 --- a/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/BookingsRemoteTests.swift @@ -62,7 +62,7 @@ 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["s"] as? String) == searchQuery) + #expect((parameters["search"] as? String) == searchQuery) } @Test func test_loadAllBookings_omits_nil_parameters() async throws { diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift index ba9ff6473d3..97cdbafbed1 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -39,9 +39,9 @@ private extension BookingListView { case .syncingFirstPage: loadingView case .results: - bookingList(with: viewModel.bookings, onRefresh: { - await viewModel.onRefreshAction() - }) + bookingList(with: viewModel.bookings, + onNextPage: { viewModel.onLoadNextPageAction() }, + onRefresh: { await viewModel.onRefreshAction() }) } } .overlay(alignment: .bottom) { @@ -65,9 +65,9 @@ private extension BookingListView { await searchViewModel.onRefreshAction() } } else { - bookingList(with: searchViewModel.searchResults, onRefresh: { - await searchViewModel.onRefreshAction() - }) + bookingList(with: searchViewModel.searchResults, + onNextPage: { searchViewModel.onLoadNextPageAction() }, + onRefresh: {await searchViewModel.onRefreshAction()}) } } .overlay(alignment: .bottom) { @@ -92,7 +92,9 @@ private extension BookingListView { .background(Color(.systemBackground)) } - func bookingList(with bookings: [Booking], onRefresh: @escaping () async -> Void) -> some View { + func bookingList(with bookings: [Booking], + onNextPage: @escaping () -> Void, + onRefresh: @escaping () async -> Void) -> some View { List(selection: $selectedBooking) { ForEach(bookings) { item in bookingItem(item) @@ -102,7 +104,7 @@ private extension BookingListView { InfiniteScrollIndicator(showContent: viewModel.shouldShowBottomActivityIndicator) .padding(.top, Layout.viewPadding) .onAppear { - viewModel.onLoadNextPageAction() + onNextPage() } } .listStyle(.plain) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift index 1ef9ee341a1..634d38905fd 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift @@ -109,6 +109,11 @@ private extension BookingSearchViewModel { extension BookingSearchViewModel: PaginationTrackerDelegate { func sync(pageNumber: Int, pageSize: Int, reason: String?, onCompletion: SyncCompletion?) { + defer { + isSearching = false + shouldShowBottomActivityIndicator = false + } + guard !currentSearchQuery.isEmpty else { onCompletion?(.success(false)) return @@ -149,9 +154,6 @@ extension BookingSearchViewModel: PaginationTrackerDelegate { } onCompletion?(.failure(error)) } - - isSearching = false - shouldShowBottomActivityIndicator = false } stores.dispatch(action) } diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift index 3389e10a6ac..d68270e7f2e 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift @@ -367,7 +367,11 @@ struct BookingListViewModelTests { onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .upcoming, searchQueryPublisher: searchQueryPublisher, stores: stores, currentDate: testDate) + let viewModel = BookingListViewModel(siteID: sampleSiteID, + type: .upcoming, + searchQueryPublisher: searchQueryPublisher, + stores: stores, + currentDate: testDate) // When viewModel.loadBookings() From bbe66674b63daa36f94acfd201d761ca2c95a6a5 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Wed, 8 Oct 2025 17:01:55 +0700 Subject: [PATCH 09/14] Add BookingSearchViewModelTests --- .../BookingList/BookingSearchViewModel.swift | 10 +- .../WooCommerce.xcodeproj/project.pbxproj | 4 + .../BookingSearchViewModelTests.swift | 402 ++++++++++++++++++ 3 files changed, 411 insertions(+), 5 deletions(-) create mode 100644 WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingSearchViewModelTests.swift diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift index 634d38905fd..340001986a3 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift @@ -109,13 +109,10 @@ private extension BookingSearchViewModel { extension BookingSearchViewModel: PaginationTrackerDelegate { func sync(pageNumber: Int, pageSize: Int, reason: String?, onCompletion: SyncCompletion?) { - defer { - isSearching = false - shouldShowBottomActivityIndicator = false - } - guard !currentSearchQuery.isEmpty else { onCompletion?(.success(false)) + isSearching = false + shouldShowBottomActivityIndicator = false return } @@ -154,6 +151,9 @@ extension BookingSearchViewModel: PaginationTrackerDelegate { } 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 dac27603374..0b9932f60cd 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -2366,6 +2366,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 */; }; @@ -5296,6 +5297,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 = ""; }; @@ -12514,6 +12516,7 @@ DED1E3162E8556270089909C /* Bookings */ = { isa = PBXGroup; children = ( + DE49CD212E966814006DCB07 /* BookingSearchViewModelTests.swift */, DED1E3152E8556270089909C /* BookingListViewModelTests.swift */, ); path = Bookings; @@ -16352,6 +16355,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/BookingSearchViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingSearchViewModelTests.swift new file mode 100644 index 00000000000..734c0930695 --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingSearchViewModelTests.swift @@ -0,0 +1,402 @@ +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") + } + + @Test func search_results_are_cleared_when_query_becomes_empty() async throws { + // Given + let searchQuerySubject = PassthroughSubject() + let stores = MockStoresManager(sessionManager: .testingInstance) + let booking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 1, startDate: Date()) + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .searchBookings(_, _, _, _, _, _, onCompletion) = action else { + return + } + onCompletion(.success([booking])) + } + + let viewModel = BookingSearchViewModel( + siteID: sampleSiteID, + type: .all, + searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), + stores: stores + ) + + // When - perform search + searchQuerySubject.send("test") + try await Task.sleep(nanoseconds: 400_000_000) // Wait for debounce + search + + #expect(viewModel.searchResults.count == 1) + + // Clear query + searchQuerySubject.send("") + try await Task.sleep(nanoseconds: 400_000_000) + + // Then + #expect(viewModel.searchResults.isEmpty) + #expect(viewModel.isSearching == false) + } + + // 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([])) + } + + _ = 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([])) + } + + _ = 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([])) + } + + _ = 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([])) + } + + _ = 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([])) + } + + _ = 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") + } +} From 470b77ac748d252139bfd653ef8ae9942694abe9 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Wed, 8 Oct 2025 17:43:15 +0700 Subject: [PATCH 10/14] Fix failed tests --- .../Bookings/BookingSearchViewModelTests.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingSearchViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingSearchViewModelTests.swift index 734c0930695..1499a1f8a0e 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingSearchViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingSearchViewModelTests.swift @@ -79,7 +79,7 @@ struct BookingSearchViewModelTests { onCompletion(.success([])) } - _ = BookingSearchViewModel( + let viewModel = BookingSearchViewModel( siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), @@ -107,7 +107,7 @@ struct BookingSearchViewModelTests { onCompletion(.success([])) } - _ = BookingSearchViewModel( + let viewModel = BookingSearchViewModel( siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), @@ -279,7 +279,7 @@ struct BookingSearchViewModelTests { onCompletion(.success([])) } - _ = BookingSearchViewModel( + let viewModel = BookingSearchViewModel( siteID: sampleSiteID, type: .today, searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), @@ -313,7 +313,7 @@ struct BookingSearchViewModelTests { onCompletion(.success([])) } - _ = BookingSearchViewModel( + let viewModel = BookingSearchViewModel( siteID: sampleSiteID, type: .upcoming, searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), @@ -347,7 +347,7 @@ struct BookingSearchViewModelTests { onCompletion(.success([])) } - _ = BookingSearchViewModel( + let viewModel = BookingSearchViewModel( siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), From a2d0dc8f448e3829fcaa88ff78af0b83e6b81922 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Thu, 9 Oct 2025 10:12:20 +0700 Subject: [PATCH 11/14] Update empty and loading states of search view --- .../BookingList/BookingListView.swift | 9 +++- .../BookingList/BookingSearchViewModel.swift | 42 ++----------------- 2 files changed, 12 insertions(+), 39 deletions(-) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift index 97cdbafbed1..58219a89658 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -163,7 +163,9 @@ private extension BookingListView { .frame(width: Layout.emptyStateImageWidth * scale) .padding(.bottom, Layout.viewPadding) if isSearching { - Text(searchViewModel.emptyStateMessage) + Text(Localization.emptySearchText) + .font(.body) + .foregroundStyle(Color.secondary) } else { VStack(spacing: Layout.textVerticalPadding) { Text(viewModel.emptyStateTitle) @@ -232,5 +234,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/BookingSearchViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift index 340001986a3..69230d970be 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift @@ -28,24 +28,6 @@ final class BookingSearchViewModel: ObservableObject { /// Current search query @Published var currentSearchQuery: String = "" - var emptyStateMessage: AttributedString { - let quotedSearchQuery = "\"\(currentSearchQuery)\"" - let content = String.localizedStringWithFormat(Localization.emptySearchText, quotedSearchQuery) - - var attributedText = AttributedString(content) - attributedText.font = .headline.weight(.regular) - attributedText.foregroundColor = Color(.text) - - if let range = attributedText.range(of: quotedSearchQuery) { - let textStyleContainer = AttributeContainer() - .font(.headline.weight(.semibold)) - .foregroundColor(Color(.text)) - attributedText[range].setAttributes(textStyleContainer) - } - - return attributedText - } - init(siteID: Int64, type: BookingListTab, searchQueryPublisher: AnyPublisher, @@ -96,12 +78,11 @@ private extension BookingSearchViewModel { .removeDuplicates() .sink { [weak self] query in guard let self else { return } + isSearching = true if query.isEmpty { - self.searchResults = [] - self.isSearching = false + searchResults = [] } else { - self.isSearching = true - self.searchPaginationTracker.syncFirstPage() + searchPaginationTracker.syncFirstPage() } } } @@ -116,10 +97,6 @@ extension BookingSearchViewModel: PaginationTrackerDelegate { return } - if pageNumber == pageFirstIndex { - searchResults = [] // Clear previous search results - } - shouldShowBottomActivityIndicator = true withAnimation { errorFetching = false @@ -136,7 +113,7 @@ extension BookingSearchViewModel: PaginationTrackerDelegate { guard let self else { return } switch result { case .success(let bookings): - if pageNumber == self.pageFirstIndex { + if pageNumber == pageFirstIndex { searchResults = bookings } else { searchResults.append(contentsOf: bookings) @@ -158,14 +135,3 @@ extension BookingSearchViewModel: PaginationTrackerDelegate { stores.dispatch(action) } } - -private extension BookingSearchViewModel { - enum Localization { - static let emptySearchText = NSLocalizedString( - "bookingList.emptySearchText", - value: "We're sorry, we couldn't find results for %1$@", - comment: "Message displayed when searching bookings by keyword yields no results. " + - "The placeholder is the search keyword." - ) - } -} From ace2b984fde852ebc14c6ad16ff5506b9a6c53aa Mon Sep 17 00:00:00 2001 From: Huong Do Date: Thu, 9 Oct 2025 11:01:28 +0700 Subject: [PATCH 12/14] Inject searchViewModel from container --- .../BookingListContainerView.swift | 1 + .../BookingListContainerViewModel.swift | 32 +++++++++++++++-- .../BookingList/BookingListView.swift | 28 ++++++++------- .../BookingList/BookingListViewModel.swift | 16 +++------ .../Bookings/BookingListViewModelTests.swift | 33 +++++------------- .../BookingSearchViewModelTests.swift | 34 ------------------- 6 files changed, 60 insertions(+), 84 deletions(-) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift index be853aba999..c276d59d780 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift @@ -19,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) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift index a0afd812d0f..1b789a11491 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift @@ -7,6 +7,10 @@ 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 = "" @@ -18,16 +22,29 @@ final class BookingListContainerViewModel: ObservableObject { self.todayListViewModel = BookingListViewModel( siteID: siteID, type: .today, - searchQueryPublisher: searchQueryPublisher ) self.upcomingListViewModel = BookingListViewModel( siteID: siteID, type: .upcoming, - searchQueryPublisher: searchQueryPublisher ) 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 ) @@ -47,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 58219a89658..974388bbaae 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -4,27 +4,29 @@ 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 = viewModel.searchViewModel + self.searchViewModel = searchViewModel self._selectedBooking = selectedBooking } var body: some View { - VStack { - mainContentView - .overlay { - searchContentView - .renderedIf(searchViewModel.currentSearchQuery.isNotEmpty) - } - } - .task { - viewModel.loadBookings() - } + mainContentView + .task { + viewModel.loadBookings() + } + .overlay { + searchContentView + .renderedIf(searchViewModel.currentSearchQuery.isNotEmpty) + } } } @@ -67,7 +69,7 @@ private extension BookingListView { } else { bookingList(with: searchViewModel.searchResults, onNextPage: { searchViewModel.onLoadNextPageAction() }, - onRefresh: {await searchViewModel.onRefreshAction()}) + onRefresh: { await searchViewModel.onRefreshAction() }) } } .overlay(alignment: .bottom) { diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift index dc4ef6a15e9..9c46258f510 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -11,9 +11,6 @@ final class BookingListViewModel: ObservableObject { @Published var errorFetching = false - /// Search view model for handling search functionality - let searchViewModel: BookingSearchViewModel - var hasFilters: Bool { // TODO: Update when adding filters return false @@ -41,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 @@ -64,7 +64,6 @@ final class BookingListViewModel: ObservableObject { init(siteID: Int64, type: BookingListTab, - searchQueryPublisher: AnyPublisher, stores: StoresManager = ServiceLocator.stores, storage: StorageManagerType = ServiceLocator.storageManager, currentDate: Date = Date()) { @@ -74,13 +73,6 @@ final class BookingListViewModel: ObservableObject { self.storage = storage self.currentDate = currentDate self.paginationTracker = PaginationTracker(pageFirstIndex: pageFirstIndex) - self.searchViewModel = BookingSearchViewModel( - siteID: siteID, - type: type, - searchQueryPublisher: searchQueryPublisher, - stores: stores, - currentDate: currentDate - ) configureResultsController() configurePaginationTracker() @@ -88,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/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift index d68270e7f2e..ccfbb5507f4 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift @@ -19,9 +19,6 @@ struct BookingListViewModelTests { storageManager.viewStorage } - /// Search query publisher for tests - private let searchQueryPublisher = PassthroughSubject().eraseToAnyPublisher() - init() { storageManager = MockStorageManager() } @@ -38,7 +35,7 @@ struct BookingListViewModelTests { } invocationCountOfLoadBookings += 1 } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher, stores: stores) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) // Then #expect(viewModel.syncState == .empty) @@ -55,7 +52,7 @@ struct BookingListViewModelTests { } invocationCountOfLoadBookings += 1 } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher, stores: stores) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) // When viewModel.loadBookings() @@ -66,7 +63,7 @@ struct BookingListViewModelTests { @Test func state_is_syncing_first_page_upon_load_bookings_if_no_existing_booking_in_storage() { // Given - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all) // When viewModel.loadBookings() @@ -80,7 +77,6 @@ struct BookingListViewModelTests { insertBookings([existingBooking]) let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, - searchQueryPublisher: searchQueryPublisher, stores: MockStoresManager(sessionManager: .testingInstance), storage: storageManager) @@ -104,7 +100,6 @@ struct BookingListViewModelTests { } let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, - searchQueryPublisher: searchQueryPublisher, stores: stores, storage: storageManager) @@ -142,7 +137,6 @@ struct BookingListViewModelTests { } let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, - searchQueryPublisher: searchQueryPublisher, stores: stores, storage: storageManager) @@ -187,7 +181,6 @@ struct BookingListViewModelTests { let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, - searchQueryPublisher: searchQueryPublisher, stores: stores, storage: storageManager) @@ -233,7 +226,6 @@ struct BookingListViewModelTests { } let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, - searchQueryPublisher: searchQueryPublisher, stores: stores, storage: storageManager) @@ -258,7 +250,6 @@ struct BookingListViewModelTests { } let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, - searchQueryPublisher: searchQueryPublisher, stores: stores, storage: storageManager) @@ -284,7 +275,6 @@ struct BookingListViewModelTests { } let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, - searchQueryPublisher: searchQueryPublisher, stores: stores, storage: storageManager) @@ -313,7 +303,7 @@ struct BookingListViewModelTests { onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher, stores: stores) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) // When await viewModel.onRefreshAction() @@ -341,7 +331,7 @@ struct BookingListViewModelTests { onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .today, searchQueryPublisher: searchQueryPublisher, stores: stores, currentDate: testDate) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .today, stores: stores, currentDate: testDate) // When viewModel.loadBookings() @@ -369,7 +359,6 @@ struct BookingListViewModelTests { let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .upcoming, - searchQueryPublisher: searchQueryPublisher, stores: stores, currentDate: testDate) @@ -397,7 +386,7 @@ struct BookingListViewModelTests { onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher, stores: stores, currentDate: testDate) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores, currentDate: testDate) // When viewModel.loadBookings() @@ -422,7 +411,7 @@ struct BookingListViewModelTests { onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher, stores: stores) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) // When viewModel.loadBookings() @@ -446,7 +435,7 @@ struct BookingListViewModelTests { onCompletion(.success(actionCallCount == 1)) // First call has next page, second doesn't } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher, stores: stores) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) // When viewModel.loadBookings() // First page @@ -470,7 +459,7 @@ struct BookingListViewModelTests { onCompletion(.success(false)) } - let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, searchQueryPublisher: searchQueryPublisher, stores: stores) + let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) // When await viewModel.onRefreshAction() @@ -498,7 +487,6 @@ struct BookingListViewModelTests { let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .today, - searchQueryPublisher: searchQueryPublisher, stores: MockStoresManager(sessionManager: .testingInstance), storage: storageManager, currentDate: testDate) @@ -524,7 +512,6 @@ struct BookingListViewModelTests { let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .upcoming, - searchQueryPublisher: searchQueryPublisher, stores: MockStoresManager(sessionManager: .testingInstance), storage: storageManager, currentDate: testDate) @@ -554,7 +541,6 @@ struct BookingListViewModelTests { let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, - searchQueryPublisher: searchQueryPublisher, stores: MockStoresManager(sessionManager: .testingInstance), storage: storageManager, currentDate: testDate) @@ -582,7 +568,6 @@ struct BookingListViewModelTests { let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, - searchQueryPublisher: searchQueryPublisher, stores: MockStoresManager(sessionManager: .testingInstance), storage: storageManager, currentDate: testDate) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingSearchViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingSearchViewModelTests.swift index 1499a1f8a0e..362dc6fc3c2 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingSearchViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingSearchViewModelTests.swift @@ -30,40 +30,6 @@ struct BookingSearchViewModelTests { #expect(viewModel.currentSearchQuery == "test query") } - @Test func search_results_are_cleared_when_query_becomes_empty() async throws { - // Given - let searchQuerySubject = PassthroughSubject() - let stores = MockStoresManager(sessionManager: .testingInstance) - let booking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 1, startDate: Date()) - stores.whenReceivingAction(ofType: BookingAction.self) { action in - guard case let .searchBookings(_, _, _, _, _, _, onCompletion) = action else { - return - } - onCompletion(.success([booking])) - } - - let viewModel = BookingSearchViewModel( - siteID: sampleSiteID, - type: .all, - searchQueryPublisher: searchQuerySubject.eraseToAnyPublisher(), - stores: stores - ) - - // When - perform search - searchQuerySubject.send("test") - try await Task.sleep(nanoseconds: 400_000_000) // Wait for debounce + search - - #expect(viewModel.searchResults.count == 1) - - // Clear query - searchQuerySubject.send("") - try await Task.sleep(nanoseconds: 400_000_000) - - // Then - #expect(viewModel.searchResults.isEmpty) - #expect(viewModel.isSearching == false) - } - // MARK: - Search action @Test func search_bookings_is_dispatched_when_query_is_not_empty() async throws { From f440191c7c74cbc26054ca7ea3971e98985c41c9 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Mon, 13 Oct 2025 16:31:35 +0700 Subject: [PATCH 13/14] Move searchable to the list view --- .../Bookings/BookingList/BookingListContainerView.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift index b3b654a2b30..61d290b06b3 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift @@ -22,17 +22,15 @@ struct BookingListContainerView: View { searchViewModel: viewModel.searchViewModel(for: tab), selectedBooking: $selectedBooking ) + .searchable(text: $viewModel.searchQuery, + isPresented: $isSearching, + prompt: Localization.searchPrompt) .tag(tab) } } .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) { From a721ac5b2601203c0c0fdb37485619f50c535ad5 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Mon, 13 Oct 2025 21:29:54 +0700 Subject: [PATCH 14/14] Revert "Move searchable to the list view" This reverts commit f440191c7c74cbc26054ca7ea3971e98985c41c9. --- .../Bookings/BookingList/BookingListContainerView.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift index 61d290b06b3..b3b654a2b30 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerView.swift @@ -22,15 +22,17 @@ struct BookingListContainerView: View { searchViewModel: viewModel.searchViewModel(for: tab), selectedBooking: $selectedBooking ) - .searchable(text: $viewModel.searchQuery, - isPresented: $isSearching, - prompt: Localization.searchPrompt) .tag(tab) } } .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) {