From 4f9a4127991e21c76ef03e53aa93eb333050f126 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Thu, 25 Sep 2025 17:18:27 +0700 Subject: [PATCH 1/4] Add booking list view and view model --- .../BookingList/BookingListView.swift | 13 ++++++++++++ .../BookingList/BookingListViewModel.swift | 19 +++++++++++++++++ .../Classes/Bookings/BookingsTabView.swift | 21 +++++++++++++------ 3 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 WooCommerce/Classes/Bookings/BookingList/BookingListView.swift create mode 100644 WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift new file mode 100644 index 00000000000..c4dccd565bf --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct BookingListView: View { + @ObservedObject private var viewModel: BookingListViewModel + + init(viewModel: BookingListViewModel) { + self.viewModel = viewModel + } + + var body: some View { + Text("Hello, World!") + } +} diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift new file mode 100644 index 00000000000..1b0d565afac --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -0,0 +1,19 @@ +import Foundation +import Yosemite +import protocol Storage.StorageManagerType + +/// View model for `BookingListView` +final class BookingListViewModel: ObservableObject { + private let siteID: Int64 + private let stores: StoresManager + private let storage: StorageManagerType + + init(siteID: Int64, + stores: StoresManager = ServiceLocator.stores, + storage: StorageManagerType = ServiceLocator.storageManager) { + self.siteID = siteID + self.stores = stores + self.storage = storage + } +} + diff --git a/WooCommerce/Classes/Bookings/BookingsTabView.swift b/WooCommerce/Classes/Bookings/BookingsTabView.swift index 32c9e80c28a..1e3c59ed7c9 100644 --- a/WooCommerce/Classes/Bookings/BookingsTabView.swift +++ b/WooCommerce/Classes/Bookings/BookingsTabView.swift @@ -3,9 +3,9 @@ import SwiftUI /// Hosting view for `BookingsTabView` /// final class BookingsTabViewHostingController: UIHostingController { - // periphery: ignore + init(siteID: Int64) { - super.init(rootView: BookingsTabView()) + super.init(rootView: BookingsTabView(siteID: siteID)) configureTabBarItem() } @@ -17,16 +17,19 @@ final class BookingsTabViewHostingController: UIHostingController Date: Thu, 25 Sep 2025 18:28:46 +0700 Subject: [PATCH 2/4] Add basic logic for BookingListViewModel --- .../BookingList/BookingListViewModel.swift | 121 +++++++++++++++++- 1 file changed, 120 insertions(+), 1 deletion(-) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift index 1b0d565afac..a49a93de064 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -1,19 +1,138 @@ +// periphery:ignore:all import Foundation import Yosemite import protocol Storage.StorageManagerType /// View model for `BookingListView` final class BookingListViewModel: ObservableObject { + + @Published private(set) var bookings: [Booking] = [] + private let siteID: Int64 private let stores: StoresManager private let storage: StorageManagerType - + + /// Keeps track of the current state of the syncing + @Published private(set) var syncState: SyncState = .empty + + /// Tracks if the infinite scroll indicator should be displayed. + @Published private(set) var shouldShowBottomActivityIndicator = false + + /// Supports infinite scroll. + private let paginationTracker: PaginationTracker + private let pageFirstIndex: Int = PaginationTracker.Defaults.pageFirstIndex + + /// Booking ResultsController. + private lazy var resultsController: ResultsController = { + let predicate = NSPredicate(format: "siteID == %lld", siteID) + let sortDescriptorByDate = NSSortDescriptor(key: "dateCreated", ascending: false) + let resultsController = ResultsController(storageManager: storage, + matching: predicate, + sortedBy: [sortDescriptorByDate]) + return resultsController + }() + init(siteID: Int64, stores: StoresManager = ServiceLocator.stores, storage: StorageManagerType = ServiceLocator.storageManager) { self.siteID = siteID self.stores = stores self.storage = storage + self.paginationTracker = PaginationTracker(pageFirstIndex: pageFirstIndex) + + configureResultsController() + configurePaginationTracker() + } + + /// Called when loading the first page of bookings. + func loadBookings() { + paginationTracker.syncFirstPage() + } + + /// Called when the next page should be loaded. + func onLoadNextPageAction() { + paginationTracker.ensureNextPageIsSynced() + } + + /// Called when the user pulls down the list to refresh. + /// - Parameter completion: called when the refresh completes. + func onRefreshAction(completion: @escaping () -> Void) { + paginationTracker.resync(reason: nil) { + completion() + } + } +} + +// MARK: Configuration + +private extension BookingListViewModel { + func configurePaginationTracker() { + paginationTracker.delegate = self + } + + /// Performs initial fetch from storage and updates results. + func configureResultsController() { + resultsController.onDidChangeContent = { [weak self] in + self?.updateResults() + } + resultsController.onDidResetContent = { [weak self] in + self?.updateResults() + } + do { + try resultsController.performFetch() + updateResults() + } catch { + ServiceLocator.crashLogging.logError(error) + } + } + + /// Updates row view models and sync state. + func updateResults() { + bookings = resultsController.fetchedObjects + transitionToResultsUpdatedState() } } +extension BookingListViewModel: PaginationTrackerDelegate { + func sync(pageNumber: Int, pageSize: Int, reason: String?, onCompletion: SyncCompletion?) { + transitionToSyncingState() + let action = BookingAction.synchronizeBookings(siteID: siteID, pageNumber: pageNumber, pageSize: pageSize) { [weak self] result in + switch result { + case .success(let hasNextPage): + onCompletion?(.success(hasNextPage)) + + case .failure(let error): + DDLogError("⛔️ Error synchronizing bookings: \(error)") + onCompletion?(.failure(error)) + } + + self?.updateResults() + } + stores.dispatch(action) + } +} + +// MARK: State Machine + +extension BookingListViewModel { + /// Represents possible states for syncing bookings. + enum SyncState: Equatable { + case syncingFirstPage + case results + case empty + } + + /// Update states for sync from remote. + func transitionToSyncingState() { + shouldShowBottomActivityIndicator = true + if bookings.isEmpty { + syncState = .syncingFirstPage + } + } + + /// Update states after sync is complete. + func transitionToResultsUpdatedState() { + shouldShowBottomActivityIndicator = false + syncState = bookings.isNotEmpty ? .results : .empty + } +} From c1195e2e235928ea4ee46211a383f38c4d6b68c7 Mon Sep 17 00:00:00 2001 From: Huong Do Date: Thu, 25 Sep 2025 18:28:59 +0700 Subject: [PATCH 3/4] Add tests for BookingListViewModel --- .../WooCommerce.xcodeproj/project.pbxproj | 12 + .../Bookings/BookingListViewModelTests.swift | 309 ++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 7b6ef7bb5a6..308fd1e7180 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -2838,6 +2838,7 @@ DED039292BC7A04B005D0571 /* StorePerformanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED039282BC7A04B005D0571 /* StorePerformanceView.swift */; }; DED0392B2BC7A076005D0571 /* StorePerformanceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED0392A2BC7A076005D0571 /* StorePerformanceViewModel.swift */; }; DED0392D2BC7DAFD005D0571 /* StatsTimeRangePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED0392C2BC7DAFD005D0571 /* StatsTimeRangePicker.swift */; }; + DED1E3172E8556270089909C /* BookingListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED1E3152E8556270089909C /* BookingListViewModelTests.swift */; }; DED91DFA2AD78A3A00CDCC53 /* BlazeCampaignItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED91DF92AD78A3A00CDCC53 /* BlazeCampaignItemView.swift */; }; DED9740B2AD7D09E00122EB4 /* BlazeCampaignListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED9740A2AD7D09E00122EB4 /* BlazeCampaignListView.swift */; }; DED9740D2AD7D27000122EB4 /* BlazeCampaignListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DED9740C2AD7D27000122EB4 /* BlazeCampaignListViewModel.swift */; }; @@ -6062,6 +6063,7 @@ DED039282BC7A04B005D0571 /* StorePerformanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorePerformanceView.swift; sourceTree = ""; }; DED0392A2BC7A076005D0571 /* StorePerformanceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorePerformanceViewModel.swift; sourceTree = ""; }; DED0392C2BC7DAFD005D0571 /* StatsTimeRangePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsTimeRangePicker.swift; sourceTree = ""; }; + DED1E3152E8556270089909C /* BookingListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookingListViewModelTests.swift; sourceTree = ""; }; DED91DF92AD78A3A00CDCC53 /* BlazeCampaignItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeCampaignItemView.swift; sourceTree = ""; }; DED9740A2AD7D09E00122EB4 /* BlazeCampaignListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeCampaignListView.swift; sourceTree = ""; }; DED9740C2AD7D27000122EB4 /* BlazeCampaignListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlazeCampaignListViewModel.swift; sourceTree = ""; }; @@ -12981,6 +12983,7 @@ D816DDBA22265D8000903E59 /* ViewRelated */ = { isa = PBXGroup; children = ( + DED1E3162E8556270089909C /* Bookings */, 2D7A3E212E7891C400C46401 /* CIAB */, 864059FE2C6F67A000DA04DC /* Custom Fields */, 86023FAB2B16D80E00A28F07 /* Themes */, @@ -13710,6 +13713,14 @@ path = StoreStats; sourceTree = ""; }; + DED1E3162E8556270089909C /* Bookings */ = { + isa = PBXGroup; + children = ( + DED1E3152E8556270089909C /* BookingListViewModelTests.swift */, + ); + path = Bookings; + sourceTree = ""; + }; DED91DF72AD78A0C00CDCC53 /* Blaze */ = { isa = PBXGroup; children = ( @@ -17223,6 +17234,7 @@ DE78DE442B2846AF002E58DE /* ThemesCarouselViewModelTests.swift in Sources */, DE4D23B029B1D02A003A4B5D /* WPCom2FALoginViewModelTests.swift in Sources */, 03B9E52F2A150EED005C77F5 /* MockCardReaderSupportDeterminer.swift in Sources */, + DED1E3172E8556270089909C /* BookingListViewModelTests.swift in Sources */, D8C11A6022E2479800D4A88D /* OrderPaymentDetailsViewModelTests.swift in Sources */, B622BC74289CF19400B10CEC /* WaitingTimeTrackerTests.swift in Sources */, 023EC2E224DA8BAB0021DA91 /* MockProductSKUValidationStoresManager.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift new file mode 100644 index 00000000000..46b627a42fb --- /dev/null +++ b/WooCommerce/WooCommerceTests/ViewRelated/Bookings/BookingListViewModelTests.swift @@ -0,0 +1,309 @@ +import Combine +import Foundation +import Testing +import Yosemite +import protocol Storage.StorageManagerType +import protocol Storage.StorageType +@testable import WooCommerce + +@MainActor +struct BookingListViewModelTests { + + private let sampleSiteID: Int64 = 322 + + /// Mock Storage: InMemory + private var storageManager: StorageManagerType + + /// View storage for tests + private var storage: StorageType { + storageManager.viewStorage + } + + init() { + storageManager = MockStorageManager() + } + + // MARK: - State transitions + + @Test func state_is_empty_without_any_actions() { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + var invocationCountOfLoadBookings = 0 + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case .synchronizeBookings = action else { + return + } + invocationCountOfLoadBookings += 1 + } + let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores) + + // Then + #expect(viewModel.syncState == .empty) + #expect(invocationCountOfLoadBookings == 0) + } + + @Test func synchronize_bookings_is_dispatched_upon_load_bookings() { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + var invocationCountOfLoadBookings = 0 + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case .synchronizeBookings = action else { + return + } + invocationCountOfLoadBookings += 1 + } + let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores) + + // When + viewModel.loadBookings() + + // Then + #expect(invocationCountOfLoadBookings == 1) + } + + @Test func state_is_syncing_first_page_upon_load_bookings_if_no_existing_booking_in_storage() { + // Given + let viewModel = BookingListViewModel(siteID: sampleSiteID) + + // When + viewModel.loadBookings() + + // Then + #expect(viewModel.syncState == .syncingFirstPage) + } + + @Test func state_is_results_upon_load_bookings_if_existing_bookings_in_storage() { + let existingBooking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 123) + insertBookings([existingBooking]) + let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: MockStoresManager(sessionManager: .testingInstance), storage: storageManager) + + // When + viewModel.loadBookings() + + // Then + #expect(viewModel.syncState == .results) + } + + @Test func state_is_results_after_load_bookings_with_nonempty_results() async { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + let booking = Booking.fake().copy(siteID: sampleSiteID) + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .synchronizeBookings(_, _, _, onCompletion) = action else { + return + } + self.insertBookings([booking]) + onCompletion(.success(true)) + } + let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores, storage: storageManager) + + var states = [BookingListViewModel.SyncState]() + await confirmation("State transitions") { confirmation in + var subscriptions: [AnyCancellable] = [] + var expectedStateCount = 0 + viewModel.$syncState + .removeDuplicates() + .sink { state in + states.append(state) + expectedStateCount += 1 + if expectedStateCount >= 3 { + confirmation() + } + } + .store(in: &subscriptions) + + // When + viewModel.loadBookings() + } + + // Then + #expect(states == [.empty, .syncingFirstPage, .results]) + } + + @Test func state_is_back_to_empty_after_load_bookings_with_empty_results() async { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .synchronizeBookings(_, _, _, onCompletion) = action else { + return + } + onCompletion(.success(false)) + } + let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores, storage: storageManager) + + var states = [BookingListViewModel.SyncState]() + await confirmation("State transitions") { confirmation in + var subscriptions: [AnyCancellable] = [] + var expectedStateCount = 0 + viewModel.$syncState + .removeDuplicates() + .sink { state in + states.append(state) + expectedStateCount += 1 + if expectedStateCount >= 3 { + confirmation() + } + } + .store(in: &subscriptions) + + // When + viewModel.loadBookings() + } + + // Then + #expect(states == [.empty, .syncingFirstPage, .empty]) + } + + @Test func it_loads_next_page_after_load_bookings_and_on_load_next_page_action_until_has_next_page_is_false() async { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + var invocationCountOfLoadBookings = 0 + let firstPageItems = [Booking](repeating: .fake().copy(siteID: sampleSiteID), count: 2) + let secondPageItems = [Booking](repeating: .fake().copy(siteID: sampleSiteID), count: 1) + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .synchronizeBookings(_, pageNumber, _, onCompletion) = action else { + return + } + invocationCountOfLoadBookings += 1 + let bookings = pageNumber == 1 ? firstPageItems: secondPageItems + self.insertBookings(bookings) + onCompletion(.success(pageNumber == 1 ? true : false)) + } + + let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores, storage: storageManager) + + var states = [BookingListViewModel.SyncState]() + await confirmation("State transitions") { confirmation in + var subscriptions: [AnyCancellable] = [] + var expectedStateCount = 0 + viewModel.$syncState + .removeDuplicates() + .sink { state in + states.append(state) + expectedStateCount += 1 + if expectedStateCount >= 3 { + confirmation() + } + } + .store(in: &subscriptions) + + // When + viewModel.loadBookings() // Syncs first page of bookings. + viewModel.onLoadNextPageAction() // Syncs next page of bookings. + viewModel.onLoadNextPageAction() // No more data to be synced. + } + + // Then + #expect(states == [.empty, .syncingFirstPage, .results]) + #expect(invocationCountOfLoadBookings == 2) + } + + // MARK: - Row view models + + @Test func booking_models_match_loaded_bookings() { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + let booking1 = Booking.fake().copy(siteID: sampleSiteID, bookingID: 9) + let booking2 = Booking.fake().copy(siteID: sampleSiteID, bookingID: 10) + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .synchronizeBookings(_, _, _, onCompletion) = action else { + return + } + self.insertBookings([booking1, booking2]) + onCompletion(.success(true)) + } + let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores, storage: storageManager) + + // When + viewModel.loadBookings() + + // Then + // ensure that the items are sorted correctly by dateCreated (descending) + #expect(viewModel.bookings.count == 2) + #expect(viewModel.bookings.contains { $0.bookingID == booking1.bookingID }) + #expect(viewModel.bookings.contains { $0.bookingID == booking2.bookingID }) + } + + @Test func booking_models_are_empty_when_loaded_bookings_are_empty() { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .synchronizeBookings(_, _, _, onCompletion) = action else { + return + } + onCompletion(.success(false)) + } + let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores, storage: storageManager) + + // When + viewModel.loadBookings() + + // Then + #expect(viewModel.bookings == []) + } + + @Test func booking_models_are_sorted_by_date_created() { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + let olderBooking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 1, dateCreated: Date(timeIntervalSince1970: 1000)) + let newerBooking = Booking.fake().copy(siteID: sampleSiteID, bookingID: 3, dateCreated: Date(timeIntervalSince1970: 2000)) + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .synchronizeBookings(_, _, _, onCompletion) = action else { + return + } + let items = [olderBooking, newerBooking] + self.insertBookings(items) + onCompletion(.success(false)) + } + let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores, storage: storageManager) + + // When + viewModel.loadBookings() + + // Then bookings are sorted by descending dateCreated + #expect(viewModel.bookings.count == 2) + #expect(viewModel.bookings[0].bookingID == newerBooking.bookingID) + #expect(viewModel.bookings[1].bookingID == olderBooking.bookingID) + } + + // MARK: - `onRefreshAction` + + @Test func on_refresh_action_resyncs_the_first_page() async { + // Given + let stores = MockStoresManager(sessionManager: .testingInstance) + var invocationCountOfLoadBookings = 0 + var skip: Int? + stores.whenReceivingAction(ofType: BookingAction.self) { action in + guard case let .synchronizeBookings(_, pageNumber, pageSize, onCompletion) = action else { + return + } + invocationCountOfLoadBookings += 1 + skip = pageNumber > 1 ? pageSize * (pageNumber - 1) : 0 + + onCompletion(.success(false)) + } + let viewModel = BookingListViewModel(siteID: sampleSiteID, stores: stores) + + // When + await confirmation("Refresh completion") { confirmation in + viewModel.onRefreshAction { + confirmation() + } + } + + // Then + #expect(skip == 0) + #expect(invocationCountOfLoadBookings == 1) + } +} + +private extension BookingListViewModelTests { + func insertBookings(_ readOnlyBookings: [Booking]) { + storageManager.performAndSave({ storage in + readOnlyBookings.forEach { booking in + let newBooking = storage.insertNewObject(ofType: StorageBooking.self) + newBooking.update(with: booking) + } + }, completion: {}, on: .main) + } +} From 2ebef2fe107c5203b25c8e810af5fc12816bf71c Mon Sep 17 00:00:00 2001 From: Huong Do Date: Fri, 26 Sep 2025 10:26:23 +0700 Subject: [PATCH 4/4] Ignore periphery warning for view model --- WooCommerce/Classes/Bookings/BookingList/BookingListView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift index c4dccd565bf..bcd9ff48e38 100644 --- a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -1,6 +1,7 @@ import SwiftUI struct BookingListView: View { + // periphery:ignore @ObservedObject private var viewModel: BookingListViewModel init(viewModel: BookingListViewModel) {