From 11abe198a09251e95b65d9f615242375716e1e5d Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Fri, 28 Nov 2025 10:31:40 +0100 Subject: [PATCH 1/4] POC --- .../BookingListContainerViewModel.swift | 20 ++++++++++++++++--- .../BookingList/BookingListView.swift | 4 ++-- .../BookingList/BookingListViewModel.swift | 11 ++++++++-- .../BookingList/BookingSearchViewModel.swift | 2 +- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift index a5bf4ca30f9..be0224757cc 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift @@ -89,14 +89,28 @@ final class BookingListContainerViewModel: ObservableObject { restorePersistedFilters() } + func pullToRefresh() async { + async let today = todayListViewModel.onRefreshAction() + async let upcoming = upcomingListViewModel.onRefreshAction(reason: "pull-to-refresh") + async let all = allListViewModel.onRefreshAction(reason: "pull-to-refresh") + _ = await (today, upcoming, all) + } + func listViewModel(for tab: BookingListTab) -> BookingListViewModel { + + + + todayListViewModel.parent = self + upcomingListViewModel.parent = self + allListViewModel.parent = self + switch tab { case .today: - todayListViewModel + return todayListViewModel case .upcoming: - upcomingListViewModel + return upcomingListViewModel case .all: - allListViewModel + return allListViewModel } } diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift index f641db640ce..9121c85db46 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -43,14 +43,14 @@ private extension BookingListView { switch viewModel.syncState { case .empty: emptyStateView(isSearching: false) { - await viewModel.onRefreshAction() + await viewModel.onRefreshAllAction() } case .syncingFirstPage: loadingView case .results: bookingList(with: viewModel.bookings, onNextPage: { viewModel.onLoadNextPageAction() }, - onRefresh: { await viewModel.onRefreshAction() }) + onRefresh: { await viewModel.onRefreshAllAction() }) } } .overlay(alignment: .bottom) { diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift index a071bf2d506..26dc99e76f4 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -14,6 +14,8 @@ final class BookingListViewModel: ObservableObject { @Published private(set) var hasFilters = false + weak var parent: BookingListContainerViewModel? + var emptyStateTitle: String { type.emptyStateTitle(hasFilters: hasFilters) } @@ -58,6 +60,7 @@ final class BookingListViewModel: ObservableObject { init(siteID: Int64, type: BookingListTab, + parent: BookingListContainerViewModel? = nil, stores: StoresManager = ServiceLocator.stores, storage: StorageManagerType = ServiceLocator.storageManager, currentDate: Date = Date()) { @@ -95,11 +98,15 @@ final class BookingListViewModel: ObservableObject { paginationTracker.ensureNextPageIsSynced() } + func onRefreshAllAction() async { + await parent?.pullToRefresh() + } + /// Called when the user pulls down the list to refresh. @MainActor - func onRefreshAction() async { + func onRefreshAction2(reason: String? = nil) async { await withCheckedContinuation { continuation in - paginationTracker.resync(reason: Self.refreshCacheReason) { + paginationTracker.resync(reason: reason ?? Self.refreshCacheReason) { continuation.resume(returning: ()) } } diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift index 66f902aedd4..c60221837f1 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift @@ -59,7 +59,7 @@ final class BookingSearchViewModel: ObservableObject { /// Called when the user pulls down the list to refresh. @MainActor - func onRefreshAction() async { + func onRefreshAction(reason: String? = nil) async { await withCheckedContinuation { continuation in searchPaginationTracker.resync(reason: nil) { continuation.resume(returning: ()) From e1ea486a4d142674e81c4a1686696b43862a731a Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Fri, 28 Nov 2025 12:04:25 +0100 Subject: [PATCH 2/4] Make reviewable --- .../Yosemite/Actions/BookingAction.swift | 8 +++++ .../Yosemite/Stores/BookingStore.swift | 26 +++++++++++------ .../BookingListContainerViewModel.swift | 29 +++++++++++++++---- .../BookingList/BookingListView.swift | 4 +-- .../BookingList/BookingListViewModel.swift | 9 +++--- 5 files changed, 56 insertions(+), 20 deletions(-) diff --git a/Modules/Sources/Yosemite/Actions/BookingAction.swift b/Modules/Sources/Yosemite/Actions/BookingAction.swift index 5f710654e74..f386d962beb 100644 --- a/Modules/Sources/Yosemite/Actions/BookingAction.swift +++ b/Modules/Sources/Yosemite/Actions/BookingAction.swift @@ -103,4 +103,12 @@ public enum BookingAction: Action { bookingID: Int64, note: String, onCompletion: (Error?) -> Void) + + + /// Clears the booking cache. + /// + /// - Parameter siteID: The site ID of the booking. + /// - Parameter onCompletion: Called when clear completes. + case clearBookingsCache(siteID: Int64, + onCompletion: () -> Void) } diff --git a/Modules/Sources/Yosemite/Stores/BookingStore.swift b/Modules/Sources/Yosemite/Stores/BookingStore.swift index 4753075c4b2..78d1fff21e0 100644 --- a/Modules/Sources/Yosemite/Stores/BookingStore.swift +++ b/Modules/Sources/Yosemite/Stores/BookingStore.swift @@ -89,6 +89,8 @@ public class BookingStore: Store { note: note, onCompletion: onCompletion ) + case .clearBookingsCache(siteID: let siteID, onCompletion: let onCompletion): + clearBookingsCache(siteID: siteID, onCompletion: onCompletion) } } } @@ -205,12 +207,12 @@ private extension BookingStore { /// Returns results immediately without saving to storage. /// func searchBookings(siteID: Int64, - searchQuery: String, - pageNumber: Int, - pageSize: Int, - filters: BookingFilters?, - order: BookingsRemote.Order, - onCompletion: @escaping (Result<[Booking], Error>) -> Void) { + searchQuery: String, + pageNumber: Int, + pageSize: Int, + filters: BookingFilters?, + order: BookingsRemote.Order, + onCompletion: @escaping (Result<[Booking], Error>) -> Void) { Task { @MainActor in do { let bookings = try await remote.loadAllBookings(for: siteID, @@ -271,9 +273,9 @@ private extension BookingStore { /// Synchronizes booking resources for the specified site. /// func synchronizeResources(siteID: Int64, - pageNumber: Int, - pageSize: Int, - onCompletion: @escaping (Result) -> Void) { + pageNumber: Int, + pageSize: Int, + onCompletion: @escaping (Result) -> Void) { Task { @MainActor in do { let resources = try await remote.fetchResources( @@ -461,6 +463,12 @@ private extension BookingStore { } } } + + func clearBookingsCache(siteID: Int64, onCompletion: @escaping () -> Void) { + storageManager.performAndSave({ storage in + storage.deleteBookings(siteID: siteID) + }, completion: onCompletion, on: .main) + } } diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift index be0224757cc..afb00636875 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift @@ -7,6 +7,11 @@ final class BookingListContainerViewModel: ObservableObject { private let siteID: Int64 private let stores: StoresManager + private lazy var allTabViewModels: [BookingListViewModel] = [ + todayListViewModel, + upcomingListViewModel, + allListViewModel + ] private let todayListViewModel: BookingListViewModel private let upcomingListViewModel: BookingListViewModel private let allListViewModel: BookingListViewModel @@ -89,11 +94,25 @@ final class BookingListContainerViewModel: ObservableObject { restorePersistedFilters() } - func pullToRefresh() async { - async let today = todayListViewModel.onRefreshAction() - async let upcoming = upcomingListViewModel.onRefreshAction(reason: "pull-to-refresh") - async let all = allListViewModel.onRefreshAction(reason: "pull-to-refresh") - _ = await (today, upcoming, all) + @MainActor + func pullToRefresh(on tab: BookingListTab) async { + await withCheckedContinuation { continuation in + let action = BookingAction.clearBookingsCache(siteID: siteID) { + continuation.resume() + } + stores.dispatch(action) + } + + // Launch all tab refreshes in parallel and wait for all to complete + await withTaskGroup(of: Void.self) { group in + for viewModel in allTabViewModels { + group.addTask { @MainActor in + await viewModel.onRefreshSelfAction( + reason: BookingListViewModel.siblingRefreshReason + ) + } + } + } } func listViewModel(for tab: BookingListTab) -> BookingListViewModel { diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift index 9121c85db46..f641db640ce 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -43,14 +43,14 @@ private extension BookingListView { switch viewModel.syncState { case .empty: emptyStateView(isSearching: false) { - await viewModel.onRefreshAllAction() + await viewModel.onRefreshAction() } case .syncingFirstPage: loadingView case .results: bookingList(with: viewModel.bookings, onNextPage: { viewModel.onLoadNextPageAction() }, - onRefresh: { await viewModel.onRefreshAllAction() }) + onRefresh: { await viewModel.onRefreshAction() }) } } .overlay(alignment: .bottom) { diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift index 26dc99e76f4..506a9d541b7 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -34,6 +34,7 @@ final class BookingListViewModel: ObservableObject { private static let refreshCacheReason = "refresh-cache" private static let reorderReason = "reorder" + static let siblingRefreshReason = "sibling-refresh" /// Keeps track of the current state of the syncing @Published private(set) var syncState: SyncState = .empty @@ -98,13 +99,13 @@ final class BookingListViewModel: ObservableObject { paginationTracker.ensureNextPageIsSynced() } - func onRefreshAllAction() async { - await parent?.pullToRefresh() + /// Called when the user pulls down the list to refresh. + func onRefreshAction() async { + await parent?.pullToRefresh(on: type) } - /// Called when the user pulls down the list to refresh. @MainActor - func onRefreshAction2(reason: String? = nil) async { + func onRefreshSelfAction(reason: String? = nil) async { await withCheckedContinuation { continuation in paginationTracker.resync(reason: reason ?? Self.refreshCacheReason) { continuation.resume(returning: ()) From 21b3d5ea71042f6395bde16cf2187b915b1a3e7b Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Fri, 28 Nov 2025 14:18:54 +0100 Subject: [PATCH 3/4] Update tests --- .../BookingListContainerViewModel.swift | 63 +++++++++---------- .../BookingList/BookingListViewModel.swift | 8 ++- .../Bookings/BookingListViewModelTests.swift | 27 ++++---- 3 files changed, 51 insertions(+), 47 deletions(-) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift index afb00636875..f92f6dc1df2 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift @@ -7,11 +7,6 @@ final class BookingListContainerViewModel: ObservableObject { private let siteID: Int64 private let stores: StoresManager - private lazy var allTabViewModels: [BookingListViewModel] = [ - todayListViewModel, - upcomingListViewModel, - allListViewModel - ] private let todayListViewModel: BookingListViewModel private let upcomingListViewModel: BookingListViewModel private let allListViewModel: BookingListViewModel @@ -29,6 +24,11 @@ final class BookingListContainerViewModel: ObservableObject { private var searchQuerySubscription: AnyCancellable? private var sortBySubscription: AnyCancellable? + private lazy var allTabViewModels: [BookingListViewModel] = [ + todayListViewModel, + upcomingListViewModel, + allListViewModel + ] private var filters = BookingFiltersViewModel.Filters() @@ -92,37 +92,13 @@ final class BookingListContainerViewModel: ObservableObject { } restorePersistedFilters() - } - @MainActor - func pullToRefresh(on tab: BookingListTab) async { - await withCheckedContinuation { continuation in - let action = BookingAction.clearBookingsCache(siteID: siteID) { - continuation.resume() - } - stores.dispatch(action) - } - - // Launch all tab refreshes in parallel and wait for all to complete - await withTaskGroup(of: Void.self) { group in - for viewModel in allTabViewModels { - group.addTask { @MainActor in - await viewModel.onRefreshSelfAction( - reason: BookingListViewModel.siblingRefreshReason - ) - } - } - } + todayListViewModel.refreshCoordinator = self + upcomingListViewModel.refreshCoordinator = self + allListViewModel.refreshCoordinator = self } func listViewModel(for tab: BookingListTab) -> BookingListViewModel { - - - - todayListViewModel.parent = self - upcomingListViewModel.parent = self - allListViewModel.parent = self - switch tab { case .today: return todayListViewModel @@ -214,6 +190,29 @@ private extension BookingListContainerViewModel { } } +extension BookingListContainerViewModel: BookingListsRefreshCoordinating { + @MainActor + func refreshAllLists() async { + await withCheckedContinuation { continuation in + let action = BookingAction.clearBookingsCache(siteID: siteID) { + continuation.resume() + } + stores.dispatch(action) + } + + // Launch all tab refreshes in parallel and wait for all to complete + await withTaskGroup(of: Void.self) { group in + for viewModel in allTabViewModels { + group.addTask { @MainActor in + await viewModel.onRefreshSelfAction( + reason: BookingListViewModel.siblingRefreshReason + ) + } + } + } + } +} + private extension BookingListContainerViewModel { enum Localization { static let filter = NSLocalizedString( diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift index 506a9d541b7..0033d9dfb3c 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -5,6 +5,10 @@ import Combine import protocol Storage.StorageManagerType import class Networking.BookingsRemote +protocol BookingListsRefreshCoordinating: AnyObject { + func refreshAllLists() async +} + /// View model for `BookingListView` final class BookingListViewModel: ObservableObject { @@ -14,7 +18,7 @@ final class BookingListViewModel: ObservableObject { @Published private(set) var hasFilters = false - weak var parent: BookingListContainerViewModel? + weak var refreshCoordinator: BookingListsRefreshCoordinating? var emptyStateTitle: String { type.emptyStateTitle(hasFilters: hasFilters) @@ -101,7 +105,7 @@ final class BookingListViewModel: ObservableObject { /// Called when the user pulls down the list to refresh. func onRefreshAction() async { - await parent?.pullToRefresh(on: type) + await refreshCoordinator?.refreshAllLists() } @MainActor diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift index c294f081b43..c3a0113692f 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift @@ -307,7 +307,7 @@ struct BookingListViewModelTests { let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) // When - await viewModel.onRefreshAction() + await viewModel.onRefreshSelfAction() // Then #expect(skip == 0) @@ -441,26 +441,18 @@ struct BookingListViewModelTests { #expect(actionCallCount == 2, "Should have made two API calls") } - @Test func on_refresh_action_clears_cache() async { + @Test func on_refresh_action_calls_refreshcoordiantor() async { // Given let stores = MockStoresManager(sessionManager: .testingInstance) - var capturedShouldClearCache: Bool? - - stores.whenReceivingAction(ofType: BookingAction.self) { action in - guard case let .synchronizeBookings(_, _, _, _, _, shouldClearCache, onCompletion) = action else { - return - } - capturedShouldClearCache = shouldClearCache - onCompletion(.success(false)) - } - + let mockRefresher = MockBookingListsRefreshCoordinating() let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) + viewModel.refreshCoordinator = mockRefresher // When await viewModel.onRefreshAction() // Then - #expect(capturedShouldClearCache == true, "Refresh action should clear cache") + #expect(mockRefresher.refreshAllListsCalled) } // MARK: - Local storage filtering @@ -719,3 +711,12 @@ private extension BookingListViewModelTests { return Booking.fake().copy(siteID: siteID ?? self.sampleSiteID, bookingID: id, startDate: startDate) } } + + +class MockBookingListsRefreshCoordinating: BookingListsRefreshCoordinating { + private(set) var refreshAllListsCalled = false + + func refreshAllLists() async { + refreshAllListsCalled = true + } +} From 38331472b5951c6e7640abfa7d747fb64b0588b5 Mon Sep 17 00:00:00 2001 From: Adam Borbas Date: Mon, 1 Dec 2025 11:57:37 +0100 Subject: [PATCH 4/4] Review findings --- .../BookingList/BookingListContainerViewModel.swift | 8 ++++---- .../Bookings/BookingList/BookingListViewModel.swift | 4 ++-- .../Bookings/BookingList/BookingSearchViewModel.swift | 2 +- .../ViewRelated/Bookings/BookingListViewModelTests.swift | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift index f92f6dc1df2..f221cab485f 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListContainerViewModel.swift @@ -101,11 +101,11 @@ final class BookingListContainerViewModel: ObservableObject { func listViewModel(for tab: BookingListTab) -> BookingListViewModel { switch tab { case .today: - return todayListViewModel + todayListViewModel case .upcoming: - return upcomingListViewModel + upcomingListViewModel case .all: - return allListViewModel + allListViewModel } } @@ -204,7 +204,7 @@ extension BookingListContainerViewModel: BookingListsRefreshCoordinating { await withTaskGroup(of: Void.self) { group in for viewModel in allTabViewModels { group.addTask { @MainActor in - await viewModel.onRefreshSelfAction( + await viewModel.reloadData( reason: BookingListViewModel.siblingRefreshReason ) } diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift index 0033d9dfb3c..f32a5b9b736 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -109,9 +109,9 @@ final class BookingListViewModel: ObservableObject { } @MainActor - func onRefreshSelfAction(reason: String? = nil) async { + func reloadData(reason: String = BookingListViewModel.refreshCacheReason) async { await withCheckedContinuation { continuation in - paginationTracker.resync(reason: reason ?? Self.refreshCacheReason) { + paginationTracker.resync(reason: reason) { continuation.resume(returning: ()) } } diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift index c60221837f1..66f902aedd4 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingSearchViewModel.swift @@ -59,7 +59,7 @@ final class BookingSearchViewModel: ObservableObject { /// Called when the user pulls down the list to refresh. @MainActor - func onRefreshAction(reason: String? = nil) async { + func onRefreshAction() async { await withCheckedContinuation { continuation in searchPaginationTracker.resync(reason: nil) { continuation.resume(returning: ()) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift index c3a0113692f..834c61052fa 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift @@ -307,7 +307,7 @@ struct BookingListViewModelTests { let viewModel = BookingListViewModel(siteID: sampleSiteID, type: .all, stores: stores) // When - await viewModel.onRefreshSelfAction() + await viewModel.reloadData() // Then #expect(skip == 0)