diff --git a/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift new file mode 100644 index 00000000000..bcd9ff48e38 --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListView.swift @@ -0,0 +1,14 @@ +import SwiftUI + +struct BookingListView: View { + // periphery:ignore + @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..a49a93de064 --- /dev/null +++ b/WooCommerce/Classes/Bookings/BookingList/BookingListViewModel.swift @@ -0,0 +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 + } +} 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= 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) + } +}